Đang tải nội dung…
Đang tải nội dung…
Cache hit rate đẹp vẫn có thể che một đợt tải trùng ngay lúc key biến mất.
Redis Internals & Cache Architecture · Phần 1Bố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 5/8
Ben thêm cache cho trang sản phẩm ở Fortex. Cậu thích thay đổi này: ít đụng code nghiệp vụ, đọc nhanh khi key đã có, database đỡ việc. Sau buổi tồn kho, cậu cũng cẩn thận không dùng cache làm nguồn quyết định trừ hàng.
“Luồng ghi em giữ trong DB. Luồng đọc có Redis là ổn rồi anh.”
Tom nhìn hàm cache-aside. “Ổn khi key có. Em đưa anh qua lúc nó vừa hết hạn nhé.”
Ben lần theo code: GET miss, đọc origin, SET cache. “Request đầu sẽ nạp lại.”
“Trong lúc request đầu chưa nạp xong, request tiếp theo thấy gì?”
Ben im một nhịp, rồi mở thêm cửa sổ. Lần này cậu đã quen đặt nhiều người gọi cạnh nhau. Chỗ khiến cậu vấp không phải phép ghi sai; mỗi người đều đang làm cùng một việc đúng, vào cùng một lúc.
Tom đề nghị tạm bỏ tỷ lệ hit trung bình khỏi màn hình. Ben chọn một câu hỏi hẹp hơn: cùng một nhóm người đọc, origin bị gọi bao nhiêu lần khi tất cả cùng thấy key trống?
Một key giá sản phẩm được đọc rất nhiều, TTL là một phút. Trong phần lớn thời gian, mọi thứ nhẹ nhàng: GET trúng cache, trả response. Nhưng ở đúng thời điểm key biến mất, 12 request có thể cùng thấy cache miss. Mỗi request đều làm đúng đoạn code của nó: hỏi database, rồi điền lại cache.
Kết quả là cùng một giá trị bị tính lại 12 lần. Nếu thao tác gốc đắt, tải dồn vào origin có thể làm connection pool đầy; request chậm kéo theo timeout và retry, khiến tình hình nặng hơn. Đây là cache stampede. Tăng số connection đôi khi chỉ giúp nhiều công việc trùng lặp chen vào DB cùng lúc.
Ta sẽ giữ nguyên 12 người đọc và cùng một giá trị, chỉ thay cách phối hợp khi miss.
“Em sẽ cho request đầu giữ lock,” Ben đề xuất.
“Thử đi,” Tom nói. “Nhưng trước khi viết, vẽ đường đi của người lấy lock sau cùng. Khi đến lượt, nó có biết người trước đã nạp xong chưa?”
value = cache.get(key)
if value is missing:
value = load_from_database(key)
cache.set(key, value, ttl)
return value
Đoạn này hợp lý khi xét một request. Nó chưa trả lời ai được phép nạp lại một key khi nhiều request miss cùng lúc. Các request không chia sẻ quyết định “tôi đang nạp” chỉ vì chúng dùng chung Redis.
Trong lab, 12 thread đều GET key rỗng, rồi dừng ở barrier trước khi đi tiếp. Nhánh thường vì thế có chính xác 12 lần truy cập origin. Đây là lịch cold-start được chủ động tạo ra; không phải lời khẳng định traffic thật luôn miss theo nhóm 12.
Ben thêm lần đọc cache sau khi lấy lease. “Nếu chỉ xếp hàng rồi ai cũng nạp, mình mới đổi chỗ chen chúc.”
Tom đưa thêm tình huống owner bị pause. Ben đã muốn viết DEL để nhả khóa; giờ cậu phải nghĩ xem mình có còn là chủ của khóa ấy không.
Nhánh đối chiếu dùng một lease ngắn hạn:
GET product:42
if missing:
SET lock:42 <unique-token> NX PX 30000
winner: recheck cache → load origin → SET cache → release own lease
others: wait briefly → recheck cache, stop at deadline
Cần kiểm tra cache lần nữa sau khi giành lease. Trong khoảng từ lần GET trước đến lúc lấy khóa, người khác có thể đã nạp xong và nhả khóa. Không recheck sẽ biến các lần nạp đồng thời thành một hàng dài nạp lại cùng dữ liệu.
Token của mỗi owner phải khác nhau. Khi nhả khóa, so sánh token và xóa phải là một thao tác nguyên tử:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
Một lệnh DEL lock:42 vô điều kiện có thể xóa lease của owner mới nếu lease cũ hết hạn trong lúc owner cũ bị pause. Tài liệu Redis về distributed locks giải thích token và giới hạn thời gian của lease. Lab này dùng một Redis instance để giảm công việc nạp lại; nó không chứng minh được mutual exclusion qua mọi tình huống failover.
Tom giữ hai nhánh có cùng số người đọc và cùng dữ liệu trả về. Ben chịu trách nhiệm đếm lần chạm origin trong PostgreSQL. “Em sẽ báo giảm công việc trùng,” cậu nói. “Chưa đo được p99 thì không viết giảm p99.”
Docker chạy PostgreSQL 17.6 và Redis 7.4.2, đều pin digest. Origin được đại diện bằng một UPDATE tăng bộ đếm trong PostgreSQL, cộng 250 ms chờ có chủ đích. Giá trị trả về cố định là price=120. Mỗi nhánh xóa cache và reset bộ đếm.
| Nhánh / điểm đo | Kết quả quan sát |
|---|---|
| Cache aside | 12 |
| Lease + double check | 1 |
Cả hai nhánh đều trả đủ 12 response giống nhau. Nhánh có lease chỉ chạm origin một lần. Assertion bổ sung đặt khóa thuộc new-owner, rồi cho old-owner thử release: khóa vẫn còn.
Report, raw log và source cuối bài đủ để chạy lại bằng make reproduce. Phép đo này đếm công việc gốc; không đo QPS hay mức giảm p99. Chi phí docker compose exec của driver lớn hơn nhiều so với client Redis dùng connection lâu dài, nên dùng thời gian tổng ở đây để kết luận tốc độ ứng dụng sẽ sai.
“Có token rồi thì chỉ một người được chạy, đúng không anh?”
Tom hỏi ngược rất nhẹ: “Token được kiểm tra lúc nhả khóa. Trong lúc chủ cũ vẫn đang chạy, ai buộc nó dừng?”
Ben gạch chữ bảo đảm trong ghi chú. Ca owner mới không bị xóa nhầm khóa chưa phải ca owner cũ không thể ghi dữ liệu muộn.
Nếu owner mất 35 giây nhưng lease chỉ có 30 giây, một request khác có thể giành lease mới và cùng nạp dữ liệu. So sánh token khi release ngăn xóa nhầm khóa; nó không ngăn owner cũ tiếp tục chạy hoặc ghi cache. Có thể cần generation/version để chặn kết quả cũ ghi đè, hoặc chấp nhận thỉnh thoảng nạp trùng nếu dữ liệu chỉ là cache.
Đừng dùng khóa cache này làm bằng chứng “chỉ trừ tiền đúng một lần”. Với mutation quan trọng, cần invariant ở hệ thống sở hữu dữ liệu và idempotency theo ý định nghiệp vụ. Với phép cập nhật trong DB, atomic UPDATE và version check giải quyết một lớp vấn đề khác.
Ben muốn thêm lease cho mọi key. Tom hỏi dữ liệu nào chấp nhận bản cũ, key nào chỉ nằm trong một process, và khi Redis lỗi thì nhóm định cho bao nhiêu request xuống origin.
Cậu chia đề xuất theo yêu cầu dữ liệu thay vì theo một pattern vừa học. Chỗ nào cho phép stale, chỗ nào cần chờ, chỗ nào nên từ chối — đó là quyết định Fortex còn phải làm.
| Phương án | Được gì | Đổi lại điều gì |
|---|---|---|
| Singleflight trong một process | Ít code, không thêm round trip đến Redis | Nhiều replica vẫn có thể cùng nạp |
| Lease trên Redis | Phối hợp giữa các replica | Thêm Redis dependency, deadline, expiry và failure handling |
| Stale-while-revalidate | Người đọc không phải chờ origin khi có bản cũ | Chấp nhận stale; phải định nghĩa tuổi tối đa và chính sách dữ liệu nhạy cảm |
| TTL jitter | Giảm nhiều key hết hạn cùng lúc | Không tự xử lý đám đông chờ một hot key |
| Prewarm / refresh sớm | Giảm cold miss của key đã biết | Công việc nền và khả năng nạp thứ không ai cần |
Waiter phải có deadline và backoff có jitter; vòng lặp GET sát nhau có thể chuyển stampede từ database sang Redis. Khi Redis lỗi, đừng mặc định cho mọi request đi thẳng xuống DB. Có thể dùng concurrency limit, trả stale phù hợp, hoặc từ chối có kiểm soát tùy loại dữ liệu.
Khi triển khai, theo dõi origin loads trên mỗi cache miss group, thời gian chờ nạp lại, cache age và tỷ lệ timeout. Hit rate trung bình cao vẫn có thể che đúng vài giây làm origin quá tải. Cache hiệu quả không chỉ vì lưu được dữ liệu, mà còn vì hệ thống biết chia sẻ công việc khi dữ liệu chưa có.
Ben đóng bài bằng một điều kiện chứ không bằng lời hứa: lease giúp gộp việc trong phạm vi đã thử; expiry và failover vẫn cần thiết kế riêng. Trang sổ mới ghi: “Đừng nhầm phối hợp công việc với bảo đảm nghiệp vụ.”
Câu đó theo cậu sang chức năng tiếp theo: tạo đơn xong phải báo cho kho. Ben tưởng lần này chỉ cần thêm một lệnh gửi event sau commit.
Chương trước: Hai request cùng trừ kho, vì sao chỉ mất một lần? · Chương tiếp: Database đã commit, vì sao event vẫn mất? · Bắt đầu hành trình
PostgreSQL 17.6 + Redis 7.4.2 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT e4881db7ab326ce553bbb0172c44fab421517f08
Đọ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.