Bài 4 dựng xong tầng vào. Từ bài này trở đi ta đi vào phía sau nó, và thứ đầu tiên gặp là tầng có tỉ lệ lợi ích trên công sức cao nhất trong toàn bộ series: cache. Lab của bài này đo được 26,5 lần throughput và p99 giảm 20 lần chỉ bằng cách bật cache cho một key nóng.

Nhưng phần đáng học không nằm ở con số đó — nó quá dễ đoán. Ba điều bất ngờ hơn: hạ hit ratio từ 99,45% xuống 95,47% (chỉ bốn điểm phần trăm) làm p99 nhảy từ 4,46 ms lên 42,11 ms trong khi p50 gần như không đổi; single-flight — cách chống thundering herd kinh điển — làm p50 và p99 tệ hơn một chút chứ không tốt hơn; và jitter cho TTL không giảm một truy vấn database nào nhưng vẫn tăng 31% throughput.

ℹ️ Cấu hình máy đo cho mọi số liệu trong bài này
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Container native arm64 (Node v22.23.2). Ba app replica, mỗi replica 1 CPU / 256 MB; nginx 1.27-alpine; Redis 7-alpine; bộ đo tải 2 CPU. "Database" của lab là một khoảng chờ 40 ms có giới hạn số truy vấn đồng thời — chi tiết này quan trọng, xem mục 5.4. Pool database là 10 kết nối/replica cho mục 5.1–5.3 và được thu xuống 2 cho mục 5.4; mỗi lần thay đổi đều được ghi rõ. Mọi thí nghiệm lặp ít nhất 3 lần.

5.1 Vì sao cache thắng, và thắng bao nhiêu

Bài 1 đã dựng bảng bậc độ lớn latency. Chỗ cache khai thác chính là khoảng cách giữa hai dòng trong bảng đó: đọc từ RAM tính bằng trăm nanosecond, còn một truy vấn database qua mạng tính bằng millisecond — chênh nhau khoảng 10.000 lần. Cache không làm database nhanh hơn; nó làm cho phần lớn request không phải hỏi database.

Độ trễ trung bình của một tầng có cache:

$$T = h \cdot T_{cache} + (1-h) \cdot T_{db}$$

với $h$ là hit ratio. Thay số của lab ($T_{cache} \approx 1{,}4$ ms, $T_{db} \approx 43$ ms) thì $h = 0{,}9$ cho $T \approx 5{,}6$ ms, còn $h = 0{,}99$ cho $T \approx 1{,}8$ ms. Nhưng công thức này che mất phần quan trọng hơn. Số truy vấn database mà hệ thống tạo ra là:

$$Q = (1-h) \cdot N$$

Nghĩa là tải database phụ thuộc vào $(1-h)$ — phần chưa hit. Đi từ $h = 90\%$ lên $h = 99\%$ nghe như "cải thiện 9 điểm", nhưng $(1-h)$ đi từ $0{,}1$ xuống $0{,}01$: tải database giảm 10 lần. Thêm một số 9 nữa, lên $99{,}9\%$, lại chia 10 lần nữa. Mỗi số 9 thêm vào là một lần chia 10 tải database.

Đây không phải suy luận trên giấy. Mình sweep kích thước tập dữ liệu làm việc (số key khác nhau mà tải chạm tới) và đo hit ratio cùng số truy vấn database thật:

Số key khác nhau Hit ratio đo được Truy vấn DB / request Throughput p50 p99
100 99,92% 0,0008 17.711 1,50 ms 4,44 ms
1.000 99,45% 0,0055 17.457 1,54 ms 4,46 ms
5.000 95,47% 0,0453 11.046 1,48 ms 42,11 ms
20.000 23,80% 0,7618 934 41,66 ms 86,08 ms
60.000 7,47% 0,9250 737 42,73 ms 88,13 ms
200.000 2,40% 0,9757 704 43,28 ms 87,23 ms

Cột thứ ba khớp với công thức đến từng chữ số: $1 - 0{,}9992 = 0{,}0008$, $1 - 0{,}9547 = 0{,}0453$, $1 - 0{,}2380 = 0{,}7620$. Công thức $Q = (1-h) \cdot N$ không phải xấp xỉ — nó là định nghĩa của hit ratio viết theo cách khác.

