Mười một phần vừa rồi mỗi phần đo một thứ: cache nhanh hơn database bao nhiêu, cache-aside tiết kiệm được gì, TTL đổi độ mới lấy tải, eviction đá nhầm key nào, stampede lớn cỡ nào và chặn nó ra sao, thứ tự invalidation, write-back đánh đổi gì, hit ratio nói dối ở đâu, hot key, và cache phân tầng.

Phần cuối này gom tất cả vào một hàm đọc duy nhất, rồi đo nó cạnh bản ngây thơ dưới cùng một lưu lượng — để xem mười một bài học cộng lại đáng bao nhiêu.

Một hàm, mười một bài học

Ảnh chụp đoạn mã nền tối một hàm đọc gom cả 11 phần theo thứ tự L1 rồi singleflight rồi Redis rồi khoá SET NX rồi database trong đó mỗi dòng là một bài học đã đo, hàm Doc nhận id và trước hết thử c.l1 Get k là tầng L1 không chạm mạng của phần 11 và nếu giá trị là dấu vắng mặt thì trả chuỗi rỗng, nếu L1 trượt thì gọi c.g.Do của singleflight phần 6 để gom trong tiến trình, trong đó thử c.rdb Get rồi nếu có thì set vào L1 và trả về theo cache-aside phần 2, nếu không thì chạy c.rdb SetNX với key cộng hậu tố lk giá trị 1 hạn 5 giây để lấy khoá toàn cụm phần 6, nếu thua khoá thì vòng for 100 lần ngủ 2 mili giây rồi thử Get lại tức chờ có trần, nếu thắng khoá thì defer xoá khoá, sau đó gọi layDB và nếu không có dữ liệu thì ghi dấu vắng mặt với TTL ngắn 10 giây theo phần 2, còn nếu có thì rdb Set với ttlCoNhieu 60 giây tức TTL có nhiễu cộng trừ 20 phần trăm theo phần 3 rồi set vào L1, ghi chú rằng phần 4 là maxmemory-policy allkeys-lfu phần 7 là ghi DB rồi xoá cache và phần 9 là đo miss mỗi giây chứ không đo hit ratio tích luỹ đều nằm ngoài hàm này vì chúng ở cấu hình ở đường ghi và ở dashboard

Hình 1: Hàm đọc gom mọi thứ. Thứ tự các tầng không tuỳ tiện — tầng rẻ nhất chặn trước, và mỗi tầng chỉ gỡ tải cho tầng ngay sau nó.

Ba bài học không nằm trong hàm này, và đó là chuyện đáng nhớ: chúng sống ở ba nơi khác nhau.

  • Phần 4 (chọn allkeys-lfu thay vì mặc định noeviction) nằm ở cấu hình Redis.
  • Phần 7 (ghi database rồi xoá cache, không bao giờ ngược lại) nằm ở đường ghi, hoàn toàn tách khỏi đường đọc.
  • Phần 9 (đo miss trên giây và hit ratio theo cửa sổ) nằm ở dashboard.

Viết đúng hàm đọc mà bỏ ba thứ kia thì vẫn hỏng — chỉ là hỏng ở chỗ khó nhìn hơn.

Đo thật: 101.739 truy vấn database còn 5.300

Lưu lượng dựng theo tất cả những gì đã học là thật: 32 người đọc song song, phân bố Zipf (lệch, như phần 10 đo), 5% lưu lượng là bot quét id không tồn tại (xuyên thủng cache, như phần 2 đo), và cả hai biến thể đều khởi động từ cache trống.

Ảnh chụp output thật nền tối gom 11 phần thì DB giảm 19 lần và Redis giảm 60 lần với 32 người đọc song song trong 6 giây theo Zipf trên 5000 key và 5 phần trăm là bot quét id không tồn tại, bản A ngây thơ đạt 321725 request mỗi giây với p50 0.091 ms p99 0.283 ms max 21.5 ms và truy vấn DB 101739 tức 16945 mỗi giây cùng 1931609 lượt tới Redis là 100 phần trăm, bản B gom 11 phần đạt 9817957 request mỗi giây với p50 0.000 ms p99 0.001 ms max 388.7 ms và truy vấn DB chỉ 5300 tức 871 mỗi giây cùng 31806 lượt tới Redis là 0.1 phần trăm, con số 5300 bằng 5000 key thật cộng 300 id ma tức là sàn lý thuyết mỗi key đúng một lần nạp còn 101739 của bản ngây thơ gần như toàn bộ là xuyên thủng cache vì 5 phần trăm lưu lượng bot nhân với 1,93 triệu request bằng khoảng 96.000 lượt chạm DB không bao giờ được cache, phần nói thẳng về cột max 388,7 ms so với 21,5 ms giải thích bản gom 11 phần có đuôi xấu hơn lúc khởi động nguội vì ai thua khoá SET NX phải chờ người thắng điền xong nhưng đổi lại p50 và p99 tốt hơn 100 đến 280 lần và database nhận 871 thay vì 16945 truy vấn mỗi giây còn nếu đuôi đó không chấp nhận được thì làm nóng cache trước khi nhận lưu lượng hoặc cho người thua trả bản cũ thay vì chờ, bảng tra nhanh các con số đã đo của cả loạt gồm phần 2 cache-aside hit ratio 37,6 lên 95 phần trăm và DB từ 20000 xuống 1000 lượt, phần 3 TTL với độ cũ xấp xỉ TTL chia 2 và nhiễu cộng trừ 20 phần trăm đưa đỉnh từ 1000 xuống 266 key, phần 4 eviction với quét tuần tự thì LRU giữ 18 trên 200 còn LFU giữ 199 trên 200, phần 5 stampede với 500 cùng miss thành 500 truy vấn và p-max 1119 ms, phần 6 singleflight và khoá đưa 500 xuống 1 truy vấn còn 4 instance thì 4 so với 1, phần 7 invalidation với ghi rồi xoá sai 7,26 phần trăm còn xoá rồi ghi sai 66,82 phần trăm và kẹt, phần 8 write-back có trễ bằng một phần ba ghi DB bằng một phần năm nhưng mất 19,41 phần trăm khi chết, phần 9 hit ratio cửa sổ 44,8 phần trăm khi tích luỹ vẫn báo 95,8 phần trăm, phần 10 hot key với 1 key bằng 20,81 phần trăm lưu lượng và 1 shard gánh 4,44 lần, phần 11 phân tầng với L1 1 giây cắt 97,6 phần trăm lưu lượng Redis và nhanh hơn 800 lần

