Lập trình · 19/09/2026

Idempotency Key cho API: Chống tạo trùng đơn hàng và thanh toán

Client gửi yêu cầu tạo đơn hàng, server xử lý thành công nhưng response bị mất trên đường truyền. Client chỉ thấy timeout và gửi lại. Nếu API tạo thêm một order hoặc charge thẻ lần hai, lỗi nằm ở thiết kế server chứ không phải ở hành vi retry hợp lý của client.

Idempotency Key cho API: Chống tạo trùng đơn hàng và thanh toán

Client gửi yêu cầu tạo đơn hàng, server xử lý thành công nhưng response bị mất trên đường truyền. Client chỉ thấy timeout và gửi lại. Nếu API tạo thêm một order hoặc charge thẻ lần hai, lỗi nằm ở thiết kế server chứ không phải ở hành vi retry hợp lý của client.

Idempotency Key cho phép nhiều lần thử của cùng một thao tác tạo ra một hiệu ứng nghiệp vụ duy nhất. Server nhận diện retry bằng khóa do client gửi, đối chiếu request và trả lại kết quả đã lưu thay vì thực hiện lại.

Idempotency là gì?

Một thao tác idempotent có cùng hiệu ứng sau một hay nhiều lần thực hiện giống nhau. HTTP định nghĩa GET, PUT và DELETE là idempotent về intended effect, còn POST thường không. Tuy vậy, method idempotent không có nghĩa implementation tự động an toàn: một DELETE gửi email mỗi lần gọi vẫn tạo side effect lặp.

Idempotency Key chủ yếu hữu ích cho các thao tác có side effect:

  • Tạo order, booking, invoice hoặc payout.
  • Charge/refund payment.
  • Gửi lệnh provisioning tài nguyên.
  • POST/PATCH có thể bị client, proxy hoặc job queue retry.
Mục tiêu không phải ngăn mọi request lặp, mà bảo đảm các retry của cùng một ý định không tạo thêm hiệu ứng.

1. Hợp đồng giữa client và server

Client tạo khóa ngẫu nhiên có entropy cao, thường là UUID, và giữ nguyên khóa cho mọi retry của cùng thao tác:

POST /v1/orders HTTP/1.1
Authorization: Bearer ...
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json

{
  "cart_id": "cart-9821",
  "shipping_address_id": "address-17"
}

Một thao tác nghiệp vụ mới phải dùng key mới. Không dùng timestamp ngắn, số tăng dần hoặc hash payload đơn thuần làm key vì collision, khả năng đoán và hai ý định giống payload có thể là hai giao dịch hợp lệ.

Server phải công bố định dạng, độ dài tối đa, phạm vi áp dụng và thời gian giữ key. IETF đã thảo luận header Idempotency-Key trong một Internet-Draft; đây là tài liệu work in progress, không nên mô tả như tiêu chuẩn RFC đã hoàn tất.

2. Scope khóa theo tenant và endpoint

Không dùng idempotency key làm khóa toàn cục duy nhất. Hai khách hàng có thể vô tình tạo cùng UUID; cùng key trên hai endpoint cũng có thể là hai ý định khác nhau. Lookup key nên là composite:

(tenant_id, operation, idempotency_key)

tenant_id phải lấy từ principal đã xác thực, không lấy từ body do client tùy ý gửi. operation là tên route ổn định như orders.create, không nhất thiết là URL chứa version hoặc query động.

3. Schema lưu idempotency record

CREATE TABLE idempotency_keys (
    tenant_id uuid NOT NULL,
    operation varchar(100) NOT NULL,
    idempotency_key varchar(255) NOT NULL,
    request_hash char(64) NOT NULL,
    status varchar(20) NOT NULL,
    resource_type varchar(100),
    resource_id varchar(100),
    response_code integer,
    response_headers jsonb,
    response_body jsonb,
    locked_until timestamptz,
    created_at timestamptz NOT NULL DEFAULT now(),
    completed_at timestamptz,
    expires_at timestamptz NOT NULL,
    PRIMARY KEY (tenant_id, operation, idempotency_key)
);

CREATE INDEX idx_idempotency_expiry
ON idempotency_keys (expires_at);

Các trạng thái cơ bản là processing, completed và có thể failed. request_hash ngăn một key bị tái sử dụng cho payload khác. Tùy API, chỉ lưu resource ID rồi dựng lại response, hoặc lưu status/body đã trả để replay chính xác.

