Mục lục
- Mục Tiêu Bài Học
- Tổng Quan 3 Capstone Projects
- Project 1 — URL Shortener: Bài Toán & Requirements
- Architecture Tổng Thể
- Schema Redis
- Short Code Generation: INCR + Base62
- Endpoint: Create Short URL
- Endpoint: Redirect (Hot Path)
- Click Tracking & Analytics
- Rate Limit & ACL
- Persistence, Scaling & Monitoring
- Bài Tập & Preview Project 2, 3
Mục Tiêu Bài Học
- Hiểu vai trò của capstone project: áp dụng đồng thời nhiều pattern Redis vào một hệ thống thực tế thay vì tách biệt từng bài đơn lẻ.
- Nắm được scope và module liên quan của 3 capstone projects trong series.
- Thiết kế được schema Redis đầy đủ cho URL Shortener: URL store, counter, analytics, rate limit, user ownership.
- Viết được short code generation dùng
INCRatomic + base62 encoding. - Triển khai endpoint create và redirect với caching cache-aside và async click tracking.
- Hiểu cách kết hợp HyperLogLog, Hash, counter đơn giản để phục vụ analytics theo yêu cầu thực tế.
- Nhận diện các quyết định scaling: hash tag cho Redis Cluster, hot URL caching policy, CDN edge cache.
Tổng Quan 3 Capstone Projects
Mỗi project ghép nhiều pattern đã học thành một hệ thống có đầu vào, đầu ra và non-functional requirement rõ ràng. Không có project nào dùng Redis như một black box — mỗi quyết định design đều có thể trace lại một bài cụ thể trong M0–M10.
| Project | Mô tả | Module Redis áp dụng | Bài |
|---|---|---|---|
| P1: URL Shortener | Rút gọn URL, redirect, analytics theo click | M1 (String/counter), M2 (HLL, Hash), M3 (rate limit), M8 (ACL) | 120 (bài này) |
| P2: Real-time Chat | Gửi tin nhắn, lịch sử, typing indicator, presence | M5 (Pub/Sub), M6 (Streams), M7 (Session + Presence) | 121 |
| P3: Rate Limit Gateway | Multi-tenant gateway, per-API-key quota, GCRA | M3 (rate limit), M9 (monitoring), M10 (hardening) | 122 |
Ba project có độ phức tạp tăng dần và bổ sung cho nhau. P1 tập trung vào read path cực nhanh. P2 tập trung vào real-time messaging và state management. P3 tập trung vào control plane — quản lý quota và bảo mật.
Project 1 — URL Shortener: Bài Toán & Requirements
URL Shortener nhận một URL dài, trả về một code ngắn. Khi user truy cập https://sh.rt/abc123, hệ thống redirect về URL gốc với HTTP 302. Đây là luồng đọc chiếm >99% traffic, nên latency của redirect path là yêu cầu quan trọng nhất.
Functional requirements
- Tạo short URL từ long URL, trả về code ngắn.
- Vanity URL: user có thể chọn custom alias (ví dụ
/my-brand) thay vì code tự sinh. - Redirect:
GET /{code}→ 302 về long URL. - Expiration: short URL có TTL tùy chọn (mặc định không hết hạn).
- Analytics: total click, unique user (approximate), unique country (approximate), device breakdown, hourly click count (24h gần nhất).
- User ownership: user xem analytics URL của mình; người khác không xem được.
Non-functional requirements
- Redirect latency: < 50 ms p99.
- Throughput: 100,000 redirect/s.
- Storage: 100 triệu short URL.
- Availability: 99.9% (tương đương < 9 giờ downtime/năm).
Redirect < 50 ms p99 với 100k req/s về cơ bản yêu cầu hot data phải nằm trong Redis — không thể đánh DB từng request. Non-hot URL (cache miss) có thể chấp nhận vài chục ms thêm để load từ DB.
Architecture Tổng Thể
┌──────────────────┐
Client ──────────▶ │ API (FastAPI) │
└────────┬─────────┘
│
┌──────────────┼────────────────┐
▼ ▼ ▼
Redis (hot) Rate Limit Analytics
url:{code} ratelimit:{ip} click:{code}
INCR counter ratelimit:{uid} click:hour:{h}:{code}
click:geo:{code} ← HLL
click:user:{code} ← HLL
click:device:{code} ← Hash
│
│ cache miss (sync on read)
▼
DB (PostgreSQL) — cold store
urls table: code, long_url, owner, created_at, expires_at
│
│ periodic flush (every 5 min)
▼
click_stats table: code, total, updated_at
Hai luồng chính tách biệt nhau rõ ràng:
- Hot path (redirect): API đọc
url:{code}từ Redis. Nếu miss mới query DB rồi nạp lại cache. Click tracking chạybackground_task— không block response. - Analytics path: đọc counter và HLL từ Redis. Counter được flush về DB định kỳ 5 phút. HLL chỉ tồn tại trong Redis (approximate, không cần exact).
Schema Redis
Toàn bộ key thuộc một trong sáu nhóm sau:
| Key pattern | Type | Ý nghĩa | TTL |
|---|---|---|---|
url:counter |
String (integer) | Monotonic counter cho code generation | Không |
url:{code} |
String | Long URL tương ứng với code | 1–24 h (cache); theo TTL URL nếu có expiry |
click:{code} |
String (integer) | Tổng số click | Không (flush về DB) |
click:hour:{hh}:{code} |
String (integer) | Click trong giờ hh (epoch // 3600) |
7 ngày |
click:geo:{code} |
HyperLogLog | Unique country (approximate) | Không |
click:user:{code} |
HyperLogLog | Unique user fingerprint (approximate) | Không |
click:device:{code} |
Hash | Click theo device type (mobile/desktop/bot) | Không |
user:urls:{user_id} |
Set | Tập code đã tạo bởi user | Không |
ratelimit:{ip} |
String (integer) | Counter rate limit per IP (shorten) | 60 s (sliding) |
ratelimit:{user_id} |
String (integer) | Counter rate limit per user (daily) | 86400 s |
Naming convention: dùng dấu hai chấm làm separator, phần biến (code, user_id) đặt cuối. Với Redis Cluster, nếu cần đảm bảo các key liên quan đến cùng một code nằm trong cùng hash slot thì dùng hash tag: {code}. Ví dụ: url:{abc123}, click:{abc123}, click:geo:{abc123}.
Short Code Generation: INCR + Base62
INCR url:counter trả về một giá trị integer tăng dần, atomic, không bao giờ trùng. Chuyển integer này sang base62 (0–9, a–z, A–Z) cho ra code ngắn, URL-safe, không cần lock phân tán.
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET) # 62
def base62_encode(n: int) -> str:
"""Encode integer n sang chuỗi base62. n > 0."""
if n == 0:
return ALPHABET[0]
chars = []
while n:
chars.append(ALPHABET[n % BASE])
n //= BASE
return "".join(reversed(chars))
# Ví dụ:
# INCR url:counter → 1 → base62 → "1"
# INCR url:counter → 1000000 → base62 → "4c92" (4 ký tự)
# INCR url:counter → 3521614606208 → base62 → "zzzzzz" (6 ký tự)
#
# 6 ký tự base62 = 62^6 ≈ 56 tỷ URL — đủ cho 100M ban đầu.
# Khi counter đến 62^5 = ~916M thì code tự động lên 6 ký tự.
So sánh với random code: INCR + base62 không có collision, không cần check trùng sau khi tạo. Random code ngắn (6 ký tự) có xác suất collision tăng dần khi database đầy và phải retry. Với 100M URL và code 6 ký tự ngẫu nhiên, xác suất collision mỗi lần insert là ~0.2% — không nghiêm trọng nhưng cần xử lý retry; counter hoàn toàn tránh được điều này.
Vanity URL (user tự chọn) không dùng counter: check EXISTS url:{code} trước, trả 409 nếu đã tồn tại.
Endpoint: Create Short URL
import redis.asyncio as aioredis
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from pydantic import BaseModel, HttpUrl
from typing import Optional
app = FastAPI()
redis = aioredis.from_url("redis://localhost:6379", decode_responses=True)
class ShortenRequest(BaseModel):
long_url: HttpUrl
vanity: Optional[str] = None
ttl_seconds: Optional[int] = None # None = không hết hạn
async def check_rate_limit(key: str, limit: int, window: int) -> bool:
"""Fixed window counter. Trả True nếu còn quota."""
pipe = redis.pipeline()
pipe.incr(f"ratelimit:{key}")
pipe.expire(f"ratelimit:{key}", window, nx=True) # chỉ set expire lần đầu
results = await pipe.execute()
return results[0] <= limit
@app.post("/shorten")
async def shorten(
req: ShortenRequest,
background_tasks: BackgroundTasks,
user_id: str, # thực tế lấy từ auth header
client_ip: str, # thực tế lấy từ request.client.host
):
# Rate limit: 100 shorten/phút per IP
if not await check_rate_limit(f"ip:{client_ip}", 100, 60):
raise HTTPException(429, "Rate limit exceeded")
# Rate limit: 1000 shorten/ngày per user
if not await check_rate_limit(f"user:{user_id}", 1000, 86400):
raise HTTPException(429, "Daily quota exceeded")
# Code generation
if req.vanity:
code = req.vanity
if await redis.exists(f"url:{code}"):
raise HTTPException(409, "Code already taken")
else:
counter = await redis.incr("url:counter")
code = base62_encode(counter)
# Ghi vào Redis
pipe = redis.pipeline()
if req.ttl_seconds:
pipe.set(f"url:{code}", str(req.long_url), ex=req.ttl_seconds)
else:
pipe.set(f"url:{code}", str(req.long_url))
pipe.sadd(f"user:urls:{user_id}", code)
await pipe.execute()
# Persist bất đồng bộ xuống DB
background_tasks.add_task(save_to_db, code, str(req.long_url), user_id, req.ttl_seconds)
return {"short": f"https://sh.rt/{code}", "code": code}
Một số điểm cần chú ý:
EXPIRE nx=Trueđảm bảo TTL chỉ được set lần đầu tiên — không reset window mỗi request. Đây là fixed window counter đơn giản; bài 35–36 đã trình bày sliding window log nếu cần chính xác hơn.- Ghi Redis và DB được tách nhau: Redis ghi đồng bộ (user nhận phản hồi ngay), DB ghi bất đồng bộ. Nếu DB fail, URL vẫn hoạt động trong Redis nhưng sẽ mất sau TTL nếu không có retry mechanism. Production cần dead letter queue hoặc retry queue (Streams).
user:urls:{user_id}là Set — dùng để kiểm tra ownership khi xem analytics, không cần lưu toàn bộ long URL ở đây.
Endpoint: Redirect (Hot Path)
Đây là path quan trọng nhất về performance. Mục tiêu: < 50 ms p99, không block trên bất kỳ I/O nào ngoài Redis GET.
from fastapi.responses import RedirectResponse
@app.get("/{code}")
async def redirect(
code: str,
request: Request,
background_tasks: BackgroundTasks,
):
# Cache lookup (hot path)
long_url = await redis.get(f"url:{code}")
if not long_url:
# Cache miss: fallback DB
long_url = await db.get_url(code) # async DB query
if not long_url:
raise HTTPException(404, "Not found")
# Nạp lại cache: TTL tuỳ theo độ "hot"
await redis.set(f"url:{code}", long_url, ex=3600) # 1 h mặc định
# Track click bất đồng bộ — không block redirect
background_tasks.add_task(track_click, code, request)
return RedirectResponse(long_url, status_code=302)
Caching policy: URL mới miss lần đầu được cache 1 giờ. Nếu click count vượt ngưỡng (> 1000 click/giờ), analytics worker có thể nâng TTL lên 24 giờ bằng EXPIRE url:{code} 86400. URL lạnh ít được truy cập sẽ tự hết hạn, giải phóng memory. Đây là cache-aside kết hợp với adaptive TTL — không cần invalidation chủ động vì URL một khi đã tạo thì không thay đổi long URL (immutable data).
Tại sao HTTP 302 thay vì 301? 301 (Permanent) bị browser cache — analytics sẽ không đếm được các lần redirect tiếp theo vì browser không gọi về server. 302 (Temporary) đảm bảo mỗi click đều đến server.
Click Tracking & Analytics
Click tracking chạy trong background_task — không nằm trên critical path của redirect. Mọi lệnh được gom vào một pipeline để giảm round-trip.
import time
import hashlib
def compute_fingerprint(request: Request) -> str:
"""Fingerprint đơn giản từ IP + UA. Không dùng cho auth — chỉ cho HLL approximate."""
raw = f"{request.client.host}:{request.headers.get('user-agent', '')}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def classify_device(user_agent: str) -> str:
ua = (user_agent or "").lower()
if any(k in ua for k in ("bot", "crawl", "spider", "curl", "python")):
return "bot"
if any(k in ua for k in ("mobile", "android", "iphone", "ipad")):
return "mobile"
return "desktop"
async def track_click(code: str, request: Request):
hour = int(time.time() // 3600)
country = geoip_lookup(request.client.host) # trả 2-letter ISO code
device = classify_device(request.headers.get("user-agent", ""))
fp = compute_fingerprint(request)
pipe = redis.pipeline()
# Tổng click
pipe.incr(f"click:{code}")
# Hourly bucket (giữ 7 ngày)
pipe.incr(f"click:hour:{hour}:{code}")
pipe.expire(f"click:hour:{hour}:{code}", 86400 * 7)
# Unique country (HLL, sai số ≈ 0.81%)
pipe.pfadd(f"click:geo:{code}", country)
# Unique user fingerprint (HLL)
pipe.pfadd(f"click:user:{code}", fp)
# Device breakdown (Hash)
pipe.hincrby(f"click:device:{code}", device, 1)
await pipe.execute()
Analytics endpoint trả về tổng hợp từ Redis:
@app.get("/analytics/{code}")
async def analytics(code: str, user_id: str):
# Kiểm tra ownership
if not await redis.sismember(f"user:urls:{user_id}", code):
raise HTTPException(403, "Forbidden")
now_hour = int(time.time() // 3600)
# Lấy hourly 24h gần nhất bằng pipeline
pipe = redis.pipeline()
for h in range(24):
pipe.get(f"click:hour:{now_hour - h}:{code}")
hourly_raw = await pipe.execute()
hourly = [int(v or 0) for v in hourly_raw]
return {
"total_clicks": int(await redis.get(f"click:{code}") or 0),
"unique_users": await redis.pfcount(f"click:user:{code}"),
"geo_diversity": await redis.pfcount(f"click:geo:{code}"),
"devices": await redis.hgetall(f"click:device:{code}"),
"hourly_last_24": hourly, # index 0 = giờ hiện tại
}
Trade-off của HLL cho unique count: sai số khoảng ±0.81%, bù lại memory cố định 12 KB mỗi key bất kể số lượng phần tử. Nếu cần exact unique count thì phải lưu Set — với 100M URL mỗi URL 1000 unique user thì cần rất nhiều RAM. HLL là lựa chọn hợp lý cho analytics không cần chính xác tuyệt đối.
Rate Limit & ACL
Rate limit
Hệ thống cần rate limit ở hai điểm:
- Shorten endpoint: giới hạn spam tạo URL. Per-IP: 100 req/phút. Per-user: 1000 req/ngày.
- Redirect endpoint: thông thường không rate limit vì đây là public endpoint, nhưng có thể giới hạn nếu phát hiện pattern bot (vd 50k req/phút từ 1 IP).
Pattern đã dùng trong bài 7 (check_rate_limit) là fixed window counter. Đủ cho hầu hết use case. Nếu cần chính xác hơn ở biên window, áp dụng sliding window counter (bài 35–36).
ACL
Tách quyền truy cập Redis cho từng component:
# redis.conf (Redis 6+)
# Web API user: đọc/ghi url:*, user:*, ratelimit:*, click:*
ACL SETUSER webapp on >webapp_secret \
~url:* ~user:* ~ratelimit:* ~click:* \
+GET +SET +DEL +EXISTS +INCR +SADD +SISMEMBER +PFADD +PFCOUNT +HINCRBY +HGETALL +EXPIRE +PIPELINE
# Analytics worker: chỉ cần đọc click:* và ghi flush xuống DB
ACL SETUSER analytics_worker on >worker_secret \
~click:* \
+GET +INCR +HGETALL +PFCOUNT +PIPELINE
# Read-only replica (monitoring, reporting)
ACL SETUSER readonly on >readonly_secret \
~* \
+GET +HGETALL +SMEMBERS +PFCOUNT
Với ACL, nếu code web app bị khai thác, attacker không thể chạy lệnh nguy hiểm (FLUSHALL, CONFIG, DEBUG) hay đọc dữ liệu ngoài namespace được cấp.
Persistence, Scaling & Monitoring
Persistence
Dữ liệu cần phân loại độ bền:
- Long URL mapping (
url:{code}): Redis là cache, DB là nguồn sự thật. Khi Redis restart, URL được nạp lại từ DB theo cache-aside khi có request. - Click counter (
click:{code}): Redis là primary store; analytics worker flush về DB mỗi 5 phút bằngGET+ SQLUPDATE. Nếu Redis crash trước khi flush, mất tối đa 5 phút dữ liệu click — acceptable cho analytics. - HLL và Hash device: chỉ nằm trong Redis (approximate, không cần exact persistence).
Bật RDB snapshot (mỗi 15 phút) và AOF appendfsync everysec để giảm data loss khi Redis restart bất ngờ.
Scaling với Redis Cluster
Với 100k redirect/s và 100M URL, một Redis single instance đủ memory nhưng có thể là bottleneck CPU trên một core. Redis Cluster chia data sang nhiều node.
Vấn đề: PIPELINE nhiều key qua cluster chỉ hoạt động nếu các key nằm cùng hash slot. Dùng hash tag để nhóm key theo code:
# Thay vì:
f"url:{code}" # hash slot tính trên toàn chuỗi
f"click:{code}"
# Dùng hash tag — slot tính theo phần trong {}:
f"url:{{{code}}}" # hash slot tính trên "code"
f"click:{{{code}}}"
f"click:hour:{hour}:{{{code}}}"
f"click:geo:{{{code}}}"
f"click:user:{{{code}}}"
f"click:device:{{{code}}}"
# → 6 key cùng code sẽ nằm cùng slot → pipeline hoạt động đúng
url:counter và ratelimit:* không liên quan đến pipeline multi-key nên không cần hash tag.
CDN edge cache
Với URL rất hot (> 10k click/giờ), có thể cache redirect response tại CDN edge với Cache-Control: max-age=60. Trade-off: click tracking mất đi khi CDN serve từ cache — cân nhắc dùng analytics pixel hoặc CDN log thay thế.
Monitoring
INFO stats:keyspace_hits,keyspace_misses→ tính hit ratio củaurl:{code}.INFO memory:used_memory_rss→ theo dõi memory growth.- Prometheus + redis_exporter: alert khi
redis_keyspace_misses_totalrate tăng đột biến (dấu hiệu cache eviction hoặc traffic URL lạnh tăng). - App-level: đo latency redirect p50/p99 tại API layer. Alert khi p99 > 100 ms.
Bài Tập & Preview Project 2, 3
Bài tập
- Implement
shortenvàredirectendpoint cơ bản với Redis local. Kiểm tra luồng cache miss và hit bằngredis-cli MONITOR. - Thêm click tracking với pipeline. Gọi redirect 10 lần rồi kiểm tra
click:{code},click:device:{code}. - Thêm rate limit per-IP. Dùng
redis-cli DEBUG SLEEP 0và loop bash để test vượt ngưỡng. - Load test redirect endpoint với
wrkhoặclocustở 10,000 req/s. Đo p99 latency với và không có Redis cache (so sánh cold start vs warm cache). - Thêm Prometheus metrics: đếm cache hit/miss tại tầng app (increment counter trong
redirecttrước và sau khi check Redis). Tính hit ratio sau 5 phút load test.
Preview: Project 2 — Real-time Chat System
Project 2 (bài 121) xây chat system với các tính năng: gửi/nhận tin nhắn real-time, lịch sử tin nhắn phân trang, typing indicator, presence (online/offline). Redis Streams làm message store kiêm delivery guarantee; Pub/Sub cho presence và typing; Hash cho user session.
Preview: Project 3 — Multi-tenant Rate Limit Gateway
Project 3 (bài 122) xây API gateway rate limit cho nhiều tenant, mỗi tenant có quota riêng theo API key. Dùng GCRA (Generic Cell Rate Algorithm) thay vì fixed window để tránh burst tại biên window. ACL và TLS bảo vệ Redis layer. Monitoring đầy đủ với Prometheus và alert rules.
