Kịch bản kinh điển: một key nóng vừa hết hạn cache, và cùng lúc 100 request tới cần nó. Không có gì bảo vệ, cả 100 đều thấy cache miss và cùng lao vào gọi DB/API để tính lại — 100 truy vấn giống hệt nhau đè lên backend cùng một khoảnh khắc. Đây là thundering herd (đàn sấm), và nó có thể làm sập chính cái backend bạn đang cố bảo vệ bằng cache. golang.org/x/sync/singleflight giải quyết gọn: gộp các lời gọi trùng thành một. Bài này đo thật hiệu quả.
Vấn đề: đàn sấm khi cache miss
// cache MISS key nóng → 100 goroutine cùng lao vào gọi hàm đắt
for i := 0; i < 100; i++ {
go func() { layDuLieu() }() // 100 lần truy vấn DB/API!
}
// → backend nhận 100 request giống hệt cùng lúc → có thể sập
Nghịch lý: cache là để giảm tải backend, nhưng đúng khoảnh khắc cache miss lại tạo một đợt dồn tải lớn nhất. Càng nhiều request đồng thời, đợt dồn càng lớn. Đây là lý do một service đang chạy êm có thể sập ngay sau khi một key cache phổ biến hết hạn.

Hình 1: singleflight. Vấn đề thundering herd, cách sửa bằng g.Do(key, fn), DoChan/Forget, và cạm bẫy lỗi lan cho tất cả người chờ.
Sửa: singleflight.Group.Do
var g singleflight.Group
v, err, dungChung := g.Do("key-nong", func() (any, error) {
return layDuLieu() // CHỈ chạy MỘT lần cho cùng key
})
Do khóa theo key: lần gọi đầu tiên cho một key chạy hàm; mọi lần gọi cùng key trong khi nó đang chạy sẽ chờ và nhận cùng kết quả (dungChung=true). Sau khi hàm xong, kết quả bị quên — lần gọi mới sau đó lại chạy hàm.
Đo thật: 100 lời gọi thành 1
Chạy 100 goroutine cùng hỏi một key, đếm số lần hàm đắt (mô phỏng truy vấn 50ms) thực sự chạy:

