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:

  1. Nhìn thấy key lần đầu → chạy business logic → lưu (key → response | result).
  2. Cùng key + cùng body (hoặc hash) → trả kết quả đã lưu, không charge lại.
  3. 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 đúngtenant + 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ù 400 validation.
  • 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_id random 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.