Timeout. Client không biết server đã trừ tiền chưa. Network blip. Worker nhận lại job sau crash. User double-click “Thanh toán”. Cùng một câu hỏi:
Làm lại lần hai — có được phép tạo side-effect lần hai không?
Nếu câu trả lời là không, bạn cần idempotency: cùng một ý định logic (cùng key) gọi bao nhiêu lần cũng cho một kết quả nghiệp vụ — hoặc trả lại kết quả lần đầu, không charge/ghi đè thêm.
Demo (browser, state in-memory): Idempotency key — bấm pay / retry / double-submit.
Ba semantics delivery (thực dụng)
- At-most-once: có thể mất, không nhân đôi. Đơn giản; sai cho lệnh quan trọng nếu mất.
- At-least-once: có thể trùng, không được “mất im”. Queue + retry điển hình. Phải xử lý trùng phía consumer.
- Exactly-once (marketing): trong hệ phân tán, thường = at-least-once + idempotent handler (+ đôi khi transactional outbox), không phải “protocol một mình giải hết”.
BullMQ / SQS / Kafka consumer… mặc định gần với at-least-once. Viết handler như thể “chỉ chạy một lần” là bug chờ ngày.
Idempotency key
Client (hoặc producer) gắn một key ổn định cho một ý định:
POST /charges
Idempotency-Key: order-42-pay-v1
{ "orderId": "42", "amount": 100_000 }
Server:
- Nhìn thấy key lần đầu → chạy business logic → lưu
(key → response | result). - Cùng key + cùng body (hoặc hash) → trả kết quả đã lưu, không charge lại.
- Cùng key + body khác →
409 Conflict(key reuse sai).
Key phải:
- Ổn định qua retry — regenerate UUID mỗi lần click Retry = mất hết ý nghĩa.
-
Phạm vi đúng —
tenant + actor + operation + business id, không chỉ random global nếu bạn cần audit theo đơn. - TTL hợp lý — 24h / 72h tùy domain; hết TTL có thể coi là ý định mới (cẩn thận).
Sketch handler
async function charge(idemKey, body) {
const existing = await store.getIdem(idemKey);
if (existing) {
if (existing.bodyHash !== hash(body)) throw Conflict();
return existing.response; // replay
}
// lock theo key — tránh 2 request song song cùng key
return store.withLock(idemKey, async () => {
const again = await store.getIdem(idemKey);
if (again) {
if (again.bodyHash !== hash(body)) throw Conflict();
return again.response;
}
const response = await paymentGateway.charge(body); // side-effect
await store.putIdem(idemKey, { bodyHash: hash(body), response });
return response;
});
}
Không có lock/unique constraint: hai request song song cùng key có thể cả hai “miss” rồi charge hai lần — TOCTOU cổ điển.
Idempotent ≠ “hàm pure”
SET balance = 100 (gán) thường idempotent theo giá trị
cuối. balance += 100 thì không — trừ khi bạn có
“đã cộng cho key X chưa?”.
- Natural idempotent: PUT resource full, delete by id, set flag.
- Cần ledger / dedup table: chuyển tiền, gửi email “một lần”, tạo invoice số tăng.
Gửi email: lưu
email_sent(idem_key) trước hoặc trong cùng transaction
với outbox — không dựa vào “hy vọng SMTP chỉ gọi một lần”.
Retry client đúng cách
- Giữ nguyên method, body, cùng Idempotency-Key.
-
Chỉ retry khi an toàn theo policy: network error, 408/429/5xx —
không retry mù
400validation. - Backoff + jitter (xem retry + jitter; storm liên quan backpressure).
- Phân biệt transport failure vs business decline (thẻ reject) — cái sau retry vô ích.
Queue / job
job = {
id: "send-invoice-42", // stable
idempotencyKey: "invoice:42:email",
payload: { invoiceId: 42 }
}
Consumer: “nếu invoice:42:email đã completed → ack và
return”. Job id trùng + at-least-once delivery = bình thường; side-effect
table là nguồn sự thật, không phải “số lần handler được gọi”.
Nối BullMQ: attempts > 1 là tín hiệu bạn đã chọn at-least-once — hãy thiết kế handler theo đó.
Bẫy thường gặp
-
Key theo
request_idrandom mỗi HTTP call — user double-click tạo 2 key → 2 charge. - Lưu idem record sau side-effect không atomic → crash giữa chừng → retry charge lại.
- Chỉ dedup trong memory process — multi-instance vẫn double (giống giới hạn single-flight local).
- Coi GET là luôn an toàn — GET có side-effect (track pixel “cộng view”) vẫn cần nghĩ dedup/short window.
Một câu để nhớ
Retry là mặc định của mạng hỏng. Side-effect chỉ an toàn khi “cùng ý định → cùng kết quả nghiệp vụ”, thường nhờ idempotency key + lưu kết quả + chống race.
Exactly-once không phải nút bật trên queue — là at-least-once delivery cộng idempotent processing.