Từ phần 3 tới giờ, mọi bài đều kết luận cùng một điều: cache-aside thuần để dữ liệu cũ trong khoảng TTL, và muốn hẹp hơn thì phải xoá cache chủ động khi ghi. Đã đến lúc làm chuyện đó, và nó hoá ra tinh tế hơn một dòng rdb.Del(key) rất nhiều.
Cụ thể: có hai cách viết trông hợp lý như nhau, khác nhau đúng thứ tự hai câu lệnh, và chúng cho tỉ lệ đọc sai chênh nhau gần 10 lần.
Bốn cách ghi, cùng một nghiệp vụ

Hình 1: Bốn cách ghi và lý do cách 3 sai. Chỉ cần một lần xen kẽ: người đọc miss sau khi cache bị xoá nhưng trước khi database được cập nhật, nó đọc ra giá cũ rồi ghi giá cũ đó trở lại cache.
Phép đo cố ý đặt TTL 300 giây — dài hơn hẳn thời gian chạy thí nghiệm — để TTL không "cứu" được gì. Mọi tính mới phải đến từ chính cơ chế invalidation. Rồi cho 8 người đọc chạy liên tục trong khi một người ghi đổi giá 100 lần, mỗi 20ms, và đếm số lượt đọc ra giá khác giá thật trong database.
Đo thật: 7,26% so với 66,82%

