Idempotency Keys — Chống ghi trùng khi API retry an toàn

Phong Hy

Vấn đề: request bị gửi 2 lần nhưng chỉ nên ghi 1 lần

Mạng không đáng tin. Client gọi POST /payment, server thật ra đã xử lý thành công, nhưng response bị rớt giữa đường → client timeout → hoặc là client retry, hoặc là client tưởng thất bại rồi gửi lại. Kết quả: khách bị trừ tiền 2 lần.

Đây là bài toán kinh điển nhất của bất kỳ hệ thống thanh toán, đặt vé, hay add-to-cart. Giải pháp chuẩn ngành: idempotency key.

Idempotency là gì?

An idempotent operation là một thao tác gọi N lần cũng giống gọi 1 lần. GET thì sao cũng đúng. Nhưng POST /orders không idempotent tự nhiên — mỗi lần gọi nó tạo thêm 1 order.

Ý tưởng idempotency key: client tự sinh ra một chuỗi duy nhất cho một ý định kinh doanh, gửi kèm trong header Idempotency-Key: 550e8400-.... Server lưu key này cùng response đầu tiên. Khi nhận cùng key lần nữa, server không chạy lại logic mà trả lại response đã lưu.

POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 3f9c1b2a-1111-4dee-8c5d-l0ngu333

{ "amount": 500000 }

Thiết kế trên PostgreSQL (hoặc Redis)

Cách đơn giản và an toàn nhất là dùng unique constraint làm hàng rào cuối cùng:

CREATE TABLE payments (
  id           UUID PRIMARY KEY,
  idempotency_key UUID NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (idempotency_key)
);

Logic xử lý trong Go:

func CreatePayment(ctx context.Context, key uuid.UUID, req PaymentReq) (*Payment, error) {
    // Cố gắng INSERT trước — hoặc là lấy về transaction đã có
    row := db.QueryRow(ctx, `
        INSERT INTO payments (id, idempotency_key, amount)
        VALUES ($1, $2, $3)
        ON CONFLICT (idempotency_key) DO UPDATE
        SET id = payments.id            -- no-op, giữ y nguyên
        RETURNING id
    `, uuid.New(), key, req.Amount)

    var id uuid.UUID
    if err := row.Scan(&id); err != nil {
        return nil, err
    }
    return getPayment(ctx, id)
}

Này là mẹo hay: ON CONFLICT ... DO UPDATE SET id = payments.id (no-op) để SQL luôn trả về một row, thay vì bắt lỗi unique_violation rồi SELECT tiếp. Một round-trip, hết phiền.

Kinh nghiệm thực tế từ deploy của em

  1. Unique constraint ở DB là chốt chặn bắt buộc, không chỉ check trong app. Hai instance chạy song song (hoặc race) sẽ cùng check SELECT thấy trống rồi cùng INSERT — chỉ cái constraint mới chặn được cả hai. App-level check chỉ là bộ lọc nhanh.

  2. Idempotency key phải bounce theo request, không bounce theo user. Key sinh mới cho mỗi ý định, không tái dùng. Dùng uuid.New() server-side nếu client không gửi, nhưng tốt nhất vẫn là client tự sinh (bởi đúng cái client đó là nơi giữ ý định "tôi muốn trả 500k cho giỏ hàng này").

  3. Trả về response cũ phải giữ nguyên thứ nó trả. Đừng tái tính toán rồi gửi số liệu mới. Client đang đợi cái response đầu tiên — nếu lần 2 trả khác, client sẽ mất định hướng. Lưu JSON response trong một bảng (hoặc Redis) và replay.

  4. Hết hạn key. Không nên giữ key vĩnh viễn. Chuẩn thực tế: 24h. Sau đó nếu client retry một request cũ, nó nên nhận 409 Conflict để client biết phải sinh key mới, thay vì im lặng tạo ra record mới.

  5. Đừng trộn retry với idempotency. Retry ở mức transport (gRPC/proxy) chỉ đảm bảo "đã gửi lại". Idempotency mới đảm bảo "chỉ ghi một lần". Hai lớp này bổ trợ, không thay thế.

Khi nào không cần?

Nếu API purely read (GET, HEAD) thì ngon: đã idempotent sẵn. Còn với mọi POST/PATCH/PUT có side effect, đặc biệt là liên quan tiền — hãy có idempotency key trước khi 1 khách than phiền "em bị trừ 2 lần". Đó không phải bug, đó là lỗi thiết kế.