Nhưng dòng gây bất ngờ là dòng thứ ba. Từ 99,45% xuống 95,47% chỉ là bốn điểm phần trăm, và p50 thậm chí không đổi (1,54 → 1,48 ms). Trong khi đó p99 nhảy từ 4,46 ms lên 42,11 ms — gấp 9,4 lần. Đúng bài học của Bài 1: giá trị trung bình che mất đuôi, và đuôi là chỗ người dùng cảm nhận.

Mỗi số 9 thêm vào hit ratio = một lần chia 10 tải database Tải DB còn lại = (1 − h) · N — trục dọc chia theo bậc 10 100% 10% 1% 0,1% h = 0% h = 90% 10% tải h = 99% 1% tải h = 99,9% ÷10 9 điểm phần trăm hit ratio = 10 lần tải database. Đừng bao giờ nói "cache đã 90%, đủ rồi". Throughput đo thật — vách sụt nằm ngay dưới 95% 18k 9k 0 95,47% → 11.046 rps 99,45% → 17.457 rps dưới 25% → chỉ ~700–930 rps (đoạn 24–95% không đo) hit ratio → Toàn bộ giá trị nằm ở đoạn 95–100%. Bên trái là hệ thống đã nghẽn ở database. p99: 42 ms tại 95,47% · 4,5 ms tại 99,45% — trong khi p50 gần như y hệt.
Bên trái là quan hệ toán học, bên phải là số đo thật của lab. Cả hai nói cùng một điều: vùng đáng đầu tư nằm ở những số 9 cuối cùng.
⚠️ Cạm bẫy: đánh giá cache bằng "có nhanh hơn không"
Bật cache rồi bấm thử vài lần thấy nhanh hơn, kết luận "cache chạy tốt" — đó là cách bỏ sót vấn đề dễ nhất. Hai số liệu phải theo dõi là hit ratiosố truy vấn database trên mỗi request. Một cache có hit ratio 60% vẫn "nhanh hơn không cache", nhưng nó đang để 40% tải đi xuống database — và khi lưu lượng tăng gấp đôi thì tải database cũng tăng gấp đôi, đúng lúc bạn tưởng mình đã có cache che chắn.

Trong lab, hai số đó nằm ở /stats và được script tools/cache-stats.sh gộp lại từ cả ba replica — vì mỗi replica đếm riêng, đọc một replica chỉ cho bạn một phần ba sự thật.

5.2 Các mẫu cache: ai ghi vào đâu, theo thứ tự nào

Mọi mẫu cache đều trả lời hai câu hỏi: ai chịu trách nhiệm nạp cache khi miss, và khi ghi dữ liệu thì ghi vào cache hay database trước. Khác biệt giữa các mẫu chỉ nằm ở đó.

