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.
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.
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 ở đó.
| 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 | Có | 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 trong lab, đúng như mã đang chạy:
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:
// 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));
}
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 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.
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.
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:
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ố 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.
Đâ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ỏ:
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ả.
// ===== 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
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#0…hot: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
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
- Redis — Lệnh EXPIRE (ngữ nghĩa TTL, và điều gì xảy ra khi TTL bị xoá)
-
Redis — Lệnh SET (tuỳ chọn
EX/NXdùng trong cache-aside và single-flight chia sẻ) -
Redis — Eviction Policies (ý nghĩa từng chính sách
maxmemory-policy: LRU, LFU, volatile, noeviction) -
Redis — Lệnh INFO (các trường
evicted_keys,used_memory_humandùng trong mục 5.5) - Microsoft Azure Architecture Center — Cache-Aside pattern
- Wikipedia — Cache stampede (mô tả hình thức chung của thundering herd, dùng ở mục 5.4)
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ình luận