Idempotency key chính là ranh giới giữa một request thanh toán bị retry với một lần charge trùng ngoài ý muốn. Trong bất kỳ hệ thống phân tán nào mà client có thể retry request sau khi timeout - app mobile xài wifi chập chờn, load balancer failover, queue redeliver lại message - server cần có cách nhận ra "cái này làm rồi" và trả về kết quả cũ thay vì lặp lại side effect đó.
Vấn đề
Client -> POST /charge {amount: 5000} -> Server xử lý charge, ghi DB thành công
Client -> (timeout chờ response, retry lại)
Client -> POST /charge {amount: 5000} -> Server xử lý LẦN NỮA -> khách bị charge hai lần
Client không bao giờ biết request đầu tiên fail trước hay sau khi side effect đã xảy ra. Không có idempotency, chiến lược retry ngây thơ âm thầm gây ra charge trùng, email trùng, đơn hàng trùng.
Cách vá: Idempotency Key
Client tạo một key duy nhất (thường là UUID) cho mỗi thao tác logic rồi gửi kèm trong header:
POST /api/v1/charges
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{ "amount": 5000, "currency": "USD" }
Server lưu key này kèm kết quả của lần thực thi thành công đầu tiên. Lúc retry với cùng key, nó trả về kết quả đã lưu chứ không chạy lại side effect.
Triển khai với database-backed store
function handleCharge(Request $request) {
$key = $request->header('Idempotency-Key');
if (!$key) {
abort(400, 'Idempotency-Key header required');
}
return DB::transaction(function () use ($key, $request) {
$existing = IdempotencyKey::where('key', $key)->lockForUpdate()->first();
if ($existing) {
if ($existing->status === 'processing') {
abort(409, 'Request with this key is already being processed');
}
return response($existing->response_body, $existing->response_status);
}
IdempotencyKey::create([
'key' => $key,
'status' => 'processing',
'request_hash' => hash('sha256', $request->getContent()),
]);
$result = processCharge($request->all());
IdempotencyKey::where('key', $key)->update([
'status' => 'completed',
'response_status' => 200,
'response_body' => json_encode($result),
]);
return response()->json($result);
});
}
Row-level lock (lockForUpdate) bên trong transaction chính là thứ ngăn hai request đồng thời cùng key lọt qua bước check "đã tồn tại chưa" - đây là race condition mà đa số implementation ngây thơ hay bỏ sót nhất.
Chuỗi sự kiện của một lần retry an toàn
Mấy cái cần tránh
Không hash request body. Nếu client tái sử dụng một key với payload khác (ví dụ amount khác), trả về response đã cache sẽ âm thầm áp charge sai vô đúng key nhưng sai ý định. Lưu hash của request body kèm key, và từ chối (422) khi không khớp.
Không có expiration. Idempotency key nên có hạn hết hạn (24-48 giờ là con số điển hình cho API thanh toán). Giữ mãi mãi làm phình table và có rủi ro (dù hiếm) chặn nhầm một UUID hợp lệ được tái sử dụng đúng cách.
Coi "processing" như "không tìm thấy". Nếu request thứ hai tới trong lúc request đầu vẫn đang xử lý dở (không phải retry sau khi fail, mà là concurrency thật sự), trả về 409 (Conflict) hoặc bắt client poll lại an toàn hơn là để cả hai cùng chạy tiếp.
Chỉ scope key theo request mà không theo endpoint. Cùng một key gửi tới hai endpoint khác nhau không nên bị đụng nhau. Nên tra cứu theo (key, endpoint) hoặc (key, account_id), chứ không chỉ mỗi key riêng lẻ.
Chỉ áp dụng cho POST. PUT với PATCH cũng có thể bị áp dụng trùng nếu bản thân nó không tự nhiên idempotent (kiểu "cộng thêm 100 vô balance" thay vì "set balance = 100"). Ưu tiên update kiểu tuyệt đối thay vì tương đối khi đụng tới tiền, và dùng idempotency key cho cả hai kiểu.
Khi nào không cần cái này
Mấy thao tác tự bản chất đã idempotent (PUT set state tuyệt đối, GET, DELETE theo ID) thì không cần key - retry lại vốn đã an toàn rồi. Idempotency key chỉ quan trọng khi thao tác có side effect mà lặp lại không tự nhiên an toàn: charge tiền, gửi email, trừ tồn kho, tạo resource kèm side effect do server sinh ra.
Quy tắc chung: endpoint nào động tới tiền, gửi notification, hay tạo resource không được phép trùng thì cần idempotency key. Còn lại, nên tự hỏi lại xem mình có đang over-engineer không.