Hình 2: Kết quả thật. Cột tỉ lệ sai và cột "sau 2s" phải đọc cùng nhau — chúng nói về hai loại lỗi khác nhau.
- Chỉ dựa vào TTL: 100% sai. Không bất ngờ, và đây là đường cơ sở cần có: với TTL 300 giây và dữ liệu đổi mỗi 20ms, cache không bao giờ đúng sau lần ghi đầu tiên. Nó xác nhận lại kết luận của phần 3 ở dạng cực đoan — TTL dài là lựa chọn về tính đúng đắn, không phải chi tiết kỹ thuật.
- Ghi DB rồi xoá cache: 7,26% sai. Gần như toàn bộ phần sai này rơi vào cửa sổ vài mili-giây giữa lúc
UPDATEhoàn tất và lúcDelchạy xong — trong khoảnh khắc đó, database đã có giá mới mà cache vẫn giữ giá cũ. Cửa sổ này thu hẹp được (đặtDelngay sauUPDATE) nhưng không xoá được hoàn toàn: hai hệ thống khác nhau thì không có cách nào đổi cùng lúc. - Xoá cache rồi ghi DB: 66,82% sai. Gấp hơn 9 lần, và nguyên nhân không phải cửa sổ rộng hơn — mà là một lỗi khác hẳn về bản chất, xem phần dưới.
- Ghi DB rồi ghi đè cache: 7,27% sai, thực tế ngang cách 2. Chạy ba lần thì cách 2 cho 6,87–8,03% còn cách 4 cho 6,77–9,62% — chúng đổi chỗ nhau giữa các lần, tức chênh lệch nằm trong nhiễu đo. Cách 3 thì không bao giờ đổi chỗ: 66,8–76,5%, luôn tệ hơn gần một bậc.
Cột "sau 2s" mới là cột đáng sợ
Tỉ lệ phần trăm ở trên dễ bị đọc nhầm thành "cách 3 sai nhiều hơn một chút". Cột cuối cùng mới cho thấy đó là hai loại lỗi hoàn toàn khác nhau. Sau khi ngừng hẳn mọi ghi và mọi đọc 2 giây, mình soi lại cache:
| cách | sau 2 giây |
|---|---|
| 1. chỉ TTL | cache kẹt sai: 100.00 trong khi DB là 200.00 |
| 2. ghi DB rồi xoá cache | cache đúng |
| 3. xoá cache rồi ghi DB | cache kẹt sai: 199.00 trong khi DB là 200.00 |
| 4. ghi DB rồi ghi đè cache | cache đúng |
7,26% của cách 2 là sai thoáng qua — cứ mỗi lần ghi lại có một khe vài mili-giây, rồi hệ thống tự hội tụ về đúng. Hệ thống tự chữa lành.
66,82% của cách 3 là sai dính luôn — cache giữ giá 199 trong khi database đã sang 200, và nó nằm đó cho tới khi TTL 300 giây hết. Không có lần ghi nào sau đó chữa được, vì mỗi lần ghi lại tạo ra đúng cơ hội cho lỗi lặp lại. Nếu thay TTL bằng một giá trị "an toàn" kiểu một giờ, bạn có một giờ phục vụ giá sai mà không một log nào báo.
Cơ chế của cách 3 đáng viết rõ vì nó là một trong những lỗi cache kinh điển:
- Người ghi xoá cache.
- Người đọc miss,
SELECTra giá cũ (database chưa được cập nhật). - Người ghi
UPDATEdatabase sang giá mới. - Người đọc
Setgiá cũ vừa đọc được vào cache — đè lên sau khiDelđã chạy.
Kết cục: database mới, cache cũ, và không ai còn xoá nó nữa. Đảo thứ tự ở cách 2 chặn đúng bước 4: Del luôn chạy sau khi database đã có giá mới, nên mọi lần nạp lại đều lấy ra giá đúng.
Đánh đổi cần cân nhắc
Cách 2 vẫn còn một khe hở, chỉ là rất hẹp. Đúng cái xen kẽ bốn bước ở trên vẫn xảy ra được với cách 2 nếu người đọc bị treo giữa bước SELECT và bước Set đủ lâu để người ghi chạy hết cả UPDATE lẫn Del. Trong thực tế việc này cực hiếm (người đọc phải chậm hơn cả một vòng ghi), nhưng không phải không thể — nó là lý do TTL vẫn phải giữ như đã nói ở phần 3: TTL là lưới an toàn đặt trần cho thiệt hại khi invalidation hụt. Muốn đóng hẳn khe này thì phải xoá hai lần (delayed double delete) hoặc đổi sang đọc-ghi qua một đường duy nhất, và cả hai đều đắt hơn nhiều so với giá trị thu được ở hầu hết hệ thống.
Cách 4 (ghi đè cache) có một chế độ hỏng mà phép đo này không bắt được. Mình chỉ dùng một người ghi, nên không bao giờ có hai lần ghi đua nhau. Với hai người ghi đồng thời, thứ tự tới Redis có thể ngược với thứ tự tới database: A ghi DB giá 10, B ghi DB giá 20, rồi B Set cache 20, rồi A Set cache 10 — database là 20, cache là 10, kẹt luôn. Cách 2 miễn nhiễm với chuyện này vì Del không mang giá trị: hai lần Del chồng nhau vẫn cho cùng một kết quả. Đây là lý do thực sự người ta chọn xoá thay vì ghi đè, chứ không phải vì nó nhanh hơn — và phép đo một-người-ghi ở trên không nhìn thấy lý do đó. Ghi đè chỉ an toàn khi bạn có cách sắp thứ tự (số phiên bản, hoặc ghi qua một đường duy nhất).
Xoá cache có thể thất bại, và lúc đó không ai biết. rdb.Del trả về lỗi nếu Redis không với tới được. Nếu code bỏ qua lỗi đó — rất dễ, vì nó nằm trên đường ghi và không ai muốn làm hỏng một giao dịch vì cache — thì cache giữ giá cũ tới hết TTL. Hệ thống nghiêm túc đẩy việc xoá sang một đường tin cậy hơn: đọc WAL/binlog của database (CDC) hoặc phát sự kiện qua outbox, để việc xoá chắc chắn xảy ra kể cả khi tiến trình ghi đã chết. Đó là bước đi tiếp theo khi TTL không đủ chặt.
Số đo ở đây là một key, một người ghi, trong một container. Tỉ lệ 7,26% phụ thuộc trực tiếp vào tần suất ghi (mỗi 20ms) và độ dài cửa sổ UPDATE→Del; với dữ liệu đổi mỗi vài phút thì tỉ lệ đó gần 0. Thứ mang đi được không phải con số mà là thứ hạng và bản chất lỗi: cách 2 và cách 4 sai thoáng qua rồi hội tụ, cách 1 và cách 3 sai dính luôn.
Ba ý mang về
- Thứ tự quyết định, không phải việc có xoá hay không: ghi DB rồi xoá cache cho 7,26% lượt đọc sai, đảo lại thành xoá cache rồi ghi DB cho 66,82% — vì người đọc xen vào giữa sẽ nạp lại giá cũ sau khi
Delđã chạy. - Phân biệt sai thoáng qua với sai dính luôn. Cùng là "đọc ra giá sai", nhưng cách 2 tự hội tụ sau mỗi lần ghi, còn cách 3 để cache kẹt ở giá trị sai cho tới khi TTL hết — đo bằng cách ngừng hẳn mọi hoạt động rồi soi lại cache. Tỉ lệ phần trăm một mình không phân biệt được hai thứ này.
- Chọn
Delchứ khôngSetkhi invalidate — không phải vì nhanh hơn, mà vìDelkhông mang giá trị nên hai lần ghi đồng thời không thể đè nhau theo thứ tự ngược. Và giữ TTL như lưới an toàn: mọi cơ chế invalidation đều có khe hở hoặc có thể thất bại im lặng.
Nguồn
- Redis docs — DEL: https://redis.io/docs/latest/commands/del/
- Martin Kleppmann — Designing Data-Intensive Applications, ch.11 (derived data, CDC)
- AWS Builders' Library — Caching challenges and strategies: https://aws.amazon.com/builders-library/caching-challenges-and-strategies/
- PostgreSQL docs — Logical decoding (nền tảng của CDC): https://www.postgresql.org/docs/current/logicaldecoding.html
Phần sau ta đi tiếp hướng còn lại: thay vì để ứng dụng tự lo cả đọc lẫn xoá, đưa cache vào thẳng đường ghi — write-through và write-back. Đo thật độ trễ mỗi lần ghi, lượng ghi xuống database, và cái giá phải trả khi tiến trình chết giữa chừng.