Hình 2: Kết quả thật, cùng bảng tra nhanh toàn bộ con số của loạt bài.

Truy vấn database: 101.739 → 5.300. Con số 5.300 không phải ngẫu nhiên — nó bằng đúng 5.000 key thật + 300 id ma, tức là sàn lý thuyết: mỗi key riêng biệt được nạp đúng một lần, không hơn. Khó mà làm tốt hơn thế.

Và đây là chỗ đáng ngạc nhiên nhất: phần lớn khoảng cách không đến từ L1 hay từ chống stampede. 101.739 − 5.300 ≈ 96.400, và 5% × 1,93 triệu request ≈ 96.500 — khớp gần như hoàn hảo. Gần như toàn bộ tải database của bản ngây thơ là xuyên thủng cache: những id không tồn tại không bao giờ được cache, nên mỗi lần bot hỏi lại là một lần chạm database. Thứ đắt nhất trong cả bài toán hoá ra được sửa bằng dấu vắng mặt — ba dòng code ở phần 2, bài học rẻ nhất của cả loạt.

Lưu lượng Redis: 100% → 0,1%. Tầng L1 với TTL 1 giây gần như xoá sổ đường đi tới Redis, đúng như phần 11 đo riêng.

p50 và p99 tốt hơn 100–280 lần, vì phần lớn request không rời khỏi tiến trình.

Nói thẳng: bản "đúng chuẩn" có đuôi xấu hơn

Cột max đi ngược mọi cột khác: 388,7ms so với 21,5ms. Bản gom 11 phần có request chậm nhất tệ hơn 18 lần.

Nguyên nhân nằm đúng ở cơ chế bảo vệ: lúc khởi động nguội, ai thua khoá SET NX phải chờ người thắng nạp xong. Với 32 người đọc tranh 5.000 key cùng lúc, một số request xui rơi vào chuỗi chờ dài. Đây chính là đánh đổi đã nêu ở phần 6, giờ hiện ra bằng số: chống stampede dời chi phí từ database sang hàng đợi của người thua.

Hai cách xử lý nếu cái đuôi đó không chấp nhận được, và cả hai đều đã nhắc tới trong loạt bài: làm nóng cache trước khi nhận lưu lượng, hoặc cho người thua trả bản cũ thay vì chờ (stale-while-revalidate). Điều cần nhớ là đừng ngạc nhiên khi thấy nó — p99 đẹp hơn và max xấu hơn là hình dạng bình thường của một hệ có khoá.

Khung quyết định

Gộp mọi thứ lại thành thứ tự câu hỏi nên hỏi, theo đúng thứ tự:

1. Có nên cache không? Đo truy vấn gốc trước (phần 1). Query nặng (gộp, join nhiều bảng) thì cache thắng lớn; query điểm có index thì lợi ích về độ trễ rất nhỏ — Redis chỉ nhanh hơn PostgreSQL 1,26 lần ở phần 11. Lý do đúng để cache một query nhẹ là gỡ tải, không phải tăng tốc.

2. Dữ liệu chịu được cũ bao lâu? Câu trả lời chính là TTL, vì độ cũ trung bình ≈ TTL/2 (phần 3). Không trả lời được câu này thì chưa nên cache. Và nhớ: sàn của độ cũ là tần suất ghi, TTL ngắn hơn chu kỳ ghi không mua thêm gì.

3. Mẫu nào? Đọc nhiều ghi ít → cache-aside + xoá khi ghi. Ghi nhiều và đọc lại ngay → write-through (xoá cache sau mỗi lần ghi là tự đục thủng cache, phần 8). Bộ đếm cộng dồn → write-back, và chấp nhận mất tối đa một chu kỳ dồn.