Bốn mẫu cache — mũi tên đánh số là THỨ TỰ xảy ra 1 · CACHE-ASIDE (đọc) app cache DB 1 GET 2 miss → DB 3 app tự SET lại cache App biết về cache Phổ biến nhất · cache sập vẫn chạy 2 · READ-THROUGH (đọc) app cache DB 1 2 CACHE tự nạp App KHÔNG biết có cache Code app sạch hơn Cache sập = mất đường ra DB 3 · WRITE-THROUGH (ghi) app cache DB 1 ghi 2 ghi tiếp, ĐỒNG BỘ Cache luôn khớp DB Không có cửa sổ stale Mọi lệnh ghi chậm hơn 4 · WRITE-BEHIND (ghi) app cache DB 1 ghi, trả về NGAY 2 flush SAU, bất đồng bộ Ghi nhanh nhất Gom được nhiều lệnh ghi Cache chết trước flush = MẤT DỮ LIỆU Trục đánh đổi thật sự không phải "nhanh hay chậm" mà là: app phức tạp thêm bao nhiêu ↔ chấp nhận mất dữ liệu tới mức nào ↔ dữ liệu được phép cũ bao lâu. Write-behind dùng cho bộ đếm lượt xem thì hợp lý; dùng cho số dư tài khoản thì không, dù nó nhanh nhất.
Cache-aside phổ biến nhất vì nó là mẫu duy nhất mà cache sập thì hệ thống chỉ chậm đi, không ngừng chạy.
Mẫu Ai nạp cache Rủi ro mất dữ liệu Dùng khi
Cache-aside App, khi miss Không Mặc định. Tải đọc nhiều, chấp nhận cửa sổ dữ liệu cũ ngắn
Read-through Chính tầng cache Không Muốn code app không biết gì về cache; đổi lại cache thành phụ thuộc bắt buộc
Write-through Ghi vào cả hai, đồng bộ Không Cần đọc ngay sau ghi luôn thấy dữ liệu mới; chấp nhận lệnh ghi chậm hơn
Write-behind Ghi cache trước, flush sau Bộ đếm, thống kê, log — dữ liệu mất một ít không sao
Refresh-ahead Nền, làm mới trước khi hết hạn Không Key nóng đã biết trước; đổi lại làm mới cả key không ai còn dùng
Cache-aside — hai nhánh, hai chi phí rất khác nhau NHÁNH HIT — đo được p50 = 1,45 ms client app cache (Redis) database 1 request 2 GET demo:k 3 có giá trị — xong 4 trả lời không chạm tới NHÁNH MISS — đo được p50 = 43,30 ms (chậm hơn 30 lần) 1 request 2 GET demo:k 3 rỗng (miss) 4 truy vấn database — 40 ms (+ chờ pool) 5 dữ liệu 6 SET ... EX ttl — chính APP tự nạp lại cache 7 trả lời Bước 4 là toàn bộ vấn đề của Bài 5: nếu N request cùng tới bước 3 một lúc thì có N lần bước 4 — cho cùng một dữ liệu (mục 5.4).
Điểm cần nhớ: ở cache-aside, chính app chịu trách nhiệm ghi lại cache (bước 6). Không có ai khác làm việc đó — và cũng chính vì thế cache sập thì app vẫn chạy được.

Cache-aside trong lab, đúng như mã đang chạy:

cache_aside.js
const cacheKey = `demo:${key}`;

let hit = null;
try {
  hit = await redis.cmd('GET', cacheKey);
} catch {
  // IMPORTANT: if Redis is down, the app must still serve the request. The cache is
  // a SPEED-UP, not a hard dependency. Catching the error here is exactly the
  // difference between "the system got slower" and "the system fell over" (Lesson 17).
  hit = null;
}
if (hit !== null) {
  cacheHits++;
  return json(res, 200, { ...JSON.parse(hit), from: 'cache' });
}

cacheMisses++;
const fresh = await readFromDb(key);
try {
  // SET ... EX: always set a TTL. A key with no TTL lives forever, and by the time
  // the underlying data changes nobody remembers where to delete it from.
  await redis.cmd('SET', cacheKey, JSON.stringify(fresh), 'EX', String(ttl));
} catch {
  // Couldn't write the cache — ignore it, don't fail the request over that.
}
return json(res, 200, fresh);

5.3 Vô hiệu hoá cache — phần khó nhất

Nạp cache thì dễ. Câu hỏi khó là: khi dữ liệu gốc đổi, làm sao bản trong cache biết mà bỏ đi? Ba cách, theo thứ tự từ đơn giản tới sạch nhất:

Cách Cơ chế Điểm yếu
TTL Đặt hạn dùng, hết hạn thì tự mất Luôn có cửa sổ dữ liệu cũ dài tối đa bằng TTL. Chọn TTL là chọn mức cũ bạn chấp nhận, không phải tinh chỉnh hiệu năng
Xoá tường minh Khi ghi DB thì DEL key tương ứng Phải biết mọi key bị ảnh hưởng — một truy vấn danh sách có thể nằm trong hàng chục key. Bỏ sót một chỗ là dữ liệu cũ tồn tại vô hạn
Key có version Không xoá gì; đổi key khi dữ liệu đổi Key cũ còn chiếm bộ nhớ cho tới khi hết hạn hoặc bị evict — đổi lại không bao giờ có ai đọc chúng

Key có version thường là lời giải sạch nhất, vì nó biến một bài toán đồng bộ (xoá đúng lúc, đúng chỗ) thành bài toán đặt tên:

versioned_key.js
// EXPLICIT DELETE: you have to remember every related key. Miss one line and you get
// the bug "I fixed it but the page still shows the old number" — a bug that's very
// hard to reproduce.
async function updateProductOld(id, data) {
  await db.update(id, data);
  await redis.del(`product:${id}`);
  await redis.del(`product:${id}:reviews`);
  await redis.del(`category:${data.categoryId}:products`);   // easy to forget this line...
  await redis.del(`search:featured`);                        // ...and this one
}

