Danh sách bài viết

Bài 14: Cache Stampede & Mutex Lock

Khi một hot key hết hạn giữa lúc traffic đang cao, hàng nghìn request có thể cùng lúc miss, cùng lúc đập xuống database để rebuild cùng một giá trị — hiện tượng này gọi là cache stampede (hay thundering herd, dogpile) và có thể làm database quá tải tới mức sập. Bài này phân tích vì sao stampede xảy ra, rồi đi qua bốn tuyến phòng thủ: mutex lock single-flight bằng SET NX EX với retry và backoff, TTL jitter, stale-while-revalidate và pre-warming. Bạn sẽ có code chống stampede hoàn chỉnh bằng ioredis (TypeScript) và redis-py (Python), hiểu các trade-off (latency của request rebuild đầu, vì sao lock luôn cần TTL) và các anti-pattern thường gặp.

25/05/2026
14 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

  • Hiểu chính xác cache stampede (thundering herd, dogpile) là gì: hot key hết hạn khiến hàng nghìn request đồng thời cùng miss, cùng rebuild và cùng đập xuống database.
  • Giải thích được vì sao stampede xảy ra (TTL hết + traffic cao + rebuild tốn kém) và vì sao key càng hot thì hậu quả càng nặng.
  • Áp dụng mutex lock single-flight bằng SET key NX EX: chỉ một request giành lock để rebuild, các request còn lại chờ rồi đọc lại cache hoặc trả stale tạm.
  • Viết được code chống stampede có retry/backoff bằng cả ioredis (TypeScript) và redis-py (Python).
  • Biết ba tuyến phòng thủ bổ trợ: TTL jitter, stale-while-revalidatepre-warming / refresh-ahead.
  • Nắm các trade-off: lock thêm latency cho request rebuild đầu, lock luôn cần TTL để chống deadlock, chọn giữa "chờ lock" và "trả stale".
  • Nhận diện các pitfall: lock không TTL, mọi request cùng chờ một lock, unlock không kiểm tra ownership, chỉ dựa TTL cố định mà không jitter.
2

Cache Stampede Là Gì

Ở bài Cache-Aside ta đã thấy: khi một key miss, application sẽ đọc database rồi ghi lại vào cache. Bình thường điều này vô hại vì các key hiếm khi miss cùng lúc. Vấn đề nảy sinh khi có một hot key — một key được hàng nghìn request mỗi giây cùng đọc — và nó hết hạn (TTL về 0) ngay giữa lúc traffic đang cao.

Tại đúng thời điểm key biến mất, mọi request đang bay tới đều thấy MISS. Vì cache-aside coi miss là tín hiệu "hãy đọc DB và rebuild", nên tất cả các request đó cùng lúc:

  1. Thấy cache rỗng (MISS).
  2. Cùng chạy query đắt đỏ xuống database.
  3. Cùng rebuild ra cùng một giá trị.
  4. Cùng ghi đè lại vào cache.

Thay vì một request rebuild còn các request khác đợi rồi hưởng kết quả, ta có hàng nghìn request làm đúng một công việc trùng lặp. Database bị một đợt sóng query y hệt nhau dội vào trong một khoảng rất ngắn — đủ để CPU, connection pool hoặc disk I/O của DB bão hoà và sập. Khi DB chậm lại, rebuild lại lâu hơn, cửa sổ miss kéo dài hơn, càng nhiều request kẹt lại — một vòng xoáy sụp đổ.

Hiện tượng này có nhiều tên gọi: cache stampede (đàn thú giẫm đạp), thundering herd (đàn thú chạy rầm rập), hay dogpile (chồng chất). Tất cả mô tả cùng một thứ: nhiều tác nhân đồng loạt lao vào làm cùng một việc tốn kém ngay khi cache trống.

# Minh hoạ: hot key product:hot có 5.000 req/s, TTL vừa hết tại t=0
#
# t=0.000s  TTL hết -> key biến mất khỏi cache
# t=0.000s  5.000 request đang tới đều thấy MISS
# t=0.001s  ~5.000 query "SELECT ... FROM products WHERE id=..." cùng xuống DB
# t=0.001s  DB connection pool (giả sử max 100) cạn ngay -> request xếp hàng / timeout
# t=0.300s  DB CPU 100%, query chậm dần -> rebuild lâu hơn -> cửa sổ MISS kéo dài
#
# Mong muốn: chỉ 1 query rebuild, 4.999 request còn lại chờ rồi đọc lại cache
3

