Redis: Cache ও Rate Limit

API কে দ্রুত আর নিরাপদ করা

রোডম্যাপের মাস ৫পড়তে প্রায় ১৫ মিনিট

Redis হলো memory তে চলা একটা অসাধারণ দ্রুত data store। প্রতি সেকেন্ডে লাখ operation, প্রতিটা এক millisecond এর নিচে। Backend এ এটা সুইস আর্মি ছুরির মতো: cache, rate limit, session, OTP, leaderboard, queue, pub/sub, distributed lock, সব এক জিনিসে। এই অধ্যায়ে Notes API তে Redis দিয়ে cache আর login এর rate limit বসাবো, আর দেখবো Redis এর data structure গুলো কোন কাজে লাগে।

#Setup

Terminal
docker run -d --name redis -p 6379:6379 redis:7-alpine
uv add redis
Python
# core/redis.py
from redis.asyncio import Redis
from app.core.config import get_settings

redis = Redis.from_url(get_settings().redis_url, decode_responses=True)

redis-cli দিয়ে সরাসরি দেখা যায়:

Terminal
docker exec -it redis redis-cli
127.0.0.1:6379> SET greeting "হ্যালো" EX 60
OK
127.0.0.1:6379> GET greeting
"হ্যালো"
127.0.0.1:6379> TTL greeting
(integer) 54

#Data structure: কোনটা কোন কাজে

Type কী দরকারি command ব্যবহার
String একটা value (লেখা, সংখ্যা, JSON) SET, GET, INCR, EXPIRE Cache, counter, OTP, lock
Hash একটা key এর ভেতরে field-value HSET, HGET, HGETALL, HINCRBY User এর ছোট profile, cart
List ক্রমানুসারে list LPUSH, RPOP, LRANGE সাধারণ queue, সর্বশেষ ১০টা জিনিস
Set Unique জিনিসের দল SADD, SISMEMBER, SCARD আজকের unique visitor, online user
Sorted set Score সহ, score অনুযায়ী সাজানো ZADD, ZRANGE, ZREVRANK Leaderboard, sliding window rate limit, সময় অনুযায়ী feed
Stream Append-only log, consumer group সহ XADD, XREADGROUP হালকা message queue, event
Geo Latitude/longitude GEOADD, GEOSEARCH কাছের rider বা restaurant খোঁজা
Pub/Sub Message প্রচার (রাখে না) PUBLISH, SUBSCRIBE WebSocket server গুলোর মধ্যে বার্তা
Python
# Counter: পেজ কতবার দেখা হলো
await redis.incr("views:note:42")

# Sorted set: leaderboard
await redis.zadd("leaderboard:october", {"rahim": 1250, "nusrat": 1890, "karim": 990})
top3 = await redis.zrevrange("leaderboard:october", 0, 2, withscores=True)

# Set: আজ কে কে এসেছে
await redis.sadd("active:2026-10-29", user_id)
count = await redis.scard("active:2026-10-29")

# Geo: ২ km এর মধ্যে rider
await redis.geoadd("riders", (90.4125, 23.8103, "rider:7"))
nearby = await redis.geosearch("riders", longitude=90.41, latitude=23.81, radius=2, unit="km")

#Cache: Notes API তে

Caching অধ্যায়ের cache-aside pattern, একটা পুনর্ব্যবহারযোগ্য রূপে:

Python
# core/cache.py
import json
import random
from collections.abc import Awaitable, Callable
from typing import TypeVar
from pydantic import BaseModel, TypeAdapter

T = TypeVar("T")


