Danh sách bài viết

Bài 120: Capstone Projects — Tổng Quan & Project 1: URL Shortener

Module 11 tổng hợp kiến thức từ 10 module trước thành 3 capstone project có thể chạy được trong môi trường thực tế. Bài này trình bày mục tiêu của từng project, sau đó đi sâu vào Project 1: URL Shortener — bao gồm thiết kế schema Redis, short code generation với INCR + base62, luồng create và redirect, click analytics dùng HLL và Hash, rate limit per-user/IP, và các quyết định scaling với Redis Cluster.

01/06/2026
0 lượt xem
1

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 INCR atomic + 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.
2

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.

3

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.

4

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ạy background_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).
5

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}.

6

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.

7

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.
8

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.

9

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.

10

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.

11

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ằng GET + SQL UPDATE. 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:counterratelimit:* 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ủa url:{code}.
  • INFO memory: used_memory_rss → theo dõi memory growth.
  • Prometheus + redis_exporter: alert khi redis_keyspace_misses_total rate 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.
12

Bài Tập & Preview Project 2, 3

Bài tập

  1. Implement shortenredirect endpoint cơ bản với Redis local. Kiểm tra luồng cache miss và hit bằng redis-cli MONITOR.
  2. Thêm click tracking với pipeline. Gọi redirect 10 lần rồi kiểm tra click:{code}, click:device:{code}.
  3. Thêm rate limit per-IP. Dùng redis-cli DEBUG SLEEP 0 và loop bash để test vượt ngưỡng.
  4. Load test redirect endpoint với wrk hoặc locust ở 10,000 req/s. Đo p99 latency với và không có Redis cache (so sánh cold start vs warm cache).
  5. Thêm Prometheus metrics: đếm cache hit/miss tại tầng app (increment counter trong redirect trướ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.

Tham khảo