Vì Sao Stampede Xảy Ra

Stampede không phải một bug ngẫu nhiên; nó là hệ quả tất yếu khi ba điều kiện cùng xuất hiện:

  • TTL hết hạn cứng (hard expiration): khi key hết hạn, nó biến mất hoàn toàn. Không còn giá trị nào để phục vụ trong lúc rebuild, nên mọi request buộc phải đi đường miss.
  • Traffic cao đồng thời: với một key cực hot, số request đến trong khoảng vài chục mili-giây (đúng bằng thời gian rebuild) có thể lên tới hàng trăm hoặc hàng nghìn. Mỗi request là một "con thú" trong đàn.
  • Rebuild tốn kém: nếu việc dựng lại giá trị nhanh và rẻ (vài trăm micro-giây), nhiều request trùng lặp vẫn chịu được. Nhưng rebuild thường là query join nặng, gọi API ngoài, hay tính toán tổng hợp mất hàng chục tới hàng trăm mili-giây — chính cửa sổ rebuild dài này là nơi cả đàn dồn vào.

Công thức trực giác: số request trùng lặp trong một đợt stampede xấp xỉ tốc_độ_request × thời_gian_rebuild. Một key 5.000 req/s với rebuild 40 ms sẽ tạo ra cỡ 5000 × 0.04 = 200 request cùng rebuild cho mỗi lần hết hạn. Key càng hot và rebuild càng lâu thì đợt sóng càng lớn.

Một biến thể nguy hiểm hơn xảy ra khi nhiều key cùng hết hạn một lúc — ví dụ một job nạp cache hàng loạt với cùng một TTL cố định, hay cache vừa được warm-up đồng thời sau khi restart. Lúc đó không chỉ một key mà cả một tập key cùng stampede. Đây là lý do TTL jitter (mục 7) là tuyến phòng thủ rẻ nhất và nên bật mặc định.

Điểm cần khắc cốt: bản thân cache-aside không có cơ chế nào ngăn nhiều request cùng rebuild. Nó coi mỗi miss độc lập. Chống stampede nghĩa là chủ động thêm cơ chế điều phối để "gộp" các miss đồng thời lại — và đó là vai trò của mutex lock.

4

Giải Pháp 1 — Mutex Lock (Single-Flight)

Ý tưởng mutex lock (còn gọi là single-flight: chỉ một chuyến bay) rất tự nhiên: khi một hot key miss, thay vì để tất cả cùng rebuild, ta chỉ cho đúng một request giành quyền rebuild. Những request còn lại không đi xuống DB mà chờ một chút rồi đọc lại cache (lúc đó người thắng lock đã ghi xong giá trị mới).

"Đúng một request" được đảm bảo bằng lệnh atomic của Redis: SET lock_key value NX EX ttl. Trong đó:

  • NX (Not eXists): chỉ set thành công nếu lock key chưa tồn tại. Đây chính là cơ chế giành lock — Redis xử lý đơn luồng nên chỉ một request duy nhất nhận được OK, tất cả các request còn lại nhận nil.
  • EX ttl: gắn TTL cho lock. Bắt buộc, để nếu request giữ lock crash giữa chừng thì lock tự hết hạn, tránh deadlock (sẽ phân tích kỹ ở mục Trade-off).

Luồng xử lý đầy đủ cho một lần đọc hot key:

  1. GET data_key. Nếu HIT, trả về ngay (đường nhanh, đa số request đi lối này).
  2. Nếu MISS, thử giành lock: SET lock_key token NX EX 10.
  3. Nếu giành được lock: là người duy nhất rebuild. Đọc DB, ghi data_key kèm TTL, rồi nhả lock (DEL lock_key) và trả về giá trị.
  4. Nếu không giành được lock (có người khác đang rebuild): chờ một khoảng ngắn (backoff) rồi GET data_key lại. Lặp lại vài lần. Khả năng cao là người giữ lock đã rebuild xong và ghi cache, nên lần đọc lại sẽ HIT.
  5. Nếu sau số lần retry tối đa vẫn chưa có giá trị: tuỳ chính sách mà trả stale tạm (nếu còn), tự rebuild như phương án dự phòng, hoặc báo lỗi nhẹ. Không bao giờ để cả đàn cùng đập DB.
          ┌─────────────┐  GET data_key
 request  │ Application │ ──────────────►  HIT? -> trả về ngay
 ───────► │             │
          └──────┬──────┘  MISS
                 │
                 ▼  SET lock_key token NX EX 10
        ┌────────────────────┐
        │ giành được lock?    │
        └─────┬──────────┬────┘
          YES │          │ NO (người khác đang rebuild)
              ▼          ▼
       đọc DB,      chờ backoff -> GET data_key lại
       SET data_key,  (retry vài lần, khả năng cao HIT)
       DEL lock_key,
       trả về

