Cache Stampede: chống sụp database khi Redis hết hạn — hoatq.dev

cat blog/.md

Cache Stampede: chống sụp database khi Redis hết hạn

date: tags: backend, redis, caching, performance, distributed-systems

Có một sự cố mà tôi nhớ mãi: một endpoint đọc danh sách sản phẩm hot, cache 5 phút trên Redis, trung bình 3K req/s. Mọi thứ chạy mượt cho đến khi TTL hết đúng giờ peak 8h tối. Trong vỏn vẹn 200ms, hàng nghìn request cùng miss cache, cùng lao xuống PostgreSQL gọi cùng một query nặng. DB CPU lên 100%, pool connection cạn, 5xx tràn lan. Sau đó hệ thống phục hồi, cache được set lại, mọi thứ yên bình — cho đến 5 phút sau.

Hiện tượng này có tên: cache stampede (hay thundering herd, dog-pile). Và nếu bạn đang chạy một API có cache Redis với TTL cố định, sớm muộn gì cũng gặp. Bài này là những gì tôi đã thử để sửa, và cái nào thực sự ổn.

Vấn đề ở đâu

Mô hình cache-aside kinh điển trông như sau:

def get_hot_products():
    data = redis.get("hot_products")
    if data is not None:
        return json.loads(data)

    data = db.query_hot_products()  # query nặng, 500ms
    redis.setex("hot_products", 300, json.dumps(data))
    return data

Bình thường 99% request hit cache, chỉ 1 request mỗi 5 phút chạm DB. Nhưng đúng khoảnh khắc TTL hết, nếu có 2000 request đang chờ key đó, cả 2000 cùng miss, cả 2000 cùng gọi db.query_hot_products(). DB nhận 2000 query nặng song song — và sập.

Vấn đề càng tệ khi:

  • Query tái tạo cache chậm (vài trăm ms đến vài giây)
  • Key có traffic cao và tập trung (một vài key chiếm phần lớn request)
  • Tất cả key cùng được set một lúc (sau deploy, hay sau FLUSHDB)

Cách 1: Single-flight lock

Ý tưởng đơn giản: chỉ cho một request tái tạo cache tại một thời điểm. Các request còn lại đợi hoặc trả về stale data.

Dùng Redis SET NX EX để lấy lock:

import time, json, redis, uuid

r = redis.Redis()

def get_hot_products():
    key = "hot_products"
    lock_key = f"lock:{key}"

    data = r.get(key)
    if data is not None:
        return json.loads(data)

    # Thử lấy lock, chỉ 1 client thắng
    token = uuid.uuid4().hex
    got_lock = r.set(lock_key, token, nx=True, ex=10)

    if got_lock:
        try:
            data = db.query_hot_products()
            r.setex(key, 300, json.dumps(data))
            return data
        finally:
            # Release lock an toàn bằng Lua script
            release_lock(lock_key, token)

    # Không lấy được lock — đợi và đọc lại cache
    for _ in range(20):
        time.sleep(0.05)
        data = r.get(key)
        if data is not None:
            return json.loads(data)

    # Timeout — fallback trực tiếp xuống DB (chấp nhận 1 ít query lọt)
    return db.query_hot_products()

Lock phải có TTL đề phòng worker chết giữa chừng; nếu không, key sẽ bị khoá vĩnh viễn. Release phải check token để không lỡ xoá lock của người khác — dùng Lua script atomic:

if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

Ưu: đơn giản, hiệu quả, giảm từ N query xuống 1 query mỗi chu kỳ.

Nhược: các request còn lại bị delay chờ rebuild. Với query 500ms, user thấy latency tăng vọt. Với query 2s, không khả thi — phải có phương án giữ response về kịp.

Cách 2: Serve stale, refresh async

Biến thể thực dụng của single-flight: không bắt request nào chờ cả. Khi cache miss, nếu có stale data (vừa hết hạn), trả về ngay, trong lúc đó một background task đi rebuild.

Điều này cần tách giá trị ra khỏi TTL của Redis — lưu kèm timestamp trong payload:

import time, json

SOFT_TTL = 300   # 5 phút — sau khoảng này sẽ refresh
HARD_TTL = 600   # 10 phút — sau khoảng này mới thật sự drop

def get_hot_products():
    key = "hot_products"
    raw = r.get(key)

    if raw is None:
        # Miss thật — buộc phải chạy đồng bộ (hiếm, chỉ khi vừa deploy)
        return rebuild_and_store(key)

    payload = json.loads(raw)
    age = time.time() - payload["created_at"]

    if age < SOFT_TTL:
        return payload["data"]

    # Stale nhưng còn dùng được — trả luôn, rebuild ngầm
    if r.set(f"lock:{key}", "1", nx=True, ex=30):
        submit_background_task(rebuild_and_store, key)

    return payload["data"]

def rebuild_and_store(key):
    data = db.query_hot_products()
    payload = {"data": data, "created_at": time.time()}
    r.setex(key, HARD_TTL, json.dumps(payload))
    return data

