Đang tải nội dung…
Đang tải nội dung…
Giá sản phẩm cũ, hai thứ tự đều hở và một consumer WAL có thể sửa lại thế nào
Redis Internals & Cache Architecture · Phần 6Bối cảnh hư cấu, bằng chứng lab thật. Fortex trong tuyến truyện này là bối cảnh hư cấu; Tom và Ben là nhân vật. Các cuộc trao đổi minh họa quá trình suy nghĩ. Số đo và assertion thuộc lab Docker được dẫn trong bài, không phải số liệu production của Fortex.
Hành trình ở Fortex · Chương 14
Đúng 10:15 sáng, điện thoại nội bộ của Ben rung lên bần bật. Đầu dây bên kia là giọng bức xúc của một Merchant VIP:
“Tại sao tai nghe bên tôi vừa tăng giá từ 100$ lên 110$ từ nửa tiếng trước, trên trang quản trị đã báo cập nhật thành công, mà khách hàng ngoài ứng dụng vẫn mua ầm ầm với giá 100$? Ai sẽ bù khoản tiền chênh lệch này cho chúng tôi?”
Ben toát mồ hôi hột. Cậu vội vàng bật terminal kiểm tra:
SELECT price FROM products WHERE id = 1; → Trả về con số 110.GET product:1 → Vẫn chình ình con số 100!Ben gõ ngay lệnh DEL product:1 trên Redis. Ngay lập tức, trang bán hàng refresh lại và hiển thị đúng 110$.
Thở phào nhẹ nhõm, Ben mở ngay source code của backend ra soi. Cậu phát hiện ra logic cập nhật sản phẩm đang được viết: Chỉ chạy duy nhất một câu lệnh UPDATE products SET price = 110 WHERE id = 1 xuống PostgreSQL mà hoàn toàn không hề có dòng nào động đến Redis!
“Hèn gì!” Ben cười thầm. Cậu mở ngay một pull request nóng: Thêm đúng một dòng lệnh xóa cache ngay sau khi database commit:
await db.query('UPDATE products SET price = $1 WHERE id = $2', [newPrice, productId]);
await db.commit();
await redis.del(`product:${productId}`); // Ben thêm dòng này
Ben hí hửng kéo ghế sang khoe Tom: “Anh Tom ơi, em tìm ra thủ phạm rồi! Do ai đó lúc viết API đổi giá quên không xóa cache. Em vừa bổ sung lệnh redis.del ngay sau lệnh db.commit(). Làm thế này thì đảm bảo dữ liệu trong database đã ghi xong xuôi rồi mới xóa cache, không bao giờ lo lệch giá nữa!”
Tom nhìn dòng code mới của Ben, không khen cũng không chê. Anh chỉ vào đúng khoảng trống giữa dòng commit() và dòng redis.del() rồi hỏi:
“Nếu tiến trình Node.js bị Kubernetes OOM-kill, sập nguồn, hoặc mạng từ server backend sang Redis bị rớt đúng vào khoảng trống này thì sao?”
Nụ cười trên môi Ben tắt ngấm.
Cậu nhận ra sự thật cay đắng: Database thì đã commit thành công con số 110$, nhưng lệnh redis.del chưa kịp gửi đi thì tiến trình đã chết! Giá trị 100$ trong Redis sẽ tiếp tục nằm trơ trơ ở đó, và khách hàng vẫn sẽ mua với giá cũ cho đến khi TTL hết hạn (hoặc sống vĩnh viễn nếu key không set TTL)!
“Thế thì...” Ben nhanh nhảu chữa cháy, “Thế thì em đổi ngược lại thứ tự! Em xóa cache trước, rồi mới cập nhật database sau!”
await redis.del(`product:${productId}`); // Xóa cache trước
await db.query('UPDATE products SET price = $1 WHERE id = $2', [newPrice, productId]);
await db.commit();
Ben lập luận: “Xóa cache trước thì an toàn hơn chứ anh! Nếu xóa cache xong mà DB commit lỗi, cùng lắm khách bị cache miss một lần rồi đọc lại dữ liệu cũ từ DB nạp vào cache. Đâu có bị kẹt dữ liệu sai!”
Tom lắc đầu, kéo bảng trắng lại: “Em mới chỉ nhìn một mình Writer đang chạy đơn độc. Em đã tính đến trường hợp có hàng trăm Reader (khách hàng xem sản phẩm) đang truy cập đồng thời chưa?”
Tom vẽ một dòng thời gian với hai tác nhân chạy song song:
Writer (Đổi giá 100 -> 110) Reader (Khách xem hàng)
---------------------------------- ----------------------------------
1. redis.del('product:1') (Cache trống)
2. Đọc Redis: Cache Miss!
3. Đọc DB: Thấy giá cũ là 100!
(Vì Writer chưa kịp commit)
4. Ghi 100 vào lại Redis!
5. db.query('UPDATE ... price = 110')
6. db.commit() (DB lên 110)
Mặt Ben tái mét. Cậu tự lẩm bẩm:
“Kinh hoàng thật... Reader đọc thấy 100 từ DB lúc Writer chưa commit, rồi vác con số 100 đó nạp đè lại vào Redis! Sau đó Writer mới commit 110. Kết quả cuối cùng: Database lưu 110, nhưng Redis lại bị nạp lại con số 100 cũ rích!”
Bài lab thực nghiệm của WhyBit đã tái hiện chính xác hai lịch trình thực thi đồng thời này:
| Lịch thực thi | DB cuối cùng | Cache cuối cùng | Bản chất nguyên nhân lỗi |
|---|---|---|---|
| DB trước, mất bước DEL | 110 | 100 | Commit DB và Invalidate Cache là hai side-effect phân tán, không thể atomic |
| DEL trước, reader chen ngang | 110 | 100 | Reader đọc trúng DB cũ trong lúc writer chưa commit rồi nạp đè lại cache |
“Em từng đọc trên mạng có người mách nước dùng chiêu Delayed Double Deletion (Xóa kép có trì hoãn),” Ben gãi đầu. “Nghĩa là: Xóa cache lần 1 → Cập nhật DB → sleep(500ms) → Xóa cache lần 2 để quét sạch những gì Reader vừa nạp nhầm. Cách này có ổn không anh?”
Tom bật cười: “Đó là giải pháp ‘cầu may’ kinh điển mà các ứng viên hay trả lời trong phỏng vấn khi chưa từng vận hành hệ thống lớn.”
Tom phân tích từng lỗ hổng chí mạng của chiêu thức này:
sleep ngay trong luồng xử lý request sẽ giữ chặt connection pool và luồng xử lý của backend, bóp nghẹt throughput của cả hệ thống.“TTL (Time-To-Live) chỉ giúp giới hạn ‘tuổi thọ’ của dữ liệu sai lệch, chứ không thể xóa bỏ khoảng trống bất nhất này,” Tom kết luận. “Muốn giải quyết triệt để, ta phải tìm một cơ chế mà ý định xóa cache phải bắt nguồn từ chính nơi dữ liệu được xác nhận thành công!”
“Nơi duy nhất xác nhận sự thật là gì?” Tom hỏi.
“Là Transaction Log của Database!” Ben sáng mắt ra. “Trong PostgreSQL, đó là Write-Ahead Log (WAL). Một khi transaction đã commit, nó được ghi bền vững xuống WAL trên đĩa cứng!”
Chính xác! Thay vì để tầng ứng dụng cố gắng làm hai việc ghi phân tán (Dual Write), giải pháp chuẩn mực của các hệ thống quy mô lớn là sử dụng Change Data Capture (CDC):
COMMIT.Tài liệu chính thức của Debezium PostgreSQL connector chỉ rõ: Debezium lưu giữ vị trí WAL đã xử lý (LSN - Log Sequence Number). Nếu Debezium bị crash hoặc tắt đi, khi khởi động lại nó sẽ tiếp tục đọc chính xác từ LSN cũ. Cơ chế này cam kết việc phân phát sự kiện là At-Least-Once (ít nhất một lần).
“Nhưng anh ơi, At-Least-Once có nghĩa là event có thể bị gửi trùng lặp!” Ben thắc mắc. “Nếu lệnh xóa cache bị chạy lại hai lần thì sao?”
Tom mỉm cười: “Em thử gõ lệnh DEL product:1 hai lần liên tiếp trên Redis xem kết quả thế nào?”
Ben gõ thử:
(integer) 1.(integer) 0.“Nó hoàn toàn Idempotent (bất biến với số lần gọi)!” Ben reo lên. “Dù có gửi lại 10 lần thì trạng thái cuối cùng của Redis vẫn là: key đó không tồn tại. Lần đọc tiếp theo của người dùng chắc chắn sẽ xuống DB lấy giá trị mới nhất!”
Trong phòng lab thực nghiệm của WhyBit, kịch bản CDC được thiết lập với PostgreSQL 17.6 sử dụng replication slot whybit_cache và plugin giải mã logic chuẩn test_decoding:
100.110 trong PostgreSQL và commit thành công.100 (khoảng trống trễ dữ liệu).BEGIN 746
table public.products: UPDATE: id[integer]:1 price[integer]:110
COMMIT 746
id: 1, gửi lệnh DEL product:1 sang Redis.110 từ PostgreSQL và nạp lại vào Redis. Dữ liệu chính thức hội tụ hoàn toàn về 110!“Đó chính là giá trị vô giá mà CDC mang lại,” Tom giải thích. “Ý định xóa cache không còn là một lời hứa bấp bênh trong bộ nhớ RAM của một ứng dụng backend. Nó được neo chặt vào transaction log của cơ sở dữ liệu. Dù backend hay consumer có chết bao nhiêu lần, dữ liệu thay đổi vẫn nằm nguyên trong WAL chờ ngày được xử lý!”
Ben hào hứng: “Vậy từ nay cứ dùng CDC là bài toán cache hoàn hảo 100% rồi đúng không anh?”
“Không có giải pháp nào là viên đạn bạc,” Tom nghiêm túc. “CDC mua cho em tính bền vững và khả năng phục hồi, nhưng nó đặt lên vai đội ngũ vận hành những thách thức kỹ thuật rất lớn.”
Ben ghi lại danh mục các rủi ro vận hành cần kiểm soát:
max_slot_wal_keep_size và thiết lập cảnh báo retained WAL bytes (PostgreSQL replication slots).Toàn bộ các assertion của bài lab được kiểm chứng tự động trong môi trường container độc lập:
Hình báo cáo được dựng từ report.json của run 771d0fda-ed2c-4d43-8c88-78df7c125ba0: DB = 110, cache cũ = 100, WAL giữ transaction 746 và hai lần DEL trả về 1 rồi 0. Đây không phải dashboard production.
| Nhánh thực nghiệm | Điều kiện kiểm thử | Kết quả quan sát |
|---|---|---|
| DB first, mất bước DEL | Crash sau commit, trước lệnh DEL | DB = 110, Cache = 100 (Bất nhất vĩnh viễn) |
| DEL first, reader chen ngang | Reader đọc trúng DB cũ trước khi commit | DB = 110, Cache = 100 (Bị nạp đè dữ liệu cũ) |
| WAL CDC Recovery | Consumer offline trong lúc DB commit, sau đó bật lại | WAL lưu trữ thay đổi, Consumer đọc và DEL, Cache hội tụ về 110 |
| Idempotent DEL | Gửi lặp lại lệnh xóa key đã xóa | Lần 1 trả về 1, lần 2 trả về 0, trạng thái an toàn tuyệt đối |
Report JSON · Raw log · Mã nguồn lab · Run 771d0fda-ed2c-4d43-8c88-78df7c125ba0
Lưu ý về phạm vi thực nghiệm: Lab chạy trên một máy chủ đơn lẻ chứa PostgreSQL 17.6 và Redis 7.4.2 để cô lập hành vi logic. Thử nghiệm chứng minh khả năng lưu giữ thay đổi của logical slot và tính bất biến của lệnh DEL; không mô phỏng Debezium Kafka Connect cluster hay độ trễ phân tán trên production.
Ben đóng nắp laptop, tổng kết lại thiết kế mới cho Fortex:
ProductChanged.DEL trên Redis.cdc_consumer_lag_seconds và postgres_wal_retained_bytes.version của sản phẩm để client nhận diện nếu dữ liệu vô tình bị cũ.Tom mỉm cười hỏi: “Vậy hệ thống của em đã đạt được tính nhất quán tức thì (Strong Consistency) chưa?”
“Chưa anh,” Ben cười đáp. “Trong hệ thống phân tán, cache muôn đời chỉ là Eventual Consistency. Nhưng giờ đây, dữ liệu sai lệch không còn bị bỏ quên vĩnh viễn chỉ vì một dòng code bị sập nguồn. Và quan trọng nhất: tụi mình biết chính xác nó đang trễ bao nhiêu mili-giây!”
Fortex và các nhân vật hội thoại là bối cảnh hư cấu minh họa. Toàn bộ số đo, đồ thị khóa và log kỹ thuật trong bài viết đều được trích xuất nguyên bản từ phòng lab Docker có thể tái lập.
Trước: Chương 13 — Deadlock vô hình · Tiếp: Chương 15 — Kafka Hot Key
PostgreSQL 17.6 logical decoding + Redis 7.4.2 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT a035d919d67e3863bdaab6961089bec3d917905e
Đọc kỹ README trước khi chạy. Các lệnh thực nghiệm chuẩn trong Docker Lab:
make setupmake seedmake reproducemake verifymake cleanupMôi trường Docker độc lập, giới hạn tài nguyên và tự động dọn dẹp sau khi chạy. Số liệu trong bài viết là kết quả lần chạy chính thức; mỗi lần chạy mới đều lưu lịch sử riêng.