// VERSIONED KEY: delete nothing. Bump the version, and EVERY derived key changes with
// it. Old keys turn into garbage nobody reads, and vanish on their own once their TTL
// expires or they get evicted.
async function updateProductNew(id, data) {
  await db.update(id, data);
  // INCR is atomic in Redis => safe even when multiple replicas write at once.
  await redis.cmd('INCR', `ver:product:${id}`);
}

async function readProduct(id) {
  const v = (await redis.cmd('GET', `ver:product:${id}`)) || '0';
  return cacheAside(`product:${id}:v${v}`, () => db.read(id));
}
🔬 Cửa sổ dữ liệu cũ khi có nhiều lệnh ghi đồng thời
Kể cả khi xoá tường minh, vẫn có một khe hở. Trình tự này hoàn toàn có thể xảy ra: request A đọc miss và lấy giá trị cũ từ database; request B ghi giá trị mới rồi xoá cache; sau đó A mới kịp SET giá trị cũ nó đã đọc. Kết quả: cache giữ giá trị cũ, và không có TTL nào cứu được vì key vừa mới được ghi lại.

Đây là lý do sâu xa để dùng key có version: với version, giá trị cũ mà A ghi nằm ở key khác (...:v3) so với key mà mọi người đọc sau đó (...:v4). Cuộc đua vẫn xảy ra nhưng nó không còn gây hậu quả — đây là cách xử lý race condition tốt nhất: làm cho nó trở nên vô hại thay vì cố ngăn nó.

TTL đồng loạt và tác dụng của jitter

Nếu nhiều key được nạp gần như cùng lúc — chuyện xảy ra mỗi lần deploy, mỗi lần cache bị flush, mỗi lần scale thêm replica — và tất cả dùng cùng một TTL, chúng sẽ hết hạn thành từng đợt. Cách chữa là cộng thêm một lượng ngẫu nhiên vào TTL:

ttl_jitter.js
// TTL 5s with jitter 0.6 => every key gets a random TTL somewhere in [2s, 8s].
// The expected value is still 5s, but the expiry moments get SPREAD OUT instead of
// piling up at one point.
function ttlWithJitter(baseSec, ratio) {
  if (!(ratio > 0)) return baseSec;
  const delta = baseSec * ratio * (Math.random() * 2 - 1);
  return Math.max(1, Math.round(baseSec + delta));
}

Đo thật: 300 key, TTL cơ sở 5 giây, 32 kết nối, 30 giây, pool database 2 kết nối mỗi replica, single-flight đã bật ở cả hai cấu hình, ba lần lặp mỗi cấu hình:

Số liệu TTL cố định 5 s TTL jitter 60%
Throughput 10.393 / 10.182 / 11.287 13.550 / 13.387 / 15.216
p50 1,15 / 1,09 / 1,03 ms 0,89 / 0,82 / 0,56 ms
p99 72,25 / 77,17 / 69,13 ms 59,61 / 65,80 / 66,69 ms
Độ trễ lớn nhất 322,7 / 415,3 / 424,9 ms 270,1 / 276,6 / 317,0 ms
Chờ hàng đợi database, trung bình 98,6 / 98,0 / 103,1 ms 56,9 / 59,3 / 61,6 ms
Truy vấn DB / request 0,0105 / 0,0109 / 0,0095 0,0102 / 0,0104 / 0,0094

Throughput tăng 31% và thời gian chờ hàng đợi database giảm gần một nửa. Nhưng hãy để ý dòng cuối: số truy vấn database trên mỗi request gần như y hệt (0,0105 so với 0,0102). Jitter không hề giảm khối lượng công việc — nó chỉ rải công việc đó ra theo thời gian. Hàng đợi ngắn hơn không phải vì có ít việc hơn, mà vì việc không còn đến cùng một lúc.

⚠️ Thí nghiệm đầu tiên của mình về jitter đã thất bại
Lần đo đầu mình dùng 2.000 key và TTL 3 giây. Kết quả: jitter không tạo khác biệt nào — mọi số liệu trùng nhau trong khoảng biến động.

