DEV Community

Idempotency — Safe Retry

Safe retry: idempotency key để retry một request không biến thành hai lần charge

Trong hệ phân tán, retry là mặc định — client, gateway, load balancer, queue consumer đều retry khi timeout hoặc lỗi tạm thời. Vấn đề là nhiều thao tác quan trọng không idempotent tự nhiên: một request POST /charges gửi hai lần thì trừ tiền khách hai lần, một message OrderCreated xử lý hai lần thì ship hai đơn. Idempotency key là cơ chế để server nhận diện "cùng một intent" giữa các lần retry và chỉ thực hiện side effect một lần, trong khi vẫn trả về response giống hệt cho mọi lần gọi lặp — về mặt hiệu ứng thấy được từ bên ngoài, đây là cái người ta hay gọi là "exactly-once effect" (dù ở tầng transport vẫn là at-least-once).

Cơ chế hoạt động

Client sinh một identifier duy nhất cho mỗi thao tác (thường là UUIDv4) và đính kèm request — quy ước phổ biến là HTTP header Idempotency-Key (Stripe API dùng đúng tên này, và IETF có draft draft-ietf-httpapi-idempotency-key-header chuẩn hoá cùng tên header). Server dùng key làm identity của thao tác trong một cửa sổ TTL:

  1. Nhận request với key K.
  2. Tra K trong idempotency store. Nếu tồn tại và request cũ ở trạng thái terminal (đã có response), trả lại response đã lưu — không chạy lại business logic.
  3. Nếu tồn tại nhưng đang in-flight, trả 409 Conflict (hoặc chờ, tuỳ contract).
  4. Nếu chưa tồn tại, INSERT bản ghi với unique constraint trên key, chạy business logic, persist response, commit.

Điểm cốt lõi là bước insert + bước business logic + bước lưu response phải nằm trong cùng một transaction boundary — hoặc chí ít, phải có cơ chế đảm bảo không có window mà một retry khác nhìn thấy "chưa có key" trong lúc lần đầu vẫn đang chạy dở.

CREATE TABLE idempotency_keys (
  key           TEXT        NOT NULL,
  user_id       BIGINT      NOT NULL,
  request_hash  TEXT        NOT NULL,          -- fingerprint payload
  status        TEXT        NOT NULL,          -- in_flight | succeeded | failed
  response_code INT,
  response_body JSONB,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  locked_until  TIMESTAMPTZ,
  PRIMARY KEY (user_id, key)
);
CREATE INDEX ON idempotency_keys (created_at);  -- để TTL sweeper dùng
Enter fullscreen mode Exit fullscreen mode
// Express handler cho POST /charges
app.post('/charges', async (req, res) => {
  const key = req.header('Idempotency-Key')
  if (!key) return res.status(400).json({ error: 'missing Idempotency-Key' })

  const userId = req.auth.userId
  const bodyHash = sha256(canonicalize(req.body))

  const client = await pool.connect()
  try {
    await client.query('BEGIN')

    // 1. Cố gắng chiếm key. Unique constraint ép serialize retry cùng key.
    const ins = await client.query(
      `INSERT INTO idempotency_keys (user_id, key, request_hash, status, locked_until)
       VALUES ($1, $2, $3, 'in_flight', now() + interval '30 seconds')
       ON CONFLICT (user_id, key) DO NOTHING
       RETURNING key`,
      [userId, key, bodyHash],
    )

    if (ins.rowCount === 0) {
      // 2. Key đã tồn tại — đọc bản cũ.
      const { rows } = await client.query(
        `SELECT status, request_hash, response_code, response_body
         FROM idempotency_keys WHERE user_id=$1 AND key=$2 FOR UPDATE`,
        [userId, key],
      )
      const row = rows[0]
      if (row.request_hash !== bodyHash) {
        await client.query('ROLLBACK')
        return res.status(422).json({ error: 'idempotency key reused with different payload' })
      }
      if (row.status === 'in_flight') {
        await client.query('ROLLBACK')
        return res.status(409).json({ error: 'request in progress' })
      }
      await client.query('COMMIT')
      return res.status(row.response_code).json(row.response_body)
    }

    // 3. Lần đầu — chạy business logic trong cùng transaction.
    const charge = await createCharge(client, userId, req.body)  // dùng chung client

    await client.query(
      `UPDATE idempotency_keys
       SET status='succeeded', response_code=$3, response_body=$4
       WHERE user_id=$1 AND key=$2`,
      [userId, key, 200, charge],
    )
    await client.query('COMMIT')
    return res.status(200).json(charge)
  } catch (err) {
    await client.query('ROLLBACK')
    throw err
  } finally {
    client.release()
  }
})
Enter fullscreen mode Exit fullscreen mode