4. Key nào cần bảo vệ? Tính R × D (phần 5) cho từng nhóm key. Tích lớn thì thêm singleflight + khoá; tích nhỏ thì đừng thêm gì, cơ chế bảo vệ cũng có giá.

5. Đo gì? Miss trên giây (tuyệt đối, cho dung lượng) và hit ratio theo cửa sổ (cho cảnh báo) — không bao giờ chỉ dùng hit ratio tích luỹ (phần 9).

Checklist trước khi đưa lên production

Mỗi dòng dưới đây tương ứng một phép đo cụ thể trong loạt bài, không phải lời khuyên chung chung:

  • Mọi Set đều có TTL. Không TTL là mất lưới an toàn cuối cùng, và với volatile-* thì key đó còn bất tử.
  • TTL có nhiễu ±20%. Một dòng code, hạ đỉnh hết hạn đồng loạt từ 1.000 xuống 266 key.
  • maxmemory và maxmemory-policy đã đặt. Mặc định của Redis là noeviction — ghi bị từ chối với OOM trong khi đọc vẫn chạy, không có tín hiệu nào.
  • Lỗi Redis ≠ cache miss. Phân biệt redis.Nil với lỗi kết nối; Redis sập thì đi thẳng xuống database, đừng trả lỗi cho người dùng.
  • Có dấu vắng mặt, TTL ngắn hơn TTL dữ liệu thật. Phép đo cuối cho thấy đây là nguồn tải database lớn nhất nếu thiếu.
  • Key có R × D lớn được bảo vệ bằng singleflight và khoá toàn cụm. singleflight một mình nhân theo số instance.
  • Khoá có TTL, vòng chờ có trần, xoá khoá kiểm chủ.
  • Đường ghi: ghi database rồi xoá cache. Không bao giờ ngược lại — 7,26% so với 66,82%, và cái sau để cache kẹt sai tới hết TTL.
  • Dùng Del chứ không Set khi invalidate, trừ khi có cách sắp thứ tự.
  • Dashboard có miss/giây và hit ratio theo cửa sổ, vẽ miss ratio chứ không vẽ hit ratio.
  • Biết key nóng nhất chiếm bao nhiêu phần trăm lưu lượng. Nếu lớn, L1 trước khi nghĩ tới tách key.
  • TTL của L1 tính bằng giây, không bằng phút — không ai xoá được nó từ xa.
  • L1 có trần số phần tử. Nó không có maxmemory, và nạn nhân là heap của chính ứng dụng.
  • Đã thử chạy với cache trống. Nếu database không gánh nổi lưu lượng khi không có cache, cache đang che giấu một vấn đề dung lượng chứ không giải quyết nó.

Đánh đổi cần cân nhắc

Đừng áp cả 11 thứ ngay từ đầu. Phép đo cuối cho thấy phần lớn lợi ích đến từ hai thứ rẻ nhất — cache-aside cơ bản và dấu vắng mặt. Thêm singleflight, khoá cụm, L1 là thêm ba chế độ hỏng mới (khoá kẹt, đuôi 388,7ms, dữ liệu lệch giữa instance) để đổi lấy phần lợi ích còn lại. Thêm từng lớp, mỗi lớp khi đo được rằng nó cần.

Con số trong loạt bài này là của container trên một máy. Redis qua loopback 36,9µs thay vì 200–1000µs như qua mạng thật; PostgreSQL với buffer pool nóng và dữ liệu nhỏ; không có nhiều instance thật, không có mạng chậm, không có GC pause. Mọi tỉ lệ trong loạt bài đều kiểm chứng được ở hệ của bạn bằng đúng cách đo đó — và đó mới là thứ nên mang đi, không phải con số tuyệt đối.

Cache là tối ưu, không phải kiến trúc. Mọi thứ trong 12 phần này đều nhằm làm hệ thống nhanh hơn và nhẹ tải hơn — không có phần nào làm nó đúng hơn. Ngược lại là khác: mỗi tầng cache thêm vào là thêm một nơi dữ liệu có thể sai. Nếu một tính năng chỉ chạy được khi có cache, thì đó không còn là cache nữa, mà là một database thiếu độ bền.

Ba ý mang về

  1. Mười một bài học cộng lại đưa truy vấn database từ 101.739 xuống 5.300 — đúng sàn lý thuyết (mỗi key nạp một lần) — và lưu lượng Redis từ 100% xuống 0,1%, p99 tốt hơn 280 lần.
  2. Phần lớn khoảng cách đó đến từ bài học rẻ nhất: ~96.400 trong 96.439 truy vấn tiết kiệm được là nhờ dấu vắng mặt chặn xuyên thủng cache. Trước khi thêm cơ chế phức tạp, hãy đo xem tải database thực sự đến từ đâu.
  3. Bản "đúng chuẩn" có p99 tốt hơn nhưng max xấu hơn (388,7ms so với 21,5ms) vì chống stampede dời chi phí sang hàng đợi người thua. Mọi cơ chế bảo vệ đều có hình dạng đánh đổi như vậy — biết trước thì không hoảng, và chữa được bằng làm nóng cache hoặc trả bản cũ.

Nguồn