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.

Ảnh chụp đoạn mã Go nền tối minh hoạ singleflight gộp nhiều lời gọi trùng thành một, khi 100 request cùng hỏi một key vừa hết cache đừng gọi DB 100 lần singleflight cho 1 lần gọi 99 kia chờ và dùng chung kết quả, một vấn đề thundering herd đàn sấm cache miss key nóng 100 goroutine cùng lao vào gọi hàm đắt for i bằng 0 i nhỏ hơn 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, hai sửa singleflight.Group.Do var g singleflight.Group v err dungChung bằng g.Do key-nong func any error return layDuLieu chỉ chạy một lần cho cùng key mọi goroutine gọi Do cùng key trong lúc nó đang chạy chờ lần chạy đó xong nhận cùng kết quả v dungChung true Do khóa theo key lần gọi đầu chạy hàm các lần gọi cùng key trong khi nó chạy sẽ chờ và chia sẻ kết quả sau khi xong kết quả bị quên lần gọi mới lại chạy hàm, ba DoChan và Forget ch bằng g.DoChan key fn trả channel chờ được với select ctx timeout select case r nhận ch case ctx.Done g.Forget key quên lần đang bay lời gọi mới không chờ nó nữa, bốn cạm bẫy lỗi kết quả xấu lan cho tất cả nếu lần gọi chung lỗi hay panic tất cả người chờ nhận lỗi đó một lỗi tạm thời làm hỏng cả 100 request cùng lúc cân nhắc retry riêng hoặc Forget khi lỗi để lần sau thử lại chia sẻ kết quả nghĩa là chia sẻ cả lỗi với lỗi tạm timeout có thể muốn mỗi caller tự thử lại thay vì cùng thất bại

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:

Ảnh chụp bảng kết quả đo thật nền tối singleflight gộp 100 lời gọi thành 1 go run golang.org x sync singleflight v0.8.0 Go 1.23 arm64 10 core hàm đắt 50ms, 100 goroutine cùng hỏi một key nóng cách KHÔNG singleflight hàm đắt chạy 100 lần tải lên backend 100 truy vấn giống hệt CÓ singleflight hàm đắt chạy 1 lần tải lên backend 1 truy vấn 100 dùng chung gộp 100 lời gọi trùng thành 1 giảm tải backend 100 lần cả 100 goroutine nhận cùng kết quả dungChung bằng 100 đây là chống thundering herd cache miss không biến thành 100 request đè lên DB, cơ chế khóa theo key trong lúc bay Do key fn lần gọi đầu cho key chạy fn các lần gọi cùng key chờ nhận cùng kết quả dungChung true fn xong quên key lời gọi mới lại chạy fn chỉ gộp các lời gọi đang bay cùng lúc sau khi xong cache bị quên singleflight không phải cache nó chỉ chống trùng lặp đồng thời thường dùng chung với cache, cạm bẫy và công cụ lỗi chung lần gọi chung lỗi tất cả người chờ nhận lỗi đó DoChan trả channel chờ với ctx timeout select Forget key quên lần đang bay caller mới không chờ nó, cốt lõi singleflight gộp lời gọi trùng cùng key thành 1 chống đàn sấm đo thật 100 goroutine hàm đắt chạy 1 lần thay vì 100 dùng khi cache miss key nóng nhiều request cùng hỏi một thứ khác cache chỉ gộp lúc đang bay không lưu kết quả dùng chung cache cạm bẫy lỗi kết quả xấu lan cho tất cả người chờ

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ề

  1. 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.
  2. 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. DoChan cho timeout với context, Forget để quên lời gọi treo.
  3. 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 Forget khi 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.