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ụ

Ảnh chụp đoạn mã nền tối invalidation thứ tự hai dòng code quyết định đúng sai với TTL 300s cố tình rất dài nên mọi tính mới phải đến từ invalidation, bốn cách ghi cho cùng một nghiệp vụ đổi giá gồm cách 1 chỉ dựa vào TTL không đụng tới cache chỉ chạy db Exec UPDATE products SET price, cách 2 ghi DB rồi xoá cache là cache-aside chuẩn với db Exec rồi rdb Del ctx key, cách 3 xoá cache rồi ghi DB tức rdb Del rồi db Exec trông cũng hợp lý và sai, cách 4 ghi DB rồi ghi đè cache bằng giá trị mới tức db Exec rồi rdb Set key giaMoi 300 giây, phần vì sao cách 3 sai chỉ cần một lần xen kẽ là đủ với người ghi chạy Del key rồi mới UPDATE giá bằng 200 trong khi người đọc xen vào giữa gặp Get miss rồi SELECT ra 199 là giá cũ rồi Set key bằng 199 tức ghi đè sau khi Del nên kết quả là DB bằng 200 còn cache bằng 199 và nó kẹt nguyên như vậy tới khi TTL hết trong khi cách 2 đảo thứ tự nên Del luôn chạy sau khi DB đã có giá mới, phép đo dùng 8 người đọc chạy liên tục và người ghi đổi giá 100 lần mỗi 20 mili giây rồi đếm số lượt đọc ra giá khác DB và ngừng hẳn 2 giây để xem cache còn sai không

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%

Ảnh chụp output thật nền tối đảo thứ tự khiến 7,26 phần trăm sai thành 66,8 phần trăm sai với Go Redis 7 PostgreSQL 16 và 8 người đọc 100 lần ghi mỗi 20ms TTL 300s, bảng tỉ lệ đọc ra giá khác database gồm cách 1 chỉ dựa vào TTL đọc 10971 lượt sai 10971 lượt là 100 phần trăm và sau 2 giây cache kẹt sai ở 100.00 trong khi DB là 200.00, cách 2 ghi DB rồi xoá cache đọc 10632 sai 772 là 7.26 phần trăm và sau 2 giây cache đúng, cách 3 xoá cache rồi ghi DB đọc 10776 sai 7200 là 66.82 phần trăm và sau 2 giây cache kẹt sai ở 199.00 trong khi DB là 200.00, cách 4 ghi DB rồi ghi đè cache đọc 10768 sai 783 là 7.27 phần trăm và sau 2 giây cache đúng, chạy 3 lần thì cách 2 cho 6,87 đến 8,03 phần trăm cách 3 cho 66,8 đến 76,5 phần trăm cách 4 cho 6,77 đến 9,62 phần trăm nên cách 2 và cách 4 đổi chỗ nhau giữa các lần chạy vì chênh lệch nằm trong nhiễu đo còn cách 3 thì sai gấp gần 10 lần lần nào cũng vậy, phần cột sau 2 giây mới là cột đáng sợ vì ngừng hẳn mọi ghi và đọc 2 giây rồi soi lại cache thì cách 2 và cách 4 cho cache khớp DB tức sai chỉ trong cửa sổ vài mili giây còn cách 1 và cách 3 cho cache kẹt ở giá trị sai và nằm đó tới khi TTL 300 giây hết, nên 7,26 phần trăm của cách 2 là sai thoáng qua giữa UPDATE và Del còn 66,82 phần trăm của cách 3 là sai dính luôn và đó là hai loại lỗi khác hẳn nhau, nói thẳng cách 4 ghi đè cache đo ra ngang cách 2 nhưng nó có một chế độ hỏng mà phép đo một người ghi này không bắt được là hai người ghi đồng thời có thể ghi đè nhau theo thứ tự ngược

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 UPDATE hoàn tất và lúc Del chạ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 (đặt Del ngay sau UPDATE) 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:

  1. Người ghi xoá cache.
  2. Người đọc miss, SELECT ra giá cũ (database chưa được cập nhật).
  3. Người ghi UPDATE database sang giá mới.
  4. Người đọc Set giá cũ vừa đọc được vào cache — đè lên sau khi Del đã 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ề

  1. 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.
  2. 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.
  3. Chọn Del chứ không Set khi invalidate — không phải vì nhanh hơn, mà vì Del khô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

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.