Với message queue (Kafka, SQS, RabbitMQ), pattern giống hệt: dedup theo một business key (order id, payment intent id) trong một dedup store — Kafka producer còn có enable.idempotence=true để bản thân producer không ghi trùng vào broker, nhưng đó là idempotency ở tầng transport, không thay được dedup ở tầng consumer.

Vấn đề gặp trong production

Failure mode 1: race condition với hai retry song song cùng key. Không phải retry nào cũng tuần tự — khi client timeout ở lần 1 và bắn lại lần 2, request 1 có thể vẫn đang chạy dở trên server khác. Nếu handler chỉ làm "SELECT xem key có chưa, nếu chưa thì INSERT và chạy", hai request đều thấy chưa có key và cùng chạy — double charge:

// SAI: check-then-act không atomic
const existing = await db.query('SELECT * FROM idempotency_keys WHERE key=$1', [key])
if (existing.rowCount === 0) {
  const charge = await stripe.charges.create(...)  // hai request cùng vào đây
  await db.query('INSERT INTO idempotency_keys ...', [key, charge])
}
Enter fullscreen mode Exit fullscreen mode

Fix là để database tự serialize bằng unique constraint (INSERT ... ON CONFLICT DO NOTHING như snippet ở trên) hoặc một distributed lock (Redis SET NX PX, DynamoDB conditional write). Cái duy nhất an toàn là một atomic op quyết định "ai là người thắng slot key này".

Failure mode 2: cache response của lỗi non-terminal. Nếu server lưu response cho cả các lỗi tạm thời (503, timeout upstream), lần retry sau sẽ nhận lại lỗi thay vì được chạy lại thật. Ngược lại, nếu quên lưu response khi request đã hoàn tất, retry sẽ chạy business logic lại. Nguyên tắc: chỉ persist response khi thao tác đạt trạng thái terminal (đã charge xong hoặc đã reject bằng business rule cứng như "thẻ hết hạn"). Với lỗi hạ tầng, xoá bản ghi key (hoặc để status failed_retryable) và cho retry.

Failure mode 3: key scoping quá rộng. Đưa Idempotency-Key làm primary key toàn hệ thống nghĩa là một user có thể (vô tình hoặc cố ý) đâm vào key của user khác. Ngoài lỗ hổng bảo mật, còn có bug thật: một client library sinh key kém entropy (dùng timestamp giây) sẽ va chạm. Luôn scope theo (tenant/user_id, key) như trong schema trên.

Failure mode 4: server sinh key thay vì client. Nếu server tự sinh key khi thấy request thiếu header, mỗi lần retry sẽ có key mới và mất luôn ý nghĩa dedup. Key phải do client sinh trước khi bắn request, và giữ nguyên qua mọi lần retry của cùng intent — thường là sinh ngay khi user bấm nút "Pay", lưu trong memory/localStorage, retry cùng key cho tới khi có response terminal.

Failure mode 5: side effect ngoài transaction. Business logic có thể chạm hệ ngoài không rollback được (gửi email, gọi payment gateway ngoài, publish event Kafka). Nếu commit idempotency row thành công nhưng crash trước khi gửi email, retry sẽ thấy key succeeded và bỏ qua email — dropped. Nếu gửi email xong rồi crash trước khi commit row, retry sẽ gửi email lần hai. Cách xử lý chuẩn là transactional outbox: ghi event vào bảng outbox trong cùng transaction với idempotency row, một relay riêng đọc outbox và publish với retry idempotent ở phía downstream. Với payment gateway ngoài, forward luôn Idempotency-Key xuống (Stripe, Adyen, Braintree đều accept) để chuỗi idempotent xuyên tầng.

