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.
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.