Nguyên nhân không phải jitter vô dụng mà là thiết kế thí nghiệm sai: với 2.000 key và pool database chỉ 2 kết nối, hệ thống nghẽn ở database suốt cả phép đo (hit ratio chỉ đạt 19%). Nó không bao giờ ở trạng thái "rảnh giữa các đợt hết hạn" — mà chính trạng thái đó mới là thứ jitter cải thiện. Giảm còn 300 key để cache kịp nạp trong dưới một giây, và tăng TTL lên 5 giây để giữa các đợt hệ thống thật sự rảnh, thì hiệu ứng hiện ra ngay và lặp lại ổn định cả ba lần.

Bài học: một phép đo "không thấy khác biệt" thường nói về thí nghiệm nhiều hơn là về thứ đang được đo. Trước khi kết luận "cơ chế này không có tác dụng", hãy kiểm tra xem hệ thống có đang ở chế độ mà cơ chế đó phát huy hay không.

5.4 Thundering herd — và một số đo phản trực giác

Một key nóng hết hạn. Ngay lúc đó có $N$ request đang cùng hỏi nó. Cả $N$ đều thấy miss, cả $N$ đều gọi database. Database nhận cùng lúc $N$ truy vấn giống nhau cho đúng một dữ liệu — đó là thundering herd (hay cache stampede).

Trường hợp tệ nhất là vòng lặp tự đóng: herd làm database quá tải, quá tải làm request chậm, chậm nên không ai kịp ghi lại cache, cache trống nên đợt request tiếp theo lại thành một herd nữa. Hệ thống không tự hồi phục được dù nguyên nhân ban đầu chỉ là một key hết hạn.

Lần đo đầu không thấy gì cả

Mình dựng thí nghiệm: một key nóng, TTL 1 giây, 100 kết nối, 15 giây. Không bật single-flight thì có 1.600 truy vấn database; bật lên thì còn 54 — ít hơn 30 lần. Nhưng latency không đổi gì cả: p50, p95, p99 của hai cấu hình trùng nhau trong khoảng biến động.

Lý do: "database" của lab lúc đó có pool 10 kết nối mỗi replica, tức 30 truy vấn đồng thời, mỗi truy vấn 40 ms — sức chứa khoảng 750 truy vấn/giây. Herd tạo ra chừng 89 truy vấn mỗi lần hết hạn. Nó chưa hề đụng tới giới hạn. Herd có thật, đo được bằng số truy vấn, nhưng chưa gây hậu quả.

Nên mình thu pool xuống 2 kết nối mỗi replica — một database đang căng, chuyện rất bình thường lúc có sự cố. Cùng thí nghiệm, ba lần lặp:

Số liệu Không single-flight Có single-flight
Truy vấn database 1.100 54
Hàng đợi database sâu nhất 33 / 35 / 33 0
Chờ hàng đợi, trung bình 312,6 / 312,3 / 312,4 ms 0 ms
Độ trễ lớn nhất 727,6 / 765,3 / 729,0 ms 67,7 / 64,0 / 67,4 ms
p50 4,98 / 5,01 / 5,00 ms 5,54 / 5,46 / 5,70 ms
p99 13,18 / 13,05 / 13,29 ms 14,68 / 13,75 / 13,62 ms
Throughput 15.928 / 15.987 / 15.894 15.938 / 16.202 / 16.062

Một điều cần giải thích trước khi đọc tiếp: số truy vấn database ở cột "Không single-flight" (1.100) thấp hơn con số 1.600 đo được ở thí nghiệm pool=10 phía trên, dù cùng một kịch bản herd. Đây không phải vì herd nhỏ đi — bộ đo tải chạy kiểu đóng (đã học ở Bài 2): mỗi kết nối chỉ gửi request kế tiếp sau khi nhận phản hồi, nên khi pool hẹp làm mỗi request chờ lâu hơn, cùng 15 giây đó có ít request hoàn tất hơn, kéo theo ít lần miss và ít truy vấn database hơn. Hai con số 1.600 và 1.100 đến từ hai thí nghiệm khác pool, không phải cùng một phép đo.

Đọc kỹ bảng này. Single-flight giảm truy vấn database 20 lần, xoá sạch hàng đợi (sâu 33 xuống 0, chờ 312 ms xuống 0), và cắt độ trễ lớn nhất từ ~730 ms xuống ~66 ms — 11 lần.