Failure mode 6: TTL cắt trước khi client hết retry. Idempotency store phải có TTL (không giữ mãi), nhưng nếu TTL ngắn hơn window client retry (mobile client có thể retry sau vài giờ khi có mạng lại), retry rơi vào "key không còn" và chạy lại. Stripe docs nêu retention khoảng một ngày cho key — chọn TTL đủ dài so với retry policy dài nhất của client hợp lệ, không đặt bằng vài phút.

Cách debug và monitor

Triệu chứng ngoài production của một hệ idempotency hỏng thường là duplicate charge / duplicate order report từ support, không phải error log — vì mỗi request lẻ đều 200 OK. Vì vậy phải chủ động đo:

  • Duplicate rate metric: tỉ lệ request mà idempotency lookup match key có sẵn / tổng số request có key. Rate quá thấp (gần 0) nghĩa là client không retry, hoặc client sinh key mới mỗi lần — dedup không có tác dụng. Rate quá cao đột biến là dấu hiệu retry storm (thường do client timeout config quá ngắn so với p99 server).
  • In-flight collision rate: số lần trả 409 do key đang chạy. Đột biến ở đây thường trùng với latency spike phía dependency (payment gateway chậm khiến client retry trong lúc server chưa xong).
  • Payload mismatch rate: số lần retry với cùng key nhưng request_hash khác. Thường lộ bug client — user sửa amount rồi retry với key cũ.
  • Response header đánh dấu replay: nhiều API (Stripe là ví dụ) trả một header đánh dấu request được replay từ store thay vì chạy mới. Log header này ở client side để phân biệt "server thực sự chạy" và "server chỉ trả lại cache".

Về công cụ: với Postgres store, pg_stat_user_tables xem hot key trên bảng idempotency_keys, pg_locks khi nghi có contention; với DynamoDB store (AWS Lambda Powertools idempotency module dùng đúng pattern này), CloudWatch metric ConditionalCheckFailedRequests bắn khi hai retry đâm vào cùng key — chính là dấu hiệu dedup đang hoạt động, không phải lỗi.

Rule phòng ngừa cứng: (1) mọi endpoint có side effect ngoài (charge, ship, send) đều bắt buộc Idempotency-Key, reject 400 nếu thiếu; (2) key phải scope theo tenant; (3) TTL của store lớn hơn retry window dài nhất của client cộng biên an toàn; (4) mọi tích hợp với gateway ngoài forward key xuống downstream.

Tradeoff

Idempotency an toàn cho retry nhưng phải trả bằng state: một bảng/DynamoDB store lớn dần theo QPS nhân với TTL, một extra write mỗi request, và độ phức tạp trong transaction boundary. Với endpoint không có side effect (GET, read query), thêm idempotency chỉ tốn tài nguyên vô ích — HTTP đã định nghĩa GET là idempotent theo spec, retry an toàn sẵn.

Có hai điểm đánh đổi tinh tế hơn. Thứ nhất, strict dedup và conflict tolerance: nếu chọn trả 409 khi request đang in-flight, client phải biết cách backoff và thử lại — nhiều client library không xử lý 409 khéo, sinh ra retry loop. Chọn "block chờ" thay vì 409 thì đơn giản cho client nhưng chiếm connection của server lâu hơn. Thứ hai, key granularity: key hẹp (mỗi request unique) an toàn nhưng dedup không dùng lại được; key rộng (theo business id như order_id) tận dụng dedup tốt hơn nhưng phải chắc business id không được tái sử dụng.

Rule of thumb: bất cứ endpoint nào tạo/charge/send/publish với side effect thấy được từ ngoài, mặc định coi là phải idempotent, và bắt buộc key do client sinh, scope theo tenant, TTL lớn hơn retry window; endpoint read-only thì để nguyên vì HTTP method đã đảm bảo semantics.

Câu hỏi phỏng vấn

Thiết kế một payment endpoint idempotent để client retry không tạo charge trùng. Nêu cả cơ chế và các cách nó vẫn có thể vỡ.