Kết quả: dù có 5.000 request cùng miss, chỉ một query xuống DB. 4.999 request còn lại chỉ tốn thêm vài mili-giây chờ rồi đọc lại cache. Đàn thú đã được gộp thành một chuyến bay duy nhất.

Lưu ý quan trọng về độ an toàn: bản mutex ở đây đủ tốt cho mục đích chống stampede trong một dịch vụ. Tuy nhiên một distributed lock thật sự an toàn cần thêm ownership token (mỗi holder set một token ngẫu nhiên) và unlock có kiểm tra ownership bằng Lua để không xoá nhầm lock của người khác sau khi lock của mình đã hết hạn. Chủ đề distributed lock (kể cả Redlock và các tranh luận quanh nó) sẽ được đào sâu ở Module 4; ở đây ta đã chuẩn bị sẵn token trong code để làm quen.

5

Code Chống Stampede Với ioredis (TypeScript)

Triển khai single-flight bằng ioredis. Điểm mấu chốt: giành lock bằng set(lockKey, token, "EX", ttl, "NX"), nếu không được thì retry với backoff và đọc lại cache; unlock bằng Lua script kiểm tra ownership (chỉ xoá nếu token khớp).

import Redis from "ioredis";
import { randomBytes } from "crypto";

const redis = new Redis({ host: "127.0.0.1", port: 6379 });