Đây là pattern mà Varnish, Cloudflare, và hầu hết CDN production đang dùng (stale-while-revalidate). Ưu điểm khổng lồ: không request nào phải đợi. Nhược điểm: data có thể stale tối đa vài giây sau SOFT_TTL — phải chấp nhận được cho use case đó.

Không phù hợp cho: giá tiền real-time, inventory stock, auth token. Rất phù hợp cho: feed, leaderboard, category list, config.

Cách 3: Probabilistic early expiration (XFetch)

Cách hay mà ít người biết, từ paper “Optimal Probabilistic Cache Stampede Prevention” của Vattani et al. Ý tưởng: mỗi request có một xác suất nhỏ tự refresh cache trước khi TTL hết, xác suất này tăng dần khi sắp đến hạn. Nhờ đó, việc refresh trải đều theo thời gian, rất khó để nhiều request cùng trúng mốc TTL.

Công thức:

should_refresh = (time.now() - delta * beta * log(random())) >= expiry

Trong đó delta là thời gian trung bình để rebuild cache (đo được), beta là hệ số tuỳ chỉnh (1.0 là mặc định, tăng lên nếu muốn refresh sớm hơn).

import math, random

def get_with_xfetch(key, rebuild_fn, ttl=300, beta=1.0):
    raw = r.get(key)
    meta = r.get(f"{key}:meta")

    if raw is None:
        return rebuild_and_store(key, rebuild_fn, ttl)

    payload = json.loads(raw)
    delta, expiry = json.loads(meta)   # delta = thời gian rebuild, expiry = epoch hết hạn

    now = time.time()
    if now - delta * beta * math.log(random.random()) >= expiry:
        return rebuild_and_store(key, rebuild_fn, ttl)

    return payload

def rebuild_and_store(key, rebuild_fn, ttl):
    start = time.time()
    data = rebuild_fn()
    delta = time.time() - start
    expiry = time.time() + ttl
    r.setex(key, ttl, json.dumps(data))
    r.setex(f"{key}:meta", ttl, json.dumps([delta, expiry]))
    return data

Kết hợp với lock đơn để chỉ một trong các “người may mắn” được refresh thực tế, ta có giải pháp vừa tránh stampede, vừa không cần giữ stale data lâu. Đây là cách HashiCorp dùng trong Consul Template và Mailchimp từng viết blog mô tả.

Ba lỗi nhỏ tôi đã mắc

1. TTL giống nhau cho toàn bộ key. Khi deploy, toàn bộ cache được warm cùng lúc, rồi cùng hết hạn đúng 5 phút sau. Cứ mỗi 5 phút, hệ thống lại có một đợt stampede. Fix: thêm jitter vào TTL:

import random
r.setex(key, 300 + random.randint(-30, 30), data)

Đơn giản, hiệu quả, đáng làm mặc định cho mọi cache set.

2. Quên bounded timeout ở fallback. Khi single-flight lock bị stuck, các request chờ không có giới hạn thời gian — API gateway trả 504 hàng loạt. Luôn có timeout cứng (vd 500ms), vượt thì fallback xuống DB hoặc trả lỗi có ý nghĩa.

3. Cache toàn bộ response thay vì dữ liệu gốc. Cache JSON đã render thì khi thay đổi format là phải flush toàn bộ. Hãy cache dữ liệu có cấu trúc (dict/list), serialize khi trả về — linh hoạt hơn nhiều.

Chọn cái nào

Kịch bảnGiải pháp phù hợp
Data có thể stale vài giâyServe stale + async refresh — user không bao giờ đợi
Data phải luôn tươi, query nhanh (<200ms)Single-flight lock — đơn giản nhất, đủ dùng
Traffic rất cao, nhiều key hot, query nặngXFetch + jitter + lock — phòng thủ nhiều lớp
Data thay đổi realtime (giá, stock)Đừng cache theo TTL — dùng invalidation by event hoặc Redis Streams

Ba pattern này không loại trừ nhau. Trong production tôi hay kết hợp: jitter cho mọi TTL, serve-stale cho feed và listing, single-flight lock cho những key siêu hot không cho phép stale.

Tóm lại

Cache stampede là một class lỗi mà test staging rất khó bắt được — vì nó chỉ xuất hiện khi có đủ concurrency và key hot. Khi đã gặp một lần thì ngấm luôn: mọi cache TTL đều nên có jitter, mọi rebuild đều nên có single-flight lock, và với data chịu được stale thì stale-while-revalidate là quà tặng.

Nếu hôm nay bạn đang chạy cache-aside thuần với setex(key, 300, data), hãy thử đếm thử xem có bao nhiêu key hot, và TTL của chúng có trùng nhau không. Một dòng jitter có thể cứu bạn khỏi một đêm bị gọi dậy.

// reactions


cat comments.log


hoatq@dev : ~/blog $