Nhưng p50 và p99 lại tệ hơn một chút: p50 từ 5,00 lên 5,57 ms, p99 từ 13,17 lên 14,02 ms. Nếu mình chỉ đo p50 và p99 — đúng hai số liệu mà hầu hết dashboard hiển thị — thì kết luận sẽ là "single-flight không giúp gì, thậm chí hơi hại", và mình đã bỏ nó đi.

🔬 Vì sao p99 không thấy được thundering herd
Trong phép đo trên có 1.100 request bị chặn ở hàng đợi database, trên tổng khoảng 280.000 request — tức 0,39%. Percentile 99 cắt ở 1%, nên toàn bộ nhóm bị hại đó nằm bên trên p99: p99 không chứa chúng, chỉ có độ trễ lớn nhất mới lộ ra.

Còn vì sao single-flight làm p50 tệ hơn? Vì nó đổi hình dạng thiệt hại. Không có nó, 1.100 request chịu toàn bộ hàng đợi 312 ms. Có nó, 1.746 request "nhập đoàn" cùng chờ trên một lần nạp duy nhất — mỗi request chỉ chờ khoảng 40 ms, nhưng số request bị ảnh hưởng nhiều hơn. Tổng thiệt hại nhỏ đi rất nhiều, nhưng nó được rải mỏng nên nhích vào p50.

Kết luận thực hành: đánh giá cơ chế chống herd bằng số truy vấn database, độ sâu hàng đợi và độ trễ lớn nhất. Nếu chỉ nhìn p50/p99, cơ chế đúng đắn nhất trông như một cơ chế vô ích. Và trên database thật, hàng đợi đó dùng chung với mọi truy vấn khác — nên 312 ms đó không chỉ làm chậm key nóng, nó làm chậm cả những phần hệ thống chẳng liên quan gì. Đó là thiệt hại mà lab này không đo được.

Bản cài đặt single-flight, chính là mã đang chạy trong lab:

single_flight.js
const inFlightLoads = new Map();

function singleFlight(key, loader) {
  const pending = inFlightLoads.get(key);
  if (pending) return pending;              // join the load that's already in flight

  // MUST delete from the map inside `finally`. Otherwise one failure leaves the key
  // stuck forever with a rejected promise, and EVERY later request gets that same error.
  const promise = loader().finally(() => inFlightLoads.delete(key));
  inFlightLoads.set(key, promise);
  return promise;
}

// Used on the read path:
const fresh = useSingleFlight
  ? await singleFlight(cacheKey, load)
  : await load();
⚡ Con số 54 tiết lộ giới hạn của single-flight
Với TTL 1 giây và 18 giây chạy (kể cả warm-up), key hết hạn khoảng 18 lần. Có 3 replica. 18 × 3 = 54 — đúng bằng số truy vấn database đo được, cả ba lần lặp.

Con số khớp chính xác đó không phải trùng hợp: nó chứng minh rằng single-flight chỉ gom được trong phạm vi MỘT tiến trình. Map inFlightLoads nằm trong bộ nhớ của một replica, nên mỗi replica vẫn tự đi nạp một lần. Muốn về đúng 18 truy vấn thì phải dùng lock chia sẻ trong Redis (SET key val NX EX 5) — nhưng lúc đó bạn nhận thêm một lớp bài toán: nếu tiến trình giữ lock chết trước khi ghi cache thì mọi request khác chờ tới hết hạn lock; và bạn phải chọn giữa "chờ lock" và "trả về dữ liệu cũ".

Với 3 replica, chênh lệch 54 so với 18 không đáng đổi thêm phức tạp. Với 300 replica thì câu trả lời khác. Đây là dạng quyết định phụ thuộc quy mô mà không có lời khuyên đúng cho mọi trường hợp — nhưng biết mình đang ở đâu trên trục đó là việc bắt buộc.

Ba cơ chế chống herd, và điều kiện dùng của từng cái:

Cơ chế Chống được gì Cái giá
Single-flight Nhiều request cùng miss MỘT key Chỉ gom trong một tiến trình; p50 nhích lên vì nhiều request cùng chờ một lần nạp
Jitter TTL NHIỀU key hết hạn cùng lúc Không giảm tổng công việc, chỉ rải ra; TTL thực tế của từng key không còn đoán được
Trả bản cũ trong lúc làm mới Cả hai — và không ai phải chờ Người dùng nhận dữ liệu cũ hơn TTL; phải lưu thêm bản "đã hết hạn nhưng còn dùng được"
Nạp trước khi mở tải Cache trống sau deploy hoặc sau khi flush Phải biết trước key nào nóng; thêm một bước trong quy trình triển khai

