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/42 trong 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:42 khác tenant: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_total vs singleflight_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.