async def cached(key: str, ttl: int, loader: Callable[[], Awaitable[T]], adapter: TypeAdapter[T]) -> T:
    raw = await redis.get(key)
    if raw is not None:
        return adapter.validate_json(raw)                   # hit
    value = await loader()                                   # miss → database
    jitter = random.randint(0, ttl // 10)                    # সব key একসাথে শেষ না হোক
    await redis.set(key, adapter.dump_json(value), ex=ttl + jitter)
    return value
Python
# services/note_service.py
STATS_ADAPTER = TypeAdapter(NoteStats)

async def stats(self, user_id: int) -> NoteStats:
    return await cached(
        key=f"notes:stats:v1:{user_id}",                    # key তে user id!
        ttl=300,
        loader=lambda: self.notes.stats_for_user(user_id),
        adapter=STATS_ADAPTER,
    )

async def create(self, user_id: int, data: NoteCreate) -> Note:
    note = ...                                               # save
    await redis.delete(f"notes:stats:v1:{user_id}")         # পুরনো stats বাতিল
    return note

Key এর নামের নিয়ম: জিনিস:উপ-জিনিস:version:id। : দিয়ে ভাগ করলে পড়তে আর খুঁজতে সহজ, আর version (v1) বদলে দিলে পুরনো সব cache একবারে অকেজো।

#Rate limiting: login এর সুরক্ষা

কেউ একটা account এ লাখ লাখ password আন্দাজ করে চেষ্টা করতে পারে (brute force)। সীমা দাও: একটা email এ ১৫ মিনিটে সর্বোচ্চ ৫টা ভুল চেষ্টা।

#সবচেয়ে সহজ: fixed window counter

Python
async def check_rate_limit(key: str, limit: int, window_seconds: int) -> None:
    count = await redis.incr(key)                 # atomic: একসাথে ১০০ request এলেও ঠিক গোনা
    if count == 1:
        await redis.expire(key, window_seconds)   # প্রথমবারে মেয়াদ বসাও
    if count > limit:
        ttl = await redis.ttl(key)
        raise TooManyRequestsError(f"অনেকবার চেষ্টা হয়েছে, {ttl} সেকেন্ড পর আবার চেষ্টা করুন")
Python
@router.post("/login")
async def login(data: LoginIn, request: Request, svc: AuthServiceDep):
    email = data.email.lower()
    await check_rate_limit(f"rl:login:email:{email}", limit=5, window_seconds=900)
    await check_rate_limit(f"rl:login:ip:{client_ip(request)}", limit=50, window_seconds=900)
    return await svc.login(email, data.password)

দুই স্তরে: email প্রতি (একটা account বাঁচাতে) আর IP প্রতি (একজন অনেক account এ চেষ্টা করলে)। IP এর সীমা উদার রাখো, কারণ CGNAT এর পেছনে অনেক নিরীহ user একই IP তে থাকে।

INCR কেন? কারণ এটা atomic: Redis একসাথে একটাই command চালায়, তাই দুইটা request একসাথে এলেও দুইবারই ঠিকভাবে গোনা হয়। Python এ get করে +1 করে set করলে মাঝখানে আরেক request ঢুকে গোনা ভুল হতো (race condition)।

Fixed window এর দুর্বলতা আর আরও ভালো algorithm (sliding window, token bucket) Rate Limiting অধ্যায়ে।

#OTP: মেয়াদি জিনিস

Python
import secrets
import hashlib

async def send_otp(phone: str) -> None:
    await check_rate_limit(f"rl:otp:{phone}", limit=3, window_seconds=600)   # ১০ মিনিটে ৩টা
    code = f"{secrets.randbelow(1_000_000):06d}"                             # নিরাপদ random
    digest = hashlib.sha256(code.encode()).hexdigest()
    await redis.set(f"otp:{phone}", digest, ex=180)                           # ৩ মিনিট
    await redis.delete(f"otp:tries:{phone}")
    await sms.send(phone, f"আপনার কোড {code}। কাউকে বলবেন না।")


async def verify_otp(phone: str, code: str) -> bool:
    tries = await redis.incr(f"otp:tries:{phone}")
    await redis.expire(f"otp:tries:{phone}", 180)
    if tries > 5:
        raise TooManyRequestsError("অনেকবার ভুল হয়েছে, নতুন কোড নিন")
    stored = await redis.get(f"otp:{phone}")
    ok = stored is not None and secrets.compare_digest(stored, hashlib.sha256(code.encode()).hexdigest())
    if ok:
        await redis.delete(f"otp:{phone}", f"otp:tries:{phone}")             # একবারই ব্যবহার
    return ok
  • random না, secrets: random আন্দাজ করা যায়
  • OTP hash করে রাখা: Redis এ কেউ ঢুকলেও কোড পাবে না
  • ভুল চেষ্টার সীমা: না হলে ১০ লাখ সম্ভাবনা কিছুক্ষণে আন্দাজ করা যায়
  • compare_digest: সময় দেখে আন্দাজ (timing attack) ঠেকাতে

#Distributed lock

দুইটা server একই কাজ একসাথে যেন না করে (যেমন দিনের report একবারই বানানো):

Python
import uuid

async def with_lock(name: str, ttl: int = 30):
    token = uuid.uuid4().hex
    acquired = await redis.set(f"lock:{name}", token, nx=True, ex=ttl)   # না থাকলে তবেই বসাও
    if not acquired:
        return None
    return token

async def release(name: str, token: str) -> None:
    # শুধু নিজের lock খুলবো (Lua script এ check আর delete একসাথে, atomic)
    script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
    await redis.eval(script, 1, f"lock:{name}", token)

nx=True মানে "key না থাকলেই set করো"। মেয়াদ (ex) দেওয়া জরুরি: lock নেওয়া server crash করলে lock চিরকাল আটকে থাকবে না। Release এ token মেলানো জরুরি: আমার lock এর মেয়াদ শেষে অন্য কেউ নিলে আমি যেন তারটা খুলে না দিই।

#Redis সম্পর্কে যা মনে রাখবে

  • Memory তে চলে। Data RAM এর চেয়ে বড় হতে পারে না। maxmemory আর eviction policy ঠিক করো।
  • Persistence আছে, কিন্তু database এর বদলি না। RDB snapshot আর AOF log দিয়ে disk এ রাখা যায়, তবু কিছু সেকেন্ডের data হারানোর ঝুঁকি থাকে। টাকা বা order এর একমাত্র রেকর্ড Redis এ রাখবে না।
  • Single-threaded command execution। একটা ধীর command (KEYS * বড় database এ) সবাইকে আটকে দেয়। Production এ KEYS কখনো না, SCAN ব্যবহার করো।
  • সব key এ TTL। না হলে memory ধীরে ধীরে ভরে যায়।

#অনুশীলন

#নিজেকে যাচাই করো

Rate limit এ `GET` করে `+1` করে `SET` এর বদলে `INCR` কেন?

INCR atomic, Redis এটা এক ধাপে করে। তিন ধাপে করলে দুইটা request একসাথে এলে দুজনেই একই পুরনো মান পড়ে একই নতুন মান লিখবে, ফলে একটা গোনা হারাবে (race condition)।

Cache key তে version (`v1`) রাখার সুবিধা কী?

Cache এ রাখা data এর গঠন বদলালে শুধু version বদলে দিলেই সব পুরনো cache একসাথে অকেজো হয়ে যায়, প্রতিটা key খুঁজে মুছতে হয় না।

OTP বানাতে `random.randint` কেন ব্যবহার করবে না?

random module এর সংখ্যা নিরাপত্তার জন্য বানানো না, যথেষ্ট output দেখে পরের সংখ্যা আন্দাজ করা সম্ভব। নিরাপত্তার কাজে secrets module ব্যবহার করতে হয়।

Distributed lock এ মেয়াদ (TTL) না দিলে কী হতে পারে?

Lock নেওয়া server crash করলে lock আর কখনো খুলবে না, ফলে অন্য কেউ সেই কাজ আর কখনো করতে পারবে না।

Production Redis এ `KEYS *` কেন চালাবে না?

Redis একসাথে একটা command চালায়। লাখ লাখ key থাকলে KEYS * অনেকক্ষণ চলবে, আর সেই সময় অন্য সব request আটকে থাকবে। SCAN অল্প অল্প করে ঘোরে, অন্যদের আটকায় না।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।