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
docker run -d --name redis -p 6379:6379 redis:7-alpine
uv add redis# 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 দিয়ে সরাসরি দেখা যায়:
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 গুলোর মধ্যে বার্তা |
# 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, একটা পুনর্ব্যবহারযোগ্য রূপে:
# 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# 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 noteKey এর নামের নিয়ম: জিনিস:উপ-জিনিস:version:id। : দিয়ে ভাগ করলে পড়তে আর খুঁজতে সহজ, আর version (v1) বদলে দিলে পুরনো সব cache একবারে অকেজো।
#Rate limiting: login এর সুরক্ষা
কেউ একটা account এ লাখ লাখ password আন্দাজ করে চেষ্টা করতে পারে (brute force)। সীমা দাও: একটা email এ ১৫ মিনিটে সর্বোচ্চ ৫টা ভুল চেষ্টা।
#সবচেয়ে সহজ: fixed window counter
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} সেকেন্ড পর আবার চেষ্টা করুন")@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: মেয়াদি জিনিস
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 okrandomনা,secrets:randomআন্দাজ করা যায়- OTP hash করে রাখা: Redis এ কেউ ঢুকলেও কোড পাবে না
- ভুল চেষ্টার সীমা: না হলে ১০ লাখ সম্ভাবনা কিছুক্ষণে আন্দাজ করা যায়
compare_digest: সময় দেখে আন্দাজ (timing attack) ঠেকাতে
#Distributed lock
দুইটা server একই কাজ একসাথে যেন না করে (যেমন দিনের report একবারই বানানো):
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 ভরে যাবে।