Client sinh UUIDv4 cho mỗi intent "bấm nút Pay" và gửi qua header Idempotency-Key, giữ nguyên key qua mọi lần retry cho tới khi có response terminal. Server có một bảng idempotency_keys với primary key (user_id, key), unique constraint để hai retry song song không cùng insert được. Handler INSERT ... ON CONFLICT DO NOTHING trong một transaction, nếu insert được thì chạy business logic (tạo charge, gọi payment gateway — forward luôn key xuống gateway để dedup xuyên tầng) và update row với response terminal trong cùng transaction. Nếu conflict, đọc row cũ, so request_hash để chống reuse key với payload khác, rồi trả lại response cũ nếu đã terminal hoặc 409 nếu đang in-flight. TTL của bảng phải lớn hơn retry window dài nhất của client hợp lệ. Điểm ăn điểm là gọi tên các failure mode: race condition khi check-then-act không atomic (phải dựa unique constraint hoặc distributed lock, không phải SELECT rồi INSERT); side effect ngoài transaction (email, publish event) phải đi qua transactional outbox chứ không gọi trực tiếp trong handler; key phải do client sinh chứ không phải server (không thì retry sinh key mới, mất dedup); và phải monitor duplicate rate cùng conditional-check-failed để biết dedup có thực sự hoạt động, vì hệ hỏng thường không log error mà chỉ lộ ra ở duplicate charge report từ support.

Hands-on

Dựng lại toàn bộ chain và ép nó vỡ, rồi sửa. Chạy trên máy local với Docker.

Bước 1 — Postgres và schema:

docker run -d --name idem-pg -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:16
sleep 3
docker exec -i idem-pg psql -U postgres <<'SQL'
CREATE TABLE idempotency_keys (
  key TEXT NOT NULL,
  user_id BIGINT NOT NULL,
  request_hash TEXT NOT NULL,
  status TEXT NOT NULL,
  response_code INT,
  response_body JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, key)
);
CREATE TABLE charges (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL,
  amount_cents INT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
SQL
Enter fullscreen mode Exit fullscreen mode

Bước 2 — server Node dùng đúng handler ở phần "Cơ chế hoạt động" phía trên (copy khối app.post('/charges', ...), bọc quanh bằng express() + pg.Pool nối tới localhost:5432, thêm sha256 bằng crypto.createHash('sha256')), rồi node server.js.

Bước 3 — Test A, cùng key gửi 5 lần chỉ tạo 1 charge:

KEY=$(uuidgen)
for i in 1 2 3 4 5; do
  curl -s -X POST localhost:3000/charges \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $KEY" \
    -d '{"amount_cents": 1000}'
  echo
done
docker exec -i idem-pg psql -U postgres -c "SELECT count(*) FROM charges;"
# expected: count = 1
Enter fullscreen mode Exit fullscreen mode

Bước 4 — Test B, race condition song song cùng key:

KEY=$(uuidgen)
for i in 1 2 3 4 5 6 7 8; do
  curl -s -X POST localhost:3000/charges \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $KEY" \
    -d '{"amount_cents": 2000}' &
done
wait
docker exec -i idem-pg psql -U postgres -c "SELECT count(*) FROM charges WHERE amount_cents=2000;"
# expected: 1 charge; 7 request kia trả 409 in-flight hoặc 200 replay
Enter fullscreen mode Exit fullscreen mode

Bước 5 — Test C, payload mismatch reuse key:

KEY=$(uuidgen)
curl -s -X POST localhost:3000/charges -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" -d '{"amount_cents": 500}'
echo
curl -s -X POST localhost:3000/charges -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" -d '{"amount_cents": 999999}'
echo
# expected lần 2: 422 payload mismatch
Enter fullscreen mode Exit fullscreen mode

Bước 6 — ép nó vỡ để so sánh. Trong server.js, thay khối INSERT ... ON CONFLICT DO NOTHING bằng "SELECT rồi nếu không có thì INSERT INTO charges" (check-then-act không atomic). TRUNCATE charges, idempotency_keys rồi chạy lại Test B — count sẽ lên 8: mất dedup, double (thực ra octuple) charge. Đây là failure mode 1.

Bước 7 — thêm một counter Prometheus cho nhánh ins.rowCount === 0 (duplicate hit) và một cho nhánh 422 (payload mismatch). Chạy Test A + Test B + Test C xen kẽ, scrape /metrics, xác nhận duplicate rate và payload mismatch rate khớp với hành vi test. Cuối cùng, thêm một background job DELETE FROM idempotency_keys WHERE created_at < now() - interval '10 seconds' chạy mỗi 5 giây; gửi request với key K, đợi 20 giây rồi retry cùng key K — kết quả sẽ tạo charge thứ hai, tái hiện failure mode 6 "TTL cắt trước retry window".

Top comments (0)