Cùng một việc nặng đang chạy — query DB, gọi service downstream, generate report, warm cache — và N chỗ trong hệ thống cùng lúc hỏi kết quả đó. Nếu mỗi chỗ tự fire một job, bạn trả N lần cost cho một câu trả lời.
Pattern: trong lúc work còn in-flight, chỉ một execution
thật; mọi caller khác đợi chung rồi lấy kết quả. JS hay gọi
là “reuse promise”; Go có
golang.org/x/sync/singleflight; tài liệu hệ thống hay
nói request coalescing / single-flight. Cùng một ý,
không gắn với frontend.
Demo mô phỏng N caller (browser, không cần server): single-flight — chạy thử.
Bài toán hệ thống, không chỉ UI
Thường gặp ở nhiều tầng:
-
API / BFF: 50 request HTTP cùng
GET /products/42trong cùng millisecond — cache miss — không nên 50 query DB giống hệt. - Worker / cron: nhiều consumer (hoặc retry) cùng lúc “đảm bảo aggregate X đã tính” — chỉ cần một pipeline.
- Cache layer: key hết hạn, traffic cao → cache stampede / thundering herd: hàng loạt miss đua nhau rebuild.
-
Downstream client: service A gọi service B; trong
một process, 20 goroutine/handler cùng cần config flag
plan:pro. - Client app (FE/mobile): vài màn hình mount cùng resource — chỉ là một chỗ xuất hiện, không phải toàn bộ story.
Điểm chung: trùng identity của work (cùng key), trùng cửa sổ thời gian (chưa xong), kết quả dùng chung an toàn (cùng quyền, cùng view data).
Process-local: map key → future/promise
Trong một process (một Node process, một JVM, một Go process…), form tối thiểu:
// Pseudocode / JS — ý giống singleflight.Group
const inflight = new Map(); // key → Promise<Result>
function doOnce(key, work) {
if (inflight.has(key)) return inflight.get(key);
const p = Promise.resolve()
.then(() => work())
.finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// 10 handler cùng key
await Promise.all(
handlers.map(() => doOnce("product:42", () => db.getProduct(42)))
);
// → 1 lần db.getProduct, 10 caller nhận cùng result (hoặc cùng error)
Timeline:
t=0 caller A miss → start work(key), đăng ký inflight
t≈0+ B…J hit → await cùng future
t=done work xong → fan-out result → xóa key
t=done+ caller K miss lại → work mới (nếu cần)
Đây không phải cache TTL. Entry chỉ sống khi work chưa settle. Xong là quên — lần sau (sau khi xong) được phép chạy lại.
Inflight ≠ cache value
- Single-flight / inflight: gộp concurrent duplicate. Mục tiêu: đúng một execution đang chạy cho mỗi key.
- Result cache (memory, Redis, CDN…): giữ sau khi xong. Mục tiêu: lần sau khỏi làm lại.
Production hay xếp tầng:
read(key):
1. L1/L2 cache hit? → return
2. Miss → singleFlight(key, () => loadFromSource + fillCache)
3. Mọi miss đồng thời chờ cùng loadFromSource
Bước 2 cắt stampede: hết hạn key + 1k RPS không biến thành 1k query
origin. Redis/memcached “only one rebuilder”, Varnish
grace/collapse, CDN request collapsing —
cùng họ ý tưởng ở rìa network.
Một process vs nhiều instance
Map trong RAM chỉ gộp caller trong cùng process.
- 3 pod × single-flight local: worst case vẫn 3 origin call song song (mỗi pod một cái) — đã tốt hơn 3×N, nhưng chưa “global 1”.
-
Cần gộp cross-instance: lock/lease theo key
(Redis
SET NX+ TTL, etcd, DB advisory lock), hoặc hàng đợi “một consumer làm, publish result”, hoặc cache trung tâm + stampede control. Đắt và phức tạp hơn local map — chỉ khi origin thật sự đau.
Thiết kế thực dụng: local single-flight luôn rẻ → bật trước; distributed collapse chỉ khi metric origin/QPS đòi hỏi.
Chỗ hay thấy trong architecture
-
Go
singleflight: canonical API —Do(key, fn), share result + error, báo có phải shared không. - GraphQL DataLoader: coalesce (và batch) load theo id trong một tick — tránh N+1; bản chất per-request single-flight + batching.
- ORM / repo layer: “getById” trong một request scope không hit DB hai lần cho cùng id.
- Job system: idempotency key + “already running” → caller sau poll/subscribe kết quả thay vì enqueue job trùng.
- Config / feature flag / JWKS: refresh document định kỳ; mọi request lúc đang refresh await một fetch.
Ràng buộc và bẫy
-
Key = identity của work. Thiếu tenant/user trong
key → leak cross-tenant. Thừa noise trong key → không gộp được.
tenant:t1:product:42kháctenant:t2:product:42. - Chỉ gộp khi result dùng chung hợp lệ. Cùng URL nhưng khác Authorization / row-level filter → không coalescing mù.
- Error cũng bị share. Một timeout/500 → mọi waiter nhận cùng lỗi (thường đúng). Retry: sau khi inflight clear, caller mới được mở flight mới — hoặc policy riêng (ví dụ không để cả đàn dính mãi một lỗi “độc” từ leader).
-
Luôn dọn khi xong (
finally/ defer), kể cả panic/reject — không thì key “kẹt”, flight sau không bao giờ chạy hoặc mãi dính error cũ. - Latency tail: waiter không nhanh hơn leader. Trade-off chấp nhận được để đổi throughput origin.
-
Observability: metric
singleflight_shared_totalvssingleflight_leader_total— biết đang cứu bao nhiêu duplicate.
Sketch Go (BE-native)
var g singleflight.Group
func Product(ctx context.Context, id string) (*Product, error) {
v, err, _ := g.Do("product:"+id, func() (interface{}, error) {
return repo.GetProduct(ctx, id) // chỉ leader chạy
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}
Cùng semantics với map+promise: leader chạy fn, caller
trùng key chờ, fan-out value/error, rồi quên key.
Một câu để nhớ
Trùng key + đang bay → một work, N waiter. Đó là single-flight: giảm load origin, cắt stampede, giữ semantics “ai cũng nhận cùng kết quả của lần chạy đó”.
Implement local (promise map, singleflight, mutex+cond)
gần như free. Mở rộng multi-instance khi thật sự cần. Đặt đúng tầng
(client, BFF, cache, worker) — pattern đi cả stack, không phải trick
của một framework UI.