cat blog/.md
Idempotency Key: thiết kế API không ngại retry
Có một cái bug kinh điển mà tôi đã gặp ít nhất ba lần ở ba công ty khác nhau: khách hàng bị trừ tiền hai lần cho một đơn hàng. Log thì cho thấy client gửi hai request POST /payments cách nhau 2 giây, server xử lý cả hai, và cả hai đều thành công.
Client không cố tình. Mạng chập chờn, response của request đầu không về kịp, client retry. Nhưng thực ra request đầu đã chạy xong phía server — chỉ có cái response bị rớt dọc đường. Kết quả: một đơn, hai lần trừ tiền.
Đây là vấn đề cơ bản của at-least-once delivery. Và giải pháp chuẩn trong API design là Idempotency Key.
Idempotent nghĩa là gì
Một operation là idempotent nếu gọi nó N lần cho ra cùng kết quả với gọi 1 lần. GET, PUT, DELETE về bản chất là idempotent: đọc lại 10 lần, set status = “cancelled” 10 lần, xoá record 10 lần — kết quả cuối cùng không khác.
Vấn đề nằm ở POST. POST /payments với amount = 100.000đ gọi 2 lần sẽ tạo 2 payment. Chính xác là điều bạn không muốn khi client retry.
Một số API tự “lách” bằng cách yêu cầu client PUT vào một ID do client tự sinh:
PUT /payments/9f2b1c3d-xxxx
Cách này hoạt động, nhưng đẩy trách nhiệm sinh ID sang client và làm URL kém gọn. Stripe, GitHub, AWS đều chọn cách khác: giữ nguyên POST, nhưng thêm header Idempotency-Key.
Cơ chế Idempotency-Key
Client gửi một key ngẫu nhiên (UUID) trong header:
POST /payments HTTP/1.1
Idempotency-Key: 9f2b1c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
Content-Type: application/json
{"order_id": 123, "amount": 100000}
Nguyên tắc server-side:
- Lần đầu: chưa từng thấy key này → xử lý request bình thường, lưu response kèm key vào DB.
- Retry: client gửi lại request với cùng key → server nhận ra, trả về response đã cache, không xử lý lại.
- Key trùng, body khác: client gửi cùng key nhưng payload đã đổi → server trả lỗi. Đây là hành vi sai từ client, không phải retry.
Nghe đơn giản, nhưng có vài cạm bẫy dễ dính.
Cạm bẫy 1: race condition khi hai request cùng đến
Client retry quá sớm, hai request đến gần như đồng thời. Nếu chỉ check-then-insert, cả hai đều thấy key chưa tồn tại → cùng xử lý → mất tác dụng.
Giải pháp: dùng unique constraint làm cơ chế khoá. Ghi key trước, xử lý sau.
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'in_progress' | 'completed'
response_code INTEGER,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_until TIMESTAMPTZ
);
CREATE INDEX idx_idem_created ON idempotency_keys (created_at);
Request đầu tiên INSERT thành công với status in_progress. Request thứ hai bị UniqueViolation → biết là đã có ai đó đang xử lý → chờ hoặc trả 409 Conflict.
Cạm bẫy 2: so khớp body
Nếu client gửi cùng key nhưng payload khác, trả lại response cũ là sai lầm nghiêm trọng — client nghĩ request mới đã xử lý, nhưng thực ra bạn trả kết quả của request cũ.
Cách chuẩn: băm payload bằng SHA-256, lưu cùng key. Khi retry, so hash. Khớp → trả response cũ. Không khớp → 422 Unprocessable Entity với message rõ.
Triển khai với FastAPI + PostgreSQL
Một middleware/decorator tối thiểu:
import hashlib
import json
from fastapi import Request, HTTPException
from sqlalchemy.exc import IntegrityError
def _hash_request(method: str, path: str, body: bytes) -> str:
h = hashlib.sha256()
h.update(method.encode())
h.update(b"\0")
h.update(path.encode())
h.update(b"\0")
h.update(body)
return h.hexdigest()
async def with_idempotency(request: Request, db, handler):
key = request.headers.get("Idempotency-Key")
if not key:
return await handler()
body = await request.body()
req_hash = _hash_request(request.method, request.url.path, body)
# Fast path: key đã tồn tại
existing = await db.fetch_one(
"SELECT request_hash, status, response_code, response_body "
"FROM idempotency_keys WHERE key = :k",
{"k": key},
)
if existing:
if existing["request_hash"] != req_hash:
raise HTTPException(422, "Idempotency-Key reused with different body")
if existing["status"] == "in_progress":
raise HTTPException(409, "Request still in progress")
return JSONResponse(
status_code=existing["response_code"],
content=existing["response_body"],
)
# Reserve key — ai vào trước người đó xử lý
try:
await db.execute(
"INSERT INTO idempotency_keys (key, request_hash, status) "
"VALUES (:k, :h, 'in_progress')",
{"k": key, "h": req_hash},
)
except IntegrityError:
raise HTTPException(409, "Concurrent request with same key")
try:
response = await handler()
except Exception:
# Trả key về để client retry được — hoặc giữ và mark failed
await db.execute(
"DELETE FROM idempotency_keys WHERE key = :k AND status = 'in_progress'",
{"k": key},
)
raise
await db.execute(
"UPDATE idempotency_keys SET status = 'completed', "
"response_code = :c, response_body = :b WHERE key = :k",
{"k": key, "c": response.status_code, "b": json.dumps(response.body)},
)
return response
Code thật thường phức tạp hơn (phân biệt lỗi 5xx và 4xx, TTL cho key, cleanup job…), nhưng khung sườn chỉ có vậy.
Cạm bẫy 3: idempotency và database transaction
Nếu business logic của bạn ghi vào một DB khác với idempotency store, bạn lại rơi vào dual-write problem (đã viết ở bài Outbox Pattern). Khi có thể, đặt bảng idempotency_keys trong cùng database với dữ liệu nghiệp vụ, và commit trong cùng một transaction:
async with db.transaction():
await db.execute("INSERT INTO idempotency_keys ...")
payment = await create_payment(...)
await db.execute("UPDATE idempotency_keys SET status = 'completed' ...")
Nếu payment fail, key rollback theo, client retry được. Nếu payment ok và commit, response đã cache cùng transaction.
Cạm bẫy 4: TTL và dọn dẹp
Không thể giữ idempotency key mãi mãi. Stripe giữ 24 giờ, AWS là vài giờ. Chọn TTL theo khả năng retry của client (thường 1–24h là đủ). Chạy job định kỳ xoá key quá hạn, hoặc đơn giản là tạo partial index và thêm điều kiện created_at > NOW() - INTERVAL '24h' ở mỗi lookup.
Khi nào cần, khi nào không
Nên có:
- Bất kỳ endpoint nào tạo ra thay đổi không thể undo:
POST /payments,POST /orders,POST /transfers. - Webhook nhận từ bên ngoài (provider có thể retry).
- API công khai cho khách hàng bên ngoài.
Không cần:
GET,HEAD,OPTIONS— đã idempotent sẵn.- Operation chỉ có ý nghĩa ở mức session (login, logout).
- Background job nội bộ đã có dedup key riêng (ví dụ queue message với deduplication ID).
Phía client: đừng quên
Idempotency-Key là hợp đồng giữa client và server. Client phải:
- Sinh UUID v4 một lần, lưu lại, và dùng đúng key đó cho tất cả các lần retry của cùng một ý định người dùng.
- Chỉ đổi key khi người dùng thật sự muốn gửi lại (ấn lại nút Pay lần thứ hai chủ động).
Lỗi thường gặp: client retry mà lại sinh key mới mỗi lần. Vậy thì idempotency coi như không có.
Kết
Idempotency Key không phải thứ bạn thêm vào sau khi hệ thống đã chạy — thêm sau là cực kỳ đau, vì phải đổi cả contract API. Nên nghĩ đến nó ngay từ thiết kế, đặc biệt cho mọi endpoint đụng đến tiền, inventory, hay gửi thông báo ra ngoài.
Kết hợp với retry có backoff và Outbox Pattern mà tôi đã viết trong hai bài trước, bạn có một bộ ba khá vững cho hệ thống phân tán: client retry an toàn, server xử lý đúng một lần, event không bao giờ bị mất. Ba mảnh ghép khác nhau nhưng cùng giải một bài toán — làm sao để một action xảy ra đúng một lần trong một thế giới mà mọi thứ đều có thể fail.
cat comments.log