4. Tạo request fingerprint ổn định

Hash raw JSON không ổn định vì thứ tự field và whitespace có thể khác dù ý nghĩa giống nhau. Hãy canonicalize dữ liệu:

  1. Parse JSON và từ chối payload không hợp lệ.
  2. Chuẩn hóa field được phép, kiểu dữ liệu và giá trị mặc định.
  3. Sắp xếp key object theo quy tắc xác định.
  4. Bao gồm method, operation và các tham số có ảnh hưởng.
  5. Hash canonical representation bằng SHA-256.
fingerprint = SHA256(
    operation + "\n" + canonical_json(validated_input)
)

Không hash authorization token hoặc header thay đổi vô nghĩa. File upload lớn có thể dùng checksum nội dung/metadata đã xác minh. Nếu cùng key nhưng fingerprint khác, trả lỗi conflict; tuyệt đối không replay response của request đầu cho payload thứ hai.

5. Claim key bằng unique constraint

Hai request cùng key có thể đến đúng một lúc. Check-then-insert trong application bị race. Hãy để unique constraint phân xử:

INSERT INTO idempotency_keys (
    tenant_id, operation, idempotency_key,
    request_hash, status, locked_until, expires_at
) VALUES (
    :tenant_id, :operation, :key,
    :hash, 'processing', now() + interval '30 seconds',
    now() + interval '24 hours'
)
ON CONFLICT DO NOTHING;

Request insert thành công là owner. Request còn lại đọc row hiện có:

  • Hash khác: trả 409 Conflict vì key bị dùng sai.
  • completed: trả lại response đã lưu.
  • processing: chờ ngắn/poll có giới hạn hoặc trả conflict/in-progress để client retry sau.
  • Lease hết hạn: chỉ takeover nếu quy trình có thể xác định an toàn thao tác trước chưa tạo side effect.

6. Transaction phải bao gồm business data

Với thao tác chỉ ghi một database, tạo business record và hoàn tất idempotency row trong cùng transaction:

BEGIN;

SELECT * FROM idempotency_keys
WHERE tenant_id = :tenant
  AND operation = 'orders.create'
  AND idempotency_key = :key
FOR UPDATE;

INSERT INTO orders (...) VALUES (...)
RETURNING id;

UPDATE idempotency_keys
SET status = 'completed',
    resource_type = 'order',
    resource_id = :order_id,
    response_code = 201,
    response_body = :body,
    completed_at = now()
WHERE tenant_id = :tenant
  AND operation = 'orders.create'
  AND idempotency_key = :key;

COMMIT;

Nếu process crash trước commit, cả order và kết quả idempotency rollback. Nếu crash sau commit nhưng trước khi gửi response, retry đọc row completed và trả đúng order đã tạo.

7. Lưu response hay tham chiếu resource?

Lưu full response replay chính xác, phù hợp response nhỏ và yêu cầu client nhận cùng status/body. Nhược điểm là tốn storage, giữ PII và response có thể chứa dữ liệu không nên lưu lâu.

Lưu resource reference nhẹ hơn và dễ dọn, nhưng response dựng lại có thể khác nếu resource đã thay đổi. Một phương án lai lưu resource ID, status code và snapshot tối thiểu cần cho contract.

Không lưu Set-Cookie, token ngắn hạn hoặc header nhạy cảm để replay. Allowlist response header an toàn thay vì serialize tất cả.

8. Validation error và server error có nên được cache?

Policy phải rõ ràng:

  • Validation chưa bắt đầu execution: thường không claim/lưu key; client sửa payload và có thể retry.
  • Business rejection đã quyết định: có thể lưu kết quả 4xx để retry nhận cùng quyết định.
  • Lỗi 5xx trước side effect: thường cho retry thực thi lại.
  • Lỗi sau side effect: phải lưu trạng thái đủ để reconcile, không được mù quáng chạy lại.

Một số API như Stripe lưu status và body khi endpoint đã bắt đầu execution, kể cả lỗi 500. Không sao chép policy máy móc; chọn theo khả năng xác định side effect của hệ thống và mô tả cho client.

9. Tích hợp payment provider

Idempotency tại API của bạn và tại payment provider là hai lớp khác nhau. Tạo provider key ổn định từ payment attempt nội bộ, không tạo UUID mới mỗi lần worker retry:

provider_key = "payment-attempt:" + payment_attempt_id