Hai cơ chế đầu chống hai vấn đề khác nhau và không thay thế được nhau — đó là lý do lab bật cả hai cùng lúc trong phép đo jitter ở mục 5.3.

⚠️ Cạm bẫy: client tự thử lại làm herd nặng thêm
Khi herd làm request chậm, client thường tự thử lại. Mỗi lần thử lại là một request mới cùng hỏi đúng key đang miss — herd tự nhân lên. Hệ thống chuyển từ "chậm" sang "không bao giờ hồi phục", và nguyên nhân cuối cùng lại là cơ chế được thêm vào để tăng độ tin cậy.

Đây chính là hiện tượng khuếch đại đã gặp ở Bài 4 mục 4.5, nhìn từ phía cache. Bài 17 sẽ đào sâu; điều cần nhớ bây giờ: thử lại chỉ ở một tầng, có khoảng nghỉ tăng dần và có nhiễu ngẫu nhiên.

5.5 Eviction và thiết kế cache key

Cache luôn nhỏ hơn dữ liệu gốc, nên luôn có lúc phải bỏ bớt. Redis quyết định bỏ cái nào bằng maxmemory-policy, và lựa chọn này thay đổi hành vi hệ thống nhiều hơn vẻ ngoài của nó:

Chính sách Bỏ key nào Phù hợp khi
allkeys-lru Key lâu nhất chưa được dùng Mặc định tốt cho cache thuần. Giả định: dùng gần đây thì sắp dùng lại
allkeys-lfu Key ít được dùng nhất (theo tần suất) Có nhóm key nóng ổn định lâu dài; chống được đợt quét dữ liệu lạnh tốt hơn LRU
volatile-lru Chỉ bỏ key CÓ TTL Redis vừa làm cache vừa giữ dữ liệu không được phép mất
noeviction Không bỏ gì — lệnh ghi bị lỗi Khi mất dữ liệu là không thể chấp nhận. Rất nguy hiểm cho cache: hết bộ nhớ là ngừng ghi được, hit ratio đóng băng rồi tụt dần

Redis trong lab cố tình đặt maxmemory 64mb với allkeys-lru để bạn tự gây ra eviction và quan sát. Khi hết bộ nhớ, INFO stats cho biết bao nhiêu key đã bị bỏ:

watch_eviction.sh
docker compose --profile cache up -d

# Push a very large key space to blow past 64mb, then see how many keys Redis dropped
docker compose run --rm loadgen loadgen.js \
  --url "http://lb:8080/cached?ttl=300" -c 32 -d 20 --key-space 200000

docker compose exec redis redis-cli INFO stats | grep evicted_keys
docker compose exec redis redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human'

# Key count and hit ratio as seen by Redis itself (different from the app-side hit ratio)
docker compose exec redis redis-cli DBSIZE
docker compose exec redis redis-cli INFO stats | grep keyspace

Key thiết kế sai làm cache vô dụng

Cạm bẫy tốn kém nhất trong bài này không phải chọn sai chính sách eviction mà là đặt key sai. Nếu key chứa thứ thay đổi theo từng request, mỗi request tạo ra một key mới: cache ghi liên tục, không bao giờ đọc lại được gì, hit ratio gần 0 — và nó vẫn "hoạt động", không báo lỗi gì cả.

cache_key_good_bad.js
// ===== BAD: a new key on every request, hit ratio ~ 0 =====
`page:${req.url}`                     // includes ?utm_source=... => each share creates a new key
`user:${id}:${Date.now()}`            // timestamp => never matches
`search:${JSON.stringify(filters)}`   // property order varies => key differs for the same filters
`cart:${sessionId}`                   // one key per session => the cache serves exactly one person

// ===== GOOD: keep only what ACTUALLY decides the result =====
const CACHE_PARAMS = ['page', 'sort', 'category'];   // allowlist, not a blocklist

function cacheKeyFor(url) {
  const u = new URL(url, 'http://x');
  const parts = CACHE_PARAMS
    .filter((k) => u.searchParams.has(k))
    .sort()                                    // SORT: ?a=1&b=2 and ?b=2&a=1 must map to the same key
    .map((k) => `${k}=${u.searchParams.get(k)}`);
  return `page:${u.pathname}|${parts.join('&')}`;
}