Hình 2: Không singleflight — 100 goroutine khiến hàm đắt chạy 100 lần. Có singleflight — chạy đúng 1 lần, cả 100 dùng chung kết quả. Cơ chế khóa theo key và các cạm bẫy.
- Không singleflight: 100 goroutine → hàm đắt chạy 100 lần — 100 truy vấn giống hệt đè lên backend.
- Có singleflight: 100 goroutine → hàm đắt chạy 1 lần, cả 100 nhận cùng kết quả (
dungChung=100).
Gộp 100 lời gọi trùng thành 1 — giảm tải backend 100 lần. Đây chính là chống thundering herd: một cache miss không biến thành 100 request đè lên DB.
Cơ chế và công cụ
singleflight KHÔNG phải cache — nó chỉ chống trùng lặp đồng thời. Nó gộp các lời gọi đang bay cùng lúc; sau khi lời gọi xong, cache bị quên và lời gọi mới lại chạy hàm. Vì vậy nó thường dùng chung với cache: cache lưu kết quả (giảm gọi lặp qua thời gian), singleflight chống đợt dồn đồng thời khi cache miss.
DoChan cho chờ có context/timeout. Do block cho tới khi hàm xong. DoChan(key, fn) trả một channel, cho bạn select với ctx.Done() để timeout — không bị kẹt vô hạn nếu lời gọi chung treo.
Forget(key) để quên lời gọi đang bay. Nếu muốn một lời gọi mới không chờ lời gọi đang chạy (ví dụ nó treo quá lâu), Forget(key) gỡ nó khỏi nhóm để lời gọi mới bắt đầu độc lập.
Ứng dụng thực tế
Bọc mọi cache miss "đắt" bằng singleflight. Bất kỳ chỗ nào cache miss dẫn tới một thao tác đắt và nhiều request có thể miss cùng lúc (render trang, tính toán, truy vấn DB nặng), bọc phần "tính lại" trong g.Do(cacheKey, ...). Đây là mẫu chuẩn cho cache-aside chống stampede.
Gộp gọi API bên ngoài theo tham số. Nếu nhiều goroutine cần cùng dữ liệu từ một API (tỷ giá, thông tin user), dùng key = tham số để gộp. Vừa giảm tải API vừa tránh vượt rate limit của họ.
Key phải bao trọn tham số ảnh hưởng kết quả. Nếu kết quả phụ thuộc user/tenant, key phải chứa user id — nếu không, request của user A có thể nhận kết quả tính cho user B. Đây là bug bảo mật/đúng đắn nghiêm trọng; chọn key cẩn thận.
Đánh đổi cần cân nhắc
Lỗi và kết quả xấu lan cho TẤT CẢ người chờ. Vì các caller chia sẻ kết quả, họ chia sẻ cả lỗi. Nếu lời gọi chung thất bại (timeout, panic được recover), cả 100 request cùng nhận lỗi đó — một lỗi tạm thời làm hỏng cả đợt. Với lỗi tạm (timeout), cân nhắc Forget khi lỗi để lần sau thử lại, hoặc cho mỗi caller tự retry độc lập thay vì cùng thất bại.
Gộp có thể tăng độ trễ đuôi. Người chờ phải đợi lời gọi chung xong — nếu nó chậm, tất cả người chờ đều chậm theo. Không có singleflight, mỗi request có thể tự chạy song song (nhanh hơn cho từng cái nhưng đè backend). Đây là đánh đổi bảo vệ-backend vs độ trễ từng request; thường đáng, nhưng biết nó tồn tại.
Chỉ chống trùng trong một tiến trình. Như rate limiter, singleflight là in-process. Nhiều instance sau load balancer, mỗi instance vẫn có thể gọi backend một lần — tổng = N lời gọi cho N instance. Vẫn giảm mạnh (từ N×100 xuống N), nhưng không phải 1 tuyệt đối; chống stampede toàn cục cần cơ chế phân tán.
Ba ý mang về
- singleflight gộp các lời gọi trùng cùng key thành một, chống thundering herd: đo thật 100 goroutine cùng hỏi một key nóng khiến hàm đắt chạy đúng 1 lần thay vì 100 — cả 100 chờ và nhận cùng kết quả, giảm tải backend 100 lần khi cache miss.
- singleflight KHÔNG phải cache — dùng chung với cache: nó chỉ gộp các lời gọi đang bay đồng thời, quên kết quả sau khi xong; cache lưu kết quả qua thời gian, singleflight chống đợt dồn lúc miss.
DoChancho timeout với context,Forgetđể quên lời gọi treo. - Cạm bẫy: lỗi/kết quả xấu lan cho tất cả người chờ: chia sẻ kết quả nghĩa là chia sẻ cả lỗi — một lỗi tạm làm hỏng cả đợt (cân nhắc
Forgetkhi lỗi); key phải bao trọn mọi tham số ảnh hưởng kết quả (gồm user/tenant id) để tránh trả nhầm dữ liệu; và nó chỉ chống trùng in-process.
Bài tập làm thử
Bài 1 (đọc hiểu — vấn đề). Giải thích hiện tượng "thundering herd" (đàn sấm) trong ngữ cảnh cache miss, và vì sao nghịch lý ở chỗ "cache vốn để giảm tải nhưng chính lúc miss lại tạo đợt tải lớn nhất".
Đáp án
Thundering herd xảy ra khi một key cache nóng (được nhiều request cùng hỏi) hết hạn hoặc bị xóa: ngay khoảnh khắc đó, mọi request đang chờ key này đều thấy cache miss cùng lúc, và nếu không có gì bảo vệ, tất cả chúng đồng loạt lao vào gọi lại nguồn dữ liệu gốc (DB/API) để tính lại giá trị — tạo ra N truy vấn giống hệt nhau đè lên backend trong cùng một khoảnh khắc. Nghịch lý: bình thường cache giúp backend chỉ phải phục vụ một phần nhỏ traffic (phần miss), nhưng đúng lúc một key phổ biến hết hạn, toàn bộ traffic đang chờ key đó dồn thành một đợt tải đột biến lên chính backend mà cache đang cố bảo vệ — càng nhiều request đồng thời đang chờ key đó, đợt dồn càng lớn, có thể đủ để làm sập backend.
Bài 2 (đọc hiểu cơ chế). Cho đoạn code sau, mô tả chính xác điều gì xảy ra khi 100 goroutine cùng gọi g.Do("key-nong", fn) gần như đồng thời.
var g singleflight.Group
v, err, dungChung := g.Do("key-nong", func() (any, error) {
return layDuLieu()
})
Đáp án
Chỉ có lời gọi đầu tiên thực sự chạy hàm layDuLieu(). 99 lời gọi còn lại tới cùng key "key-nong" trong lúc lời gọi đầu tiên đang chạy sẽ chờ (không tự chạy hàm), và khi lời gọi đầu hoàn tất, tất cả 100 caller nhận cùng giá trị trả về (v, err) — với dungChung=true cho 99 caller đã chờ (chỉ ra rằng chúng nhận kết quả dùng chung, không tự thực thi). Sau khi hàm chạy xong, kết quả bị quên ngay lập tức trong singleflight.Group — một lời gọi Do mới sau đó (không còn ai đang "bay") sẽ lại chạy hàm từ đầu.
Bài 3 (đọc hiểu số liệu). Bài đo: không dùng singleflight, 100 goroutine cùng hỏi một key khiến hàm đắt (mô phỏng 50ms) chạy 100 lần; có singleflight, hàm chỉ chạy 1 lần. Nêu chính xác ý nghĩa thực tế của kết quả này đối với tải lên backend, và giải thích vì sao singleflight "không phải cache".
Đáp án
Ý nghĩa thực tế: tải lên backend giảm đúng 100 lần trong tình huống thundering herd — thay vì 100 truy vấn giống hệt cùng lúc đè lên DB/API, backend chỉ nhận đúng 1 truy vấn, còn 99 request kia được phục vụ bằng kết quả dùng chung mà không chạm gì tới backend. Singleflight "không phải cache" vì nó chỉ gộp các lời gọi đang bay đồng thời (concurrent, trùng thời điểm) — ngay khi lời gọi xong, kết quả bị quên hoàn toàn, không được lưu lại để phục vụ các request tới sau đó. Một cache thực sự lưu kết quả qua thời gian để tránh gọi lại nhiều lần; singleflight chỉ chống trùng lặp tại cùng một khoảnh khắc — đó là lý do nó thường được dùng cùng với một cache, không thay thế cho cache.
Bài 4 (bẫy/đánh đổi — trọng tâm). Bài viết cảnh báo: "lỗi và kết quả xấu lan cho TẤT CẢ người chờ". Giải thích cơ chế gây ra điều này, và đề xuất một cách xử lý hợp lý khi lỗi đó là lỗi tạm thời (ví dụ timeout mạng).
Đáp án
Vì tất cả các caller cùng key chia sẻ kết quả của đúng một lần gọi hàm, nên nếu lần gọi đó thất bại (trả lỗi, hoặc panic được recover thành lỗi), toàn bộ những caller đang chờ (có thể tới hàng trăm) đều nhận cùng lỗi đó — một lỗi tạm thời (ví dụ timeout mạng nhất thời) làm hỏng cả một đợt request lớn, dù nhiều trong số chúng có thể đã thành công nếu tự thử độc lập. Cách xử lý hợp lý: khi phát hiện lỗi, gọi g.Forget(key) để xóa dấu vết lời gọi đang bay đó ra khỏi nhóm ngay lập tức, giúp lần gọi tiếp theo (ví dụ từ một retry) không phải chờ hoặc dùng chung kết quả lỗi cũ mà được thử lại từ đầu; hoặc thiết kế để mỗi caller tự retry độc lập thay vì tất cả cùng chấp nhận một kết quả thất bại chung.
Bài 5 (vận dụng thực tế — bẫy bảo mật). Một dịch vụ dùng singleflight để gộp các truy vấn lấy "danh sách đơn hàng của tôi" cho nhiều request cùng lúc, nhưng lập trình viên dùng key cố định "don-hang" cho mọi request bất kể user nào gọi. Chỉ ra vấn đề nghiêm trọng ở đây và cách sửa.
Đáp án
Đây là một bug bảo mật/đúng đắn nghiêm trọng: vì key không phân biệt user, nếu user A và user B cùng gọi API gần như đồng thời, singleflight sẽ gộp hai lời gọi thành một — chỉ có lời gọi của người tới trước thực sự chạy, và kết quả (đơn hàng của người đó) sẽ bị trả về cho cả hai người, bao gồm cả người kia — rò rỉ dữ liệu riêng tư của người này sang người khác. Cách sửa: key dùng trong g.Do phải bao trọn mọi tham số ảnh hưởng tới kết quả, ở đây ít nhất phải bao gồm user id, ví dụ g.Do(fmt.Sprintf("don-hang:%d", userID), fn) — đảm bảo chỉ các request của cùng một user mới được gộp chung với nhau.
Phần sau ta xây một mẫu đồng thời hoàn chỉnh dùng nhiều thứ đã học: Phần sau mổ xẻ worker pool có giới hạn và backpressure — cách dựng một pool worker với hàng đợi có giới hạn để khi quá tải hệ thống chậm lại thay vì sập, và đo thật hành vi backpressure.