const DATA_TTL = 300;     // TTL của giá trị cache (giây)
const LOCK_TTL = 10;      // TTL của lock: phải > thời gian rebuild tối đa
const MAX_RETRY = 5;      // số lần đọc lại cache khi không giành được lock
const BACKOFF_MS = 50;    // khoảng chờ cơ sở giữa các lần retry

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Unlock an toàn: chỉ DEL nếu token khớp (ownership). Atomic bằng Lua.
const UNLOCK_LUA = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end`;

async function rebuildFromDb(id: number): Promise<string> {
  // Query đắt đỏ: join nhiều bảng, tính tổng hợp... (mô phỏng)
  const row = await db.query("SELECT ... FROM products WHERE id = $1", [id]);
  return JSON.stringify(row);
}

export async function getWithMutex(id: number): Promise<string | null> {
  const dataKey = `product:${id}`;
  const lockKey = `lock:product:${id}`;

  // 1. Đường nhanh: đa số request HIT ở đây
  const cached = await redis.get(dataKey);
  if (cached !== null) return cached;

  // 2. MISS: thử giành lock single-flight
  const token = randomBytes(16).toString("hex"); // ownership token
  const got = await redis.set(lockKey, token, "EX", LOCK_TTL, "NX");

  if (got === "OK") {
    // 3a. Người thắng lock: rebuild đúng một lần
    try {
      const fresh = await rebuildFromDb(id);
      await redis.set(dataKey, fresh, "EX", DATA_TTL);
      return fresh;
    } finally {
      // Nhả lock an toàn (chỉ xoá nếu vẫn là token của mình)
      await redis.eval(UNLOCK_LUA, 1, lockKey, token);
    }
  }

  // 3b. Thua lock: chờ backoff rồi đọc lại cache (KHÔNG đập DB)
  for (let i = 0; i < MAX_RETRY; i++) {
    await sleep(BACKOFF_MS * (i + 1)); // backoff tuyến tính, có thể đổi sang mũ
    const value = await redis.get(dataKey);
    if (value !== null) return value; // người thắng lock đã ghi xong
  }

  // 4. Dự phòng: hết retry mà vẫn chưa có giá trị.
  //    Tuỳ chính sách: trả stale (nếu lưu riêng), hoặc tự rebuild một lần.
  return await rebuildFromDb(id);
}

Lưu ý LOCK_TTL phải lớn hơn thời gian rebuild xấu nhất, nếu không lock có thể hết hạn khi rebuild còn dang dở và một request khác lại giành được lock (lúc đó Lua unlock theo token giúp ta không xoá nhầm). Tổng thời gian chờ tối đa của nhánh thua lock (50+100+150+200+250 = 750 ms) cũng nên nhỏ hơn timeout của caller.

6

Code Chống Stampede Với redis-py (Python)

Phiên bản tương đương bằng redis-py. Giành lock bằng set(lock_key, token, nx=True, ex=LOCK_TTL); unlock bằng cùng một Lua script kiểm tra ownership.

import json
import secrets
import time

import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

DATA_TTL = 300     # TTL của giá trị cache (giây)
LOCK_TTL = 10      # TTL của lock: phải > thời gian rebuild tối đa
MAX_RETRY = 5      # số lần đọc lại cache khi không giành được lock
BACKOFF_MS = 50    # khoảng chờ cơ sở giữa các lần retry

# Unlock an toàn: chỉ DEL nếu token khớp (ownership). Atomic bằng Lua.
UNLOCK_LUA = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end
"""
_unlock = r.register_script(UNLOCK_LUA)


def rebuild_from_db(item_id: int) -> str:
    # Query đắt đỏ: join nhiều bảng, tính tổng hợp... (mô phỏng)
    row = db.fetchone("SELECT ... FROM products WHERE id = %s", (item_id,))
    return json.dumps(row)


def get_with_mutex(item_id: int) -> str | None:
    data_key = f"product:{item_id}"
    lock_key = f"lock:product:{item_id}"

    # 1. Đường nhanh: đa số request HIT ở đây
    cached = r.get(data_key)
    if cached is not None:
        return cached

    # 2. MISS: thử giành lock single-flight
    token = secrets.token_hex(16)  # ownership token
    got = r.set(lock_key, token, nx=True, ex=LOCK_TTL)

    if got:
        # 3a. Người thắng lock: rebuild đúng một lần
        try:
            fresh = rebuild_from_db(item_id)
            r.set(data_key, fresh, ex=DATA_TTL)
            return fresh
        finally:
            # Nhả lock an toàn (chỉ xoá nếu vẫn là token của mình)
            _unlock(keys=[lock_key], args=[token])

    # 3b. Thua lock: chờ backoff rồi đọc lại cache (KHÔNG đập DB)
    for i in range(MAX_RETRY):
        time.sleep(BACKOFF_MS * (i + 1) / 1000)  # backoff tuyến tính
        value = r.get(data_key)
        if value is not None:
            return value  # người thắng lock đã ghi xong

    # 4. Dự phòng: hết retry mà vẫn chưa có giá trị.
    #    Tuỳ chính sách: trả stale (nếu lưu riêng), hoặc tự rebuild một lần.
    return rebuild_from_db(item_id)

Hai bản code có cấu trúc giống hệt nhau: SET NX EX giành lock, retry + backoff đọc lại cache khi thua, Lua unlock theo token. register_script của redis-py sẽ cache SHA của script và dùng EVALSHA tự động, hiệu quả hơn gửi nguyên văn mỗi lần.

7

Giải Pháp 2 — TTL Jitter

Mutex lock gộp các miss đồng thời của một key. Nhưng nếu nhiều key cùng hết hạn một lúc (vì được nạp cùng lúc với cùng TTL cố định) thì ta có hàng loạt stampede song song. TTL jitter giải đúng vấn đề này — đây là kỹ thuật đã được giới thiệu ở bài về tư duy TTL trong series và là tuyến phòng thủ rẻ nhất, nên bật mặc định.

Ý tưởng: thay vì gán cùng một TTL cứng, ta cộng (hoặc nhân) thêm một lượng ngẫu nhiên để thời điểm hết hạn của các key bị phân tán ra một cửa sổ, thay vì dồn về đúng một mốc.

ttl = base_ttl × (1 + random(-jitter_pct, +jitter_pct))

Ví dụ với base_ttl = 300 giây và jitter ±10%: mỗi key nhận TTL ngẫu nhiên trong khoảng 270 – 330 giây. Thay vì cùng chết tại mốc 300, các key trải đều trên cửa sổ ~60 giây, làm phẳng đỉnh tải khi rebuild.

// TTL có jitter theo phần trăm: tránh hết hạn đồng loạt
function ttlWithJitter(baseTtl: number, jitterPct = 0.1): number {
  const factor = 1 + (Math.random() * 2 - 1) * jitterPct; // [1-pct, 1+pct]
  return Math.max(1, Math.round(baseTtl * factor));
}

// Dùng khi ghi cache trong nhánh rebuild của getWithMutex:
await redis.set(dataKey, fresh, "EX", ttlWithJitter(DATA_TTL, 0.1));
import random

def ttl_with_jitter(base_ttl: int, jitter_pct: float = 0.1) -> int:
    """TTL có jitter theo phần trăm để tránh hết hạn đồng loạt."""
    factor = 1 + (random.random() * 2 - 1) * jitter_pct  # [1-pct, 1+pct]
    return max(1, round(base_ttl * factor))

# Dùng khi ghi cache trong nhánh rebuild của get_with_mutex:
r.set(data_key, fresh, ex=ttl_with_jitter(DATA_TTL, 0.1))

TTL jitter và mutex lock bù trừ cho nhau: jitter giảm xác suất nhiều key hết hạn cùng lúc, mutex chặn stampede của từng key khi nó vẫn xảy ra. Trong production nên dùng cả hai.

8

Giải Pháp 3 & 4 — Stale-While-Revalidate & Pre-warming

Giải pháp 3 — Stale-While-Revalidate (trả cũ + làm mới nền)

Nguyên nhân gốc của stampede là TTL hết hạn cứng: key biến mất, không còn gì để phục vụ. Stale-while-revalidate (SWR) loại bỏ chính cái cửa sổ rỗng đó. Ý tưởng: lưu giá trị kèm một mốc "logic expiry" sớm hơn TTL vật lý. Khi giá trị vượt mốc logic nhưng vẫn còn trong cache:

  • Request đến vẫn được trả ngay giá trị cũ (stale) — không ai phải chờ rebuild.
  • Một (và chỉ một, nhờ mutex) tiến trình nền được kích hoạt để refresh giá trị mới ở phía sau.

Vì luôn có giá trị để trả ngay, không có khoảnh khắc cache rỗng nên không có đàn thú nào dồn vào DB. Đây thường là giải pháp mạnh nhất cho hot key, đổi lại chấp nhận trả dữ liệu hơi cũ trong cửa sổ revalidate. Một biến thể tinh vi hơn là probabilistic early expiration (XFetch): mỗi request có một xác suất nhỏ tăng dần khi gần hết hạn để chủ động refresh sớm, tránh việc tất cả cùng refresh tại mốc logic. SWR và probabilistic early expiration sẽ được phân tích sâu ở bài tiếp theo.

Giải pháp 4 — Pre-warming / Refresh-ahead (làm mới chủ động trước hạn)

Thay vì đợi traffic chạm vào key đã hết hạn (phản ứng), ta chủ động nạp lại trước khi nó hết hạn:

  • Pre-warming (warm-up): sau khi restart hoặc trước một sự kiện dự kiến tăng tải (flash sale, ra mắt sản phẩm), chạy một job nạp sẵn các hot key vào cache để traffic không gặp cold cache.
  • Refresh-ahead: một job nền theo dõi TTL còn lại của các hot key và rebuild chúng trước khi hết hạn (ví dụ khi còn 20% TTL). Vì refresh diễn ra ngoài đường request và được điều phối tập trung, chỉ một tiến trình chạm DB cho mỗi key.

Pre-warming và refresh-ahead phù hợp khi bạn biết trước đâu là hot key (bảng giá, trang chủ, cấu hình). Chúng dời chi phí rebuild ra khỏi đường nóng của người dùng. Nhược điểm: cần biết tập hot key và tốn tài nguyên refresh ngay cả khi không ai đọc — nên kết hợp với việc theo dõi tần suất truy cập để chỉ refresh những key thực sự nóng.

Tổng quan bốn tuyến phòng thủ: TTL jitter (rẻ, giảm hết hạn đồng loạt) → mutex lock (chặn stampede từng key) → stale-while-revalidate (xoá hẳn cửa sổ rỗng) → pre-warming/refresh-ahead (chủ động cho hot key đã biết). Trong hệ thống lớn, các tuyến này thường được dùng kết hợp chứ không loại trừ nhau.

9

Trade-off Khi Dùng Mutex Lock

Mutex lock không miễn phí. Các trade-off cần cân nhắc:

  • Latency thêm cho request rebuild đầu tiên: request thắng lock phải gánh trọn thời gian rebuild (đọc DB + ghi cache) trong khi các request thua phải chờ backoff. Single-flight đổi throughput của DB lấy một chút latency cho nhóm request rơi đúng cửa sổ rebuild. Đây là giao kèo gần như luôn đáng với hot key.
  • Lock BẮT BUỘC có TTL để chống deadlock: nếu request giữ lock crash (hoặc bị kill) trước khi nhả lock, mà lock không có TTL, thì lock đó tồn tại vĩnh viễn — mọi request sau đều thua lock và không bao giờ rebuild được key. Đó là deadlock. EX trên lock đảm bảo lock tự hết hạn để hệ thống tự phục hồi. Đánh đổi: nếu TTL ngắn hơn thời gian rebuild thật, lock có thể hết hạn giữa chừng và một request thứ hai lại rebuild — chấp nhận được vì hiếm và vẫn an toàn nhờ Lua unlock theo token.
  • Chọn giữa "chờ lock" và "trả stale": khi thua lock, ta có hai lựa chọn. (1) Chờ rồi đọc lại cache: ưu tiên dữ liệu mới, đổi lại latency cao hơn cho request thua. (2) Trả stale ngay (nếu còn giữ bản cũ): latency thấp nhất, đổi lại dữ liệu hơi cũ. Lựa chọn phụ thuộc yêu cầu độ tươi của từng loại dữ liệu — đây cũng là cầu nối tới stale-while-revalidate ở bài sau.
  • Một round-trip thêm khi miss: mỗi lần miss có thêm lệnh SET NX (và DEL/Lua khi nhả lock). Chi phí này không đáng kể so với việc cứu DB, nhưng vẫn nên nhớ rằng mutex chỉ cần cho hot key, không phải mọi key.
10

Pitfalls & Anti-patterns

  • Lock không có TTL: dùng SET lock_key 1 NX mà quên EX. Nếu holder crash trước khi DEL, lock tồn tại vĩnh viễn → deadlock, key đó không bao giờ rebuild lại được. Luôn set TTL khi giành lock.
  • Mọi request cùng chờ một lock rồi dồn latency: nếu để hàng nghìn request thua lock đều chờ rất lâu (TTL lock dài, không có giới hạn retry), latency của cả nhóm bị dồn lại và caller có thể timeout hàng loạt. Đặt số lần retry và tổng thời gian chờ tối đa nhỏ hơn timeout của caller; cân nhắc trả stale thay vì chờ.
  • Unlock không kiểm tra ownership: dùng DEL lock_key trần. Nếu lock của bạn đã hết hạn và một request khác đã giành lại lock đó, DEL mù sẽ xoá nhầm lock của người khác, mở cửa cho stampede thứ hai. Phải unlock bằng Lua so token (xoá chỉ khi token khớp). Distributed lock an toàn (ownership, fencing token, Redlock) sẽ học kỹ ở Module 4.
  • Chỉ dựa TTL cố định, không jitter: nạp một loạt key với cùng TTL cứng khiến chúng hết hạn đồng loạt, tạo stampede hàng loạt mà mutex từng-key không cứu nổi nếu tải cực lớn. Luôn thêm jitter cho TTL của các key được nạp theo lô.
  • TTL lock ngắn hơn thời gian rebuild: lock hết hạn giữa lúc rebuild còn chạy → request khác giành lock và rebuild lần nữa (mất tác dụng single-flight). Đặt LOCK_TTL lớn hơn rebuild xấu nhất; với rebuild rất dài, cân nhắc gia hạn lock định kỳ (lock watchdog).
  • Áp mutex cho mọi key: stampede chỉ nguy hiểm với hot key. Bọc lock quanh mọi key (kể cả key lạnh ít đọc) làm tăng độ phức tạp và latency mà không đem lại lợi ích. Chỉ áp dụng cho các key thực sự nóng.
  • Nhánh dự phòng vẫn để cả đàn đập DB: nếu khi hết retry mà code cho tất cả request thua lock cùng tự rebuild, ta lại tạo ra stampede ngay tại nhánh fallback. Hãy giới hạn fallback (chỉ một số rất ít rebuild, hoặc trả stale/lỗi nhẹ) thay vì mở cổng cho toàn bộ.
11

Tổng Kết & Quiz

Tổng kết

  • Cache stampede (thundering herd / dogpile): một hot key hết hạn giữa lúc traffic cao khiến hàng nghìn request đồng thời cùng miss, cùng rebuild, cùng đập DB và có thể làm DB sập.
  • Stampede xảy ra khi hội tụ ba điều kiện: TTL hết hạn cứng + traffic cao + rebuild tốn kém; key càng hot, đợt sóng càng lớn.
  • Mutex lock (single-flight) dùng SET key NX EX để chỉ một request rebuild; các request khác chờ backoff rồi đọc lại cache (hoặc trả stale tạm).
  • TTL jitter phân tán thời điểm hết hạn để tránh nhiều key chết cùng lúc — tuyến phòng thủ rẻ nhất, nên bật mặc định.
  • Stale-while-revalidate xoá hẳn cửa sổ rỗng (trả cũ + refresh nền); pre-warming/refresh-ahead chủ động làm mới hot key đã biết trước hạn.
  • Trade-off: latency thêm cho request rebuild đầu; lock luôn cần TTL để chống deadlock; unlock phải kiểm tra ownership bằng Lua; chọn giữa "chờ lock" và "trả stale".

Quiz 5 câu

  1. Mô tả chính xác cache stampede và ba điều kiện khiến nó xảy ra. Vì sao key càng hot thì đợt stampede càng lớn?
  2. Giải thích cơ chế single-flight bằng SET key NX EX. Vì sao chỉ đúng một request giành được lock, và các request còn lại làm gì?
  3. Vì sao lock bắt buộc phải có TTL? Điều gì xảy ra nếu holder crash mà lock không có TTL?
  4. Tại sao unlock bằng DEL lock_key trần lại nguy hiểm, và Lua script kiểm tra ownership giải quyết vấn đề đó ra sao?
  5. So sánh ngắn gọn bốn giải pháp chống stampede (mutex lock, TTL jitter, stale-while-revalidate, pre-warming/refresh-ahead): mỗi cái giải đúng phần nào của vấn đề?

Đáp án gợi ý

  1. Stampede là khi một hot key hết hạn và hàng nghìn request đồng thời cùng MISS, cùng query DB rồi cùng rebuild cùng một giá trị, dội một đợt sóng query trùng lặp làm DB quá tải. Ba điều kiện: TTL hết hạn cứng (key biến mất, không còn gì phục vụ), traffic cao đồng thời, và rebuild tốn kém (cửa sổ rebuild dài). Số request trùng lặp xấp xỉ tốc_độ_request × thời_gian_rebuild, nên key càng hot (và rebuild càng lâu) thì đàn càng đông.
  2. SET lock_key token NX EX ttl: NX chỉ set khi key chưa tồn tại. Vì Redis xử lý đơn luồng, chỉ một request nhận OK, tất cả còn lại nhận nil. Request thắng lock rebuild đúng một lần rồi ghi cache và nhả lock; các request thua lock chờ một khoảng backoff rồi GET lại cache (không đập DB), khả năng cao đã HIT vì người thắng đã ghi xong.
  3. Để chống deadlock. Nếu holder crash (hoặc bị kill) trước khi DEL lock mà lock không có TTL, lock tồn tại vĩnh viễn; mọi request sau đều thua lock và key đó không bao giờ được rebuild. EX trên lock đảm bảo lock tự hết hạn để hệ thống tự phục hồi.
  4. DEL trần xoá lock mà không kiểm tra ai đang sở hữu. Nếu lock của bạn đã hết hạn và request khác đã giành lại lock đó, DEL mù sẽ xoá nhầm lock của người khác, mở cửa cho một đợt stampede thứ hai. Lua so GET == token rồi mới DEL (atomic) đảm bảo chỉ xoá đúng lock của mình.
  5. TTL jitter: phân tán thời điểm hết hạn để giảm xác suất nhiều key chết cùng lúc (rẻ, phòng ngừa). Mutex lock: chặn stampede của từng key bằng cách chỉ cho một request rebuild. Stale-while-revalidate: xoá hẳn cửa sổ cache rỗng bằng cách trả stale + refresh nền. Pre-warming/refresh-ahead: chủ động nạp/làm mới hot key đã biết trước khi hết hạn, dời chi phí ra khỏi đường nóng. Thường dùng kết hợp.

Bài tiếp theo

Bài 15 đi sâu vào Stale-While-Revalidate (trả giá trị cũ và làm mới ở nền để không bao giờ có cửa sổ cache rỗng) và Probabilistic Early Expiration (kỹ thuật XFetch: refresh sớm theo xác suất tăng dần khi gần hết hạn để tránh cả nhóm cùng refresh một lúc) — phần mở rộng tự nhiên của mutex lock cho các hot key khắt khe nhất.

Tham khảo