// cacheKeyFor('/products?utm_source=fb&sort=price&page=2')
//   -> 'page:/products|page=2&sort=price'      utm_* dropped, order normalized
💡 Dùng danh sách cho phép, đừng dùng danh sách chặn
Liệt kê tham số được phép vào key (như CACHE_PARAMS ở trên) thay vì liệt kê tham số cần bỏ. Lý do: danh sách chặn phải được cập nhật mỗi khi marketing thêm một tham số theo dõi mới, và khi quên thì hit ratio tụt âm thầm mà không có lỗi nào xuất hiện. Danh sách cho phép thì mặc định là an toàn — tham số lạ bị bỏ qua.

Hai vấn đề còn lại đáng biết tên. Hot key: một key được truy cập quá nhiều làm lệch tải một shard Redis — mọi shard khác rảnh, một shard quá tải; cách chữa là nhân bản key đó thành vài bản (hot:v1#0hot:v1#7) rồi chọn ngẫu nhiên khi đọc. Cache pollution: một tác vụ quét dữ liệu lạnh (báo cáo, backup, con bot) đẩy hết dữ liệu nóng ra khỏi cache — LFU chịu đựng chuyện này tốt hơn LRU, hoặc tách hẳn tác vụ quét sang đường không đi qua cache.

Tự làm lại các phép đo

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile cache up -d

# --- Section 5.1: no cache vs. a hot cache ---
docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/uncached?key=hot" -c 32 -d 10 -w 3 --json
./tools/cache-stats.sh

docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/cached?key=hot&ttl=300" -c 32 -d 10 -w 3 --json
./tools/cache-stats.sh

# --- Section 5.4: thundering herd. MUST shrink the DB pool to 2 first, otherwise you
# --- will see exactly the surprise I ran into: 30x more queries with latency unchanged.
# Change DB_MAX_CONCURRENCY to '2' in docker-compose.yml, then:
docker compose --profile cache up -d --force-recreate app1 app2 app3

docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/cached?key=hot&ttl=1" -c 100 -d 15 -w 3 --json
./tools/cache-stats.sh                    # check dbQueries + queue depth

docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/cached?key=hot&ttl=1&flight=single" -c 100 -d 15 -w 3 --json
./tools/cache-stats.sh                    # compare: dbQueries, qDepth, and the MAX latency

Tóm lại

Tải database tỉ lệ với $(1-h)$, nên mỗi số 9 thêm vào hit ratio là một lần chia 10 tải — và toàn bộ giá trị nằm ở những số 9 cuối cùng, không phải ở việc đi từ 0% lên 90%. Cache-aside là mặc định vì nó là mẫu duy nhất mà cache sập thì hệ thống chỉ chậm đi. Vô hiệu hoá cache là phần khó nhất, và key có version thường là lời giải sạch nhất vì nó làm cuộc đua ghi trở nên vô hại thay vì cố ngăn cuộc đua.

Nhưng bài học lớn nhất của bài này là về cách đo. Ba lần trong quá trình viết, số liệu nói ngược điều mình chờ đợi, và cả ba lần nguyên nhân là phép đo chứ không phải cơ chế: jitter "không có tác dụng" vì hệ thống nghẽn suốt nên không có đợt nào để làm phẳng; thundering herd "không gây hại" vì pool database rộng gấp nhiều lần herd; single-flight "làm chậm hơn" vì p50 và p99 không chứa nhóm 0,39% bị hại. Trong cả ba, số liệu đúng — câu hỏi mới là cái sai.

Bài 6 mang đúng ý tưởng này ra xa hơn nữa — tới tầng edge, cạnh người dùng — nơi giới hạn không còn là CPU hay bộ nhớ mà là tốc độ ánh sáng, và nơi một lỗi cấu hình cache key làm tải origin tăng 251 lần mà không một số liệu nào phía client thay đổi.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

App server đang chạy trong lab, chứa cả ba cơ chế đo trong bài: cache-aside qua Redis, single-flight chống thundering herd, và TTL có jitter:

Tải về app.js

Bài viết liên quan trong series

Bài 4: Reverse Proxy & API Gateway Bài 6: CDN & Edge Caching Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận