Ba phần trước đều nói về một key rời khỏi cache: hết TTL (phần 3) hoặc bị đá vì hết chỗ (phần 4). Cả hai lần, mình mô tả hậu quả bằng một câu nhẹ nhàng: "request tiếp theo sẽ miss và điền lại". Câu đó đúng khi có một request tiếp theo.

Nhưng key nóng thì không có "một request tiếp theo" — nó có hàng trăm, trong cùng một phần nghìn giây. Tất cả cùng miss. Tất cả cùng đi tiếp. Hiện tượng này tên là cache stampede (hay thundering herd, dog-piling), và nó là cách cache giết chết database vì chính nó biến mất trong một khoảnh khắc.

Chỗ hở nằm giữa hai dòng code

Ảnh chụp đoạn mã nền tối stampede một key biến mất cả đàn lao xuống database trong cache-aside ngây thơ không có gì chặn giữa miss và DB, chỗ hở nằm giữa hai dòng if v err rdb Get ctx key Result err bằng nil thì return v nil còn từ đây tới lúc Set chạy xong key vẫn trống nên mọi request đến trong khoảng đó đều miss và đều đi tiếp qua db QueryRow truyVanNang gộp 400.000 dòng khoảng 8ms rồi rdb Set key v 30 giây, kịch bản đo tất cả cùng xuất phát rdb Del ctx key khi key vừa hết TTL hoặc vừa bị đá rồi for i từ 0 tới N chạy go func chờ batDau rồi docNgayTho với N bằng 1 10 50 200 500 và close batDau để bắn cùng một lúc, pool database tối đa 50 kết nối không phải vô hạn giống production, luật của cửa sổ stampede số request lọt xuống DB xấp xỉ R nhân D với R là lưu lượng req trên giây trên chính key đó và D là độ dài truy vấn gốc nên hệ quả là query càng chậm cửa sổ càng rộng càng nhiều request lọt vào đúng lúc database đang là thứ chậm nhất thì nó lại nhận nhiều việc nhất

Hình 1: Chỗ hở là khoảng giữa Get trả về miss và Set ghi xong. Trong suốt khoảng đó — dài đúng bằng thời gian chạy truy vấn gốc — cache vẫn trống với mọi người đến sau.

Đoạn code ở Hình 1 chính là cache-aside từ phần 2, không sửa gì. Vấn đề không nằm ở một dòng viết sai; nó nằm ở chỗ mẫu cache-aside không có khái niệm "đã có người đang đi lấy". Mỗi request tự quyết định một mình, và tất cả cùng đưa ra đúng một quyết định tại cùng một thời điểm.

Từ đó rút ra được một luật đơn giản, đoán trước được số request sẽ lọt xuống database:

số request lọt xuống DB ≈ R × D, với R là lưu lượng (req/s) trên chính key đó và D là độ dài truy vấn gốc.

Luật này có một hệ quả khó chịu: truy vấn càng chậm, càng nhiều request lọt vào. Đúng lúc database đang là thứ chậm nhất trong hệ thống thì nó lại nhận về nhiều việc nhất. Đó là vòng xoáy, không phải sự cố tuyến tính.

Đo thật: 6,2ms khi một mình, 1119,1ms trong cơn stampede

Mình lấy đúng truy vấn gộp 400.000 dòng đánh giá, xoá key, rồi cho N goroutine cùng xuất phát bằng một channel đóng lại. Pool database giới hạn 50 kết nối — con số thật của một ứng dụng thật, không phải vô hạn.

Ảnh chụp output thật nền tối 500 request cùng miss cả 500 lao xuống DB với Go Redis 7 PostgreSQL 16 và truy vấn gốc 6,2 ms khi chạy một mình, bảng cùng xuất phát sau khi key biến mất với pool Redis đã làm nóng, 1 request chạm DB 1 là 100 phần trăm kẹt ở DB 1 trễ trung bình 6.2 ms xấu nhất 6.2 ms, 10 request chạm DB 10 là 100 phần trăm kẹt 10 trễ trung bình 45.6 ms xấu nhất 58.4 ms, 50 request chạm DB 50 là 100 phần trăm kẹt 50 trễ trung bình 117.1 ms xấu nhất 150.1 ms, 200 request chạm DB 200 là 100 phần trăm kẹt 200 trễ trung bình 297.3 ms xấu nhất 478.3 ms, 500 request chạm DB 500 là 100 phần trăm kẹt 500 trễ trung bình 617.0 ms xấu nhất 1119.1 ms, một request lẻ mất 6,2 ms còn trong cơn stampede là 1119,1 ms chậm hơn khoảng 180 lần, pool database chỉ 50 kết nối nhưng nó không chặn được gì vì 450 request kia không biến mất mà xếp hàng và người dùng chịu đúng cái xếp hàng đó, bảng cửa sổ stampede bằng độ dài truy vấn theo luật R nhân D, query dài 20ms lưu lượng thật 169 req mỗi giây chạm DB 5 dự đoán 3, query 20ms 350 req chạm DB 8 dự đoán 7, query 100ms 170 req chạm DB 18 dự đoán 17, query 100ms 358 req chạm DB 39 dự đoán 36, query 300ms 169 req chạm DB 51 dự đoán 51, query 300ms 367 req chạm DB 101 dự đoán 110, bẫy khi đo là lần chạy đầu quên làm nóng pool kết nối Redis chỉ cho 263 trên 500 chạm DB vì việc mở 500 kết nối mới tự rải các lượt Get ra và che mất gần một nửa cơn stampede

Hình 2: Kết quả thật. Hàng đầu là đường cơ sở — một request lẻ mất 6,2ms. Hàng cuối là cùng công việc đó dưới 500 request đồng thời: cả 500 đều chạm database và request xấu nhất chờ 1119,1ms.

  • Tỉ lệ lọt xuống database là 100% ở mọi mức. Không có gì trong cache-aside ngăn request thứ hai đi tiếp khi request thứ nhất còn đang chạy — nên 500 request miss thì đúng 500 truy vấn giống hệt nhau đổ xuống database. Mười truy vấn giống nhau vẫn là mười lần quét 400.000 dòng; database không giảm giá cho bản sao.
  • Chi phí không chia đều, nó nhân lên. Một request lẻ: 6,2ms. 10 request cùng lúc: trung bình đã 45,6ms — chậm hơn 7 lần chỉ với 10 người. 500 request: trung bình 617,0ms.
  • Request xấu nhất chịu thiệt nhất: 1119,1ms so với 6,2ms — chậm hơn ~180 lần. Đây mới là con số người dùng cảm nhận. Phân bố lệch nặng: người đến sau xếp hàng sau cả pool kết nối lẫn CPU database.

Và đây là chỗ phải nói thẳng, vì nó phản bác một trực giác rất phổ biến: pool database 50 kết nối không hề chặn được stampede. Dễ nghĩ rằng giới hạn pool sẽ chặn ở 50 truy vấn. Không — nó chặn 50 truy vấn đồng thời, nhưng 450 request còn lại không biến mất, chúng xếp hàng rồi lần lượt chạy đúng truy vấn ấy. Tổng công việc database phải làm vẫn là 500 lần gộp 400.000 dòng; pool chỉ đổi "database quá tải" thành "request chậm", và 1119,1ms chính là cái giá của hàng đợi đó.

Một cái bẫy khi đo, đáng kể lại vì nó suýt dẫn mình tới kết luận sai. Lần chạy đầu tiên cho kết quả hiền lành hơn nhiều: chỉ 263/500 request chạm database, trễ xấu nhất 609,5ms, và mình đã tưởng pool database có công hãm bớt. Nguyên nhân thật nằm ở chỗ khác hẳn: pool kết nối của client Redis chưa được làm nóng. 500 goroutine cùng gọi Get buộc thư viện mở 500 kết nối TCP mới, và chính việc mở kết nối đó rải các lượt Get ra theo thời gian — tới lượt nhiều goroutine thì key đã được điền lại rồi. Thêm một vòng Ping làm nóng trước khi đo, con số nhảy lên đúng 100%. Bài học chung cho mọi phép đo đồng thời: thứ bạn chưa làm nóng sẽ âm thầm trở thành bộ điều tiết lưu lượng, và nó che mất chính hiện tượng bạn định đo.

Luật R × D, kiểm chứng bằng số

Phần đo thứ hai thay kiểu "tất cả cùng xuất phát" bằng mô hình thật hơn: request đến đều đặn theo một lưu lượng, key bị xoá ở thời điểm 0, truy vấn gốc có độ dài đặt trước bằng pg_sleep. Rồi đếm có bao nhiêu request lọt xuống database và so với dự đoán R × D:

độ dài truy vấn lưu lượng thật chạm DB (đo) R × D (dự đoán)
20ms 169 req/s 5 3
20ms 350 req/s 8 7
100ms 170 req/s 18 17
100ms 358 req/s 39 36
300ms 169 req/s 51 51
300ms 367 req/s 101 110

Dự đoán bám sát số đo trên cả dải. Điều đó biến stampede từ "một chuyện xui rủi thỉnh thoảng xảy ra" thành một đại lượng tính được trước khi lên production. Nếu một key nhận 2.000 req/s và truy vấn gốc mất 200ms, bạn biết chắc là sẽ có khoảng 400 truy vấn đồng thời đổ xuống database mỗi lần key đó hết hạn — không cần đợi sự cố để biết con số ấy.

Luật này cũng chỉ thẳng hai hướng sửa, và đó chính là nội dung phần sau: giảm R (chỉ cho một request đi lấy, số còn lại chờ hoặc dùng bản cũ) hoặc giảm D (làm truy vấn gốc nhanh hơn). Hướng thứ nhất hiệu quả hơn rất nhiều vì nó kéo R từ hàng trăm xuống 1.

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

Nhiễu TTL ở phần 3 không chữa được stampede. Rất dễ nhầm hai thứ. Nhiễu TTL giải quyết việc nhiều key khác nhau hết hạn cùng lúc — nó trải các mốc hết hạn ra. Stampede ở đây là nhiều request trên cùng một key: dù key đó hết hạn vào lúc nào, vẫn có đúng chừng ấy request đang hỏi nó tại thời điểm ấy. Hai vấn đề, hai cách chữa, và cần cả hai.

Giới hạn pool kết nối không phải phương án chống stampede. Phép đo cho thấy pool 50 vẫn để cả 500 truy vấn chạy, chỉ là tuần tự hoá chúng. Hãm bằng hàng đợi đổi "database quá tải" thành "request chậm"; nếu hàng đợi không có giới hạn thì còn đổi tiếp thành "hết bộ nhớ ứng dụng". Pool là thứ phải có để bảo vệ database khỏi sập, nhưng nó không giảm được một đơn vị công việc nào. Phòng thủ thật phải nằm trước database, ở tầng cache.

Stampede chỉ đáng lo với key nóng và truy vấn đắt. Nếu một key chỉ nhận 2 req/s, R × D cho 0,02 — tức gần như không bao giờ có hai request cùng miss. Thêm cơ chế chống stampede cho mọi key là thêm phức tạp vào 99% chỗ không cần. Tính R × D cho từng nhóm key trước, rồi chỉ bảo vệ những nhóm có tích số lớn.

Số đo ở đây là một tiến trình, một máy. Production thường có nhiều instance ứng dụng, nên stampede cộng dồn qua các instance — 10 instance mỗi cái 50 request đồng thời là 500 truy vấn, trong khi mỗi instance nhìn vào log của mình chỉ thấy 50. Hệ quả quan trọng cho phần sau: một sync.Mutex hay singleflight trong tiến trình chỉ gom được stampede trong một instance; muốn gom toàn cụm phải dùng khoá nằm ở Redis.

Ba ý mang về

  1. Cache-aside không biết "đã có người đang đi lấy": giữa lúc Get trả miss và lúc Set ghi xong, key vẫn trống với mọi người đến sau — đo thật 500 request cùng miss thì cả 500 chạm database, và request xấu nhất mất 1119,1ms so với 6,2ms khi chạy một mình (~180 lần).
  2. Quy mô stampede tính trước được bằng R × D (lưu lượng trên key × độ dài truy vấn): đo 51 so với dự đoán 51, 101 so với 110, 18 so với 17. Query càng chậm cửa sổ càng rộng — database nhận nhiều việc nhất đúng lúc nó yếu nhất.
  3. Giới hạn pool kết nối không chặn được stampede, chỉ xếp hàng nó lại: pool 50 vẫn để cả 500 truy vấn chạy, chỉ là lần lượt — tổng công việc database không đổi, còn người dùng chịu đủ 1119,1ms. Phòng thủ thật phải nằm ở tầng cache, trước database.

Nguồn

Phần sau ta chặn chính cơn stampede vừa đo: khoá trong tiến trình bằng singleflight, và khoá toàn cụm bằng SET NX trên Redis. Đo lại cùng kịch bản 500 request để xem con số 500 lượt chạm database rút xuống còn bao nhiêu.