Lưu provider request ID và trạng thái. Nếu timeout không rõ charge đã thành công, query provider bằng idempotency key/request ID trước khi gửi lệnh mới. Webhook cũng phải deduplicate bằng event ID của provider.

Không giữ database transaction mở trong lúc gọi payment network. Dùng state machine và transactional outbox để commit ý định, sau đó worker thực hiện side effect có idempotency key.

10. Idempotency và Transactional Outbox bổ sung nhau

Idempotency Key bảo vệ request vào hệ thống. Outbox bảo đảm event sau khi business transaction commit sẽ được phát đáng tin cậy. Trong transaction tạo order, có thể đồng thời:

  1. Khóa/claim idempotency key.
  2. Tạo order.
  3. Insert OrderCreated vào outbox.
  4. Lưu response idempotent.
  5. Commit tất cả.

Consumer event vẫn cần dedup riêng vì broker có thể giao lại message. Một idempotency key không tự truyền guarantee xuyên qua mọi service.

11. TTL và tái sử dụng key

TTL phải dài hơn cửa sổ retry thực tế của client, queue, mobile offline và webhook. 24 giờ có thể đủ cho API tương tác, nhưng payout hoặc provisioning dài ngày có thể cần lâu hơn. Công bố TTL trong tài liệu.

Khi record hết hạn và bị xóa, dùng lại key có thể tạo thao tác mới. Vì vậy client không nên tái chế key. Với giao dịch giá trị cao, unique business identifier như merchant_order_id nên có constraint riêng và retention dài hơn idempotency cache.

DELETE FROM idempotency_keys
WHERE expires_at < now()
  AND status <> 'processing'
LIMIT 5000;

Dọn theo batch/partition và không xóa row processing chưa được reconcile.

12. Bảo mật và abuse

  • Giới hạn độ dài và format key để tránh storage abuse.
  • Scope theo authenticated principal để ngăn dò key tenant khác.
  • Không trả response cache trước khi kiểm tra authorization hiện tại.
  • Rate-limit số key mới; attacker có thể gửi UUID mới liên tục.
  • Mã hóa hoặc giảm PII trong response đã lưu.
  • Không log body/payment data chỉ để debug idempotency.

Key không phải credential. Biết idempotency key không được phép cung cấp quyền truy cập resource.

13. Observability

  • Tỷ lệ request mới, replay, conflict và in-progress.
  • Thời gian từ processing đến completed.
  • Số lease hết hạn và thao tác cần reconcile.
  • Dung lượng bảng, tốc độ tạo/xóa và tuổi record.
  • Duplicate bị chặn theo operation, không theo raw key.
  • Provider timeout và kết quả query lại.

Gắn request ID và trace ID riêng với idempotency key. Request ID xác định từng attempt; idempotency key nhóm nhiều attempt của cùng logical operation.

14. Kịch bản kiểm thử bắt buộc

  1. Hai request giống nhau chạy đồng thời chỉ tạo một resource.
  2. Cùng key, payload khác trả conflict.
  3. Crash trước commit không để lại business record.
  4. Crash sau commit trước response được replay đúng.
  5. Timeout từ payment provider được reconcile không charge lại.
  6. Retry sau TTL hoạt động đúng policy đã công bố.
  7. Authorization thay đổi không làm lộ response cũ.
  8. Cleanup không xóa request đang processing.

Checklist production

  • Client sinh key entropy cao và giữ nguyên qua retry.
  • Server scope theo tenant + operation + key.
  • Payload canonical hóa và fingerprint được so sánh.
  • Unique constraint xử lý race, không dùng check-then-insert.
  • Business data và kết quả idempotency commit nguyên tử khi có thể.
  • Policy cho 4xx, 5xx, in-progress và lease expiry được định nghĩa.
  • External side effect có provider key/state machine riêng.
  • TTL, cleanup, bảo mật và metric được vận hành rõ ràng.

Kết luận

Idempotency Key biến retry từ rủi ro thành một phần bình thường của API fault-tolerant. Thiết kế đúng cần nhiều hơn bảng cache: khóa phải có scope, request phải có fingerprint, race phải được giải bằng unique constraint, business effect phải gắn với transaction và side effect ngoài database cần state machine riêng. Idempotency không hứa “request chỉ chạy đúng một lần”; nó bảo đảm nhiều attempt của cùng ý định chỉ tạo một kết quả nghiệp vụ.

Tài liệu tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.