Summary
Tìm hiểu Idempotency Key là gì, tại sao nó quan trọng trong payment processing và API design, cách implement với Laravel + MySQL/Redis, và các best practices từ Stripe, PayPal. Bài viết bao gồm ví dụ thực tế, sequence diagram, và code mẫu production-ready.
1. Vấn đề: Duplicate Request trong thực tế
Tưởng tượng kịch bản sau:
- User nhấn "Thanh toán" → request gửi lên server
- Network timeout → client không nhận được response
- User hoảng → nhấn lại "Thanh toán" lần 2
- Server xử lý cả 2 request → trừ tiền 2 lần 💸
Đây không phải edge case hiếm gặp. Trong production, duplicate request xảy ra do:
- Network retry — client SDK tự retry khi timeout
- User double-click — UI chưa disable button
- Load balancer retry — upstream timeout, LB forward lại request
- Webhook retry — payment gateway gửi lại callback
- Mobile app resume — app bị kill giữa chừng, mở lại gửi lại request
Bất kỳ API nào có side effect (tạo order, chuyển tiền, gửi email) đều cần cơ chế chống duplicate.
2. Idempotency là gì?
Định nghĩa
Idempotent operation = thực hiện 1 lần hay N lần đều cho cùng kết quả và cùng side effect.
HTTP Methods theo RFC 7231
| Method | Idempotent? | Safe? | Giải thích |
|---|---|---|---|
GET |
✅ Yes | ✅ Yes | Chỉ đọc, không side effect |
PUT |
✅ Yes | ❌ No | Replace toàn bộ → kết quả luôn giống nhau |
DELETE |
✅ Yes | ❌ No | Xóa 1 lần hay N lần → resource vẫn bị xóa |
POST |
❌ No | ❌ No | Mỗi lần gọi có thể tạo resource mới |
PATCH |
❌ No | ❌ No | Tùy implementation, thường không idempotent |
Key insight:
POSTvàPATCHlà non-idempotent by default. Đây chính là nơi cần Idempotency Key.
3. Idempotency Key hoạt động như thế nào?
Concept
Idempotency Key là một unique token (thường là UUID v4) mà client gửi kèm request. Server dùng key này để:
- Lần đầu: xử lý request → lưu key + response vào storage
- Lần sau (cùng key): trả lại response đã lưu → không xử lý lại
Sequence Diagram
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
participant R as Redis
Note over C: Generate UUID v4<br>as Idempotency Key
C->>S: POST /api/payments<br>Idempotency-Key: abc-123
S->>R: SETNX lock:abc-123 (acquire lock)
R-->>S: OK (first time)
S->>DB: Check idempotency_keys table
DB-->>S: Not found
S->>DB: BEGIN TRANSACTION
S->>DB: INSERT idempotency_keys (key, status=processing)
S->>DB: Process payment logic
S->>DB: UPDATE idempotency_keys (status=completed, response=...)
S->>DB: COMMIT
S->>R: DEL lock:abc-123
S-->>C: 200 OK {"payment_id": "pay_001"}
Note over C: Network timeout...<br>Client retries same key
C->>S: POST /api/payments<br>Idempotency-Key: abc-123
S->>R: SETNX lock:abc-123
R-->>S: OK
S->>DB: Check idempotency_keys table
DB-->>S: Found (status=completed)
S->>R: DEL lock:abc-123
S-->>C: 200 OK {"payment_id": "pay_001"}
Note over S: Trả cached response<br>KHÔNG xử lý lại
4. Database Design
Migration
Schema::create('idempotency_keys', function (Blueprint $table) {
$table->id();
$table->string('key', 255)->unique(); // UUID từ client
$table->string('route'); // POST /api/payments
$table->unsignedBigInteger('user_id')->nullable();
$table->enum('status', ['processing', 'completed', 'failed'])
->default('processing');
$table->json('request_hash'); // Hash của request body
$table->integer('response_code')->nullable(); // HTTP status code
$table->json('response_body')->nullable(); // Cached response
$table->timestamp('expires_at'); // TTL
$table->timestamps();
$table->index(['key', 'route']);
$table->index('expires_at'); // Cleanup job
});
Tại sao cần request_hash?
Để phát hiện key reuse — client gửi cùng idempotency key nhưng khác request body. Đây là lỗi logic từ client, server phải reject với 422 Unprocessable Entity.
5. Implementation với Laravel
5.1 Model
// app/Models/IdempotencyKey.php
class IdempotencyKey extends Model
{
protected $fillable = [
'key', 'route', 'user_id', 'status',
'request_hash', 'response_code', 'response_body', 'expires_at',
];
protected $casts = [
'response_body' => 'array',
'request_hash' => 'array',
'expires_at' => 'datetime',
];
public function isCompleted(): bool
{
return $this->status === 'completed';
}
public function isProcessing(): bool
{
return $this->status === 'processing';
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
}
5.2 Middleware
// app/Http/Middleware/IdempotencyMiddleware.php
class IdempotencyMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Chỉ áp dụng cho POST/PATCH
if (!in_array($request->method(), ['POST', 'PATCH'])) {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key');
if (!$idempotencyKey) {
return response()->json([
'error' => 'Missing Idempotency-Key header',
], 400);
}
// Validate UUID format
if (!Str::isUuid($idempotencyKey)) {
return response()->json([
'error' => 'Idempotency-Key must be a valid UUID v4',
], 400);
}
$route = $request->method() . ' ' . $request->path();
$requestHash = hash('sha256', json_encode($request->all()));
// Acquire distributed lock (prevent race condition)
$lock = Cache::lock("idempotency:{$idempotencyKey}", 30);
if (!$lock->get()) {
return response()->json([
'error' => 'Request is currently being processed',
], 409); // Conflict
}
try {
$existing = IdempotencyKey::where('key', $idempotencyKey)
->where('route', $route)
->first();
// Case 1: Key exists và đã completed
if ($existing && $existing->isCompleted()) {
// Verify request body khớp
if ($existing->request_hash !== $requestHash) {
return response()->json([
'error' => 'Idempotency-Key reused with different request body',
], 422);
}
// Trả cached response
return response()->json(
$existing->response_body,
$existing->response_code
);
}
// Case 2: Key exists nhưng đang processing (stale lock)
if ($existing && $existing->isProcessing()) {
// Check if expired → cleanup và retry
if ($existing->created_at->diffInSeconds(now()) > 60) {
$existing->delete();
} else {
return response()->json([
'error' => 'Request is still being processed',
], 409);
}
}
// Case 3: New key → tạo record và xử lý
$record = IdempotencyKey::create([
'key' => $idempotencyKey,
'route' => $route,
'user_id' => $request->user()?->id,
'status' => 'processing',
'request_hash' => $requestHash,
'expires_at' => now()->addHours(24),
]);
// Xử lý request thực tế
$response = $next($request);
// Lưu response
$record->update([
'status' => 'completed',
'response_code' => $response->getStatusCode(),
'response_body' => json_decode($response->getContent(), true),
]);
return $response;
} catch (\Throwable $e) {
// Mark as failed
if (isset($record)) {
$record->update(['status' => 'failed']);
}
throw $e;
} finally {
$lock->release();
}
}
}
5.3 Register Middleware
// bootstrap/app.php (Laravel 11+)
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->api(append: [
// Hoặc dùng alias để gán cho route cụ thể
]);
$middleware->alias([
'idempotent' => IdempotencyMiddleware::class,
]);
});
5.4 Sử dụng trên Route
// routes/api.php
Route::middleware(['auth:sanctum', 'idempotent'])->group(function () {
Route::post('/payments', [PaymentController::class, 'store']);
Route::post('/orders', [OrderController::class, 'store']);
Route::post('/transfers', [TransferController::class, 'store']);
});
6. Client-side Implementation
JavaScript/Axios
import { v4 as uuidv4 } from 'uuid';
import axios from 'axios';
async function createPayment(data, retries = 3) {
// Generate key MỘT LẦN cho cả retry chain
const idempotencyKey = uuidv4();
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await axios.post('/api/payments', data, {
headers: {
'Idempotency-Key': idempotencyKey,
},
timeout: 10000, // 10s timeout
});
return response.data;
} catch (error) {
if (attempt === retries) throw error;
// Chỉ retry với network error hoặc 5xx
if (!error.response || error.response.status >= 500) {
await sleep(Math.pow(2, attempt) * 1000); // Exponential backoff
continue;
}
throw error; // 4xx → không retry
}
}
}
⚠️ Quan trọng: Idempotency Key phải được generate trước vòng retry. Nếu generate mới mỗi lần retry → mất ý nghĩa idempotency.
7. Edge Cases & Pitfalls
7.1 Race Condition: 2 request cùng key đến đồng thời
Giải pháp: Distributed lock (Redis SETNX) trước khi check DB. Request thứ 2 sẽ nhận 409 Conflict.
7.2 Stale Processing Records
Server crash giữa chừng → record stuck ở processing mãi.
Giải pháp:
- Timeout check: nếu
processingquá 60s → coi như failed, cho phép retry - Scheduled cleanup job:
// app/Console/Commands/CleanupIdempotencyKeys.php
class CleanupIdempotencyKeys extends Command
{
protected $signature = 'idempotency:cleanup';
public function handle(): void
{
// Xóa expired keys
IdempotencyKey::where('expires_at', '<', now())->delete();
// Reset stale processing keys (> 5 phút)
IdempotencyKey::where('status', 'processing')
->where('created_at', '<', now()->subMinutes(5))
->delete();
$this->info('Cleanup completed.');
}
}
7.3 Key Reuse với khác Request Body
Client gửi cùng key nhưng body khác → phải reject, không phải trả cached response.
7.4 TTL — Bao lâu thì xóa key?
| Use case | TTL gợi ý | Lý do |
|---|---|---|
| Payment | 24–48 giờ | User có thể retry sau vài giờ |
| Order creation | 24 giờ | Thường retry ngay lập tức |
| Email sending | 1–4 giờ | Ít khi retry sau lâu |
| Webhook | 7 ngày | Provider có thể retry nhiều ngày |
8. So sánh các phương án chống Duplicate
| Phương án | Ưu điểm | Nhược điểm | Khi nào dùng |
|---|---|---|---|
| Idempotency Key | Chính xác, chuẩn industry, hỗ trợ retry | Cần storage (DB/Redis), phức tạp hơn | Payment, order, mọi mutation API quan trọng |
| DB Unique Constraint | Đơn giản, DB đảm bảo | Chỉ chặn duplicate insert, không cache response | Khi có natural unique key (email, order_number) |
| Optimistic Locking (version) | Không cần lock | Chỉ cho update, không cho create | Concurrent update trên cùng resource |
| Frontend debounce | Dễ implement | Không đảm bảo (user mở 2 tab, mobile resume) | Bổ sung, KHÔNG thay thế server-side |
| Token-based (CSRF-like) | Server generate token trước | Cần extra roundtrip | Form submission truyền thống |
9. Stripe làm Idempotency như thế nào?
Stripe là tiêu chuẩn vàng cho idempotency implementation:
- Header:
Idempotency-Key(khuyến nghị UUID v4) - TTL: 24 giờ — key tự expire sau 24h
- Replay: trả lại exact same response (bao gồm cả error responses)
- Key reuse check: reject nếu cùng key nhưng khác endpoint hoặc params
- Scope: key scoped theo API key (không phải global)
# Ví dụ Stripe API call
curl https://api.stripe.com/v1/charges \
-u sk_test_xxx: \
-H "Idempotency-Key: KG5LxwFBepaKHyud" \
-d amount=2000 \
-d currency=usd \
-d source=tok_visa
📖 Tham khảo: Stripe Idempotent Requests
10. Best Practices — Checklist
- Idempotency Key là UUID v4, do client generate
- Gửi qua header (
Idempotency-Key), không qua body - Server validate format key trước khi xử lý
- Distributed lock để handle concurrent requests cùng key
- Hash request body để detect key reuse
- Cache cả error responses (4xx cũng cache, 5xx thì không)
- TTL hợp lý — 24h cho payment, cleanup expired records
- Scope key theo user + route (không global)
- Client generate key trước retry loop
- Retry với exponential backoff
- Log idempotency events cho debugging
- Frontend debounce bổ sung, không thay thế server-side
Kết luận
Idempotency Key không phải "nice-to-have" — nó là requirement cho bất kỳ API nào xử lý tiền, tạo order, hoặc trigger side effect không thể undo. Chi phí implement thấp (1 middleware + 1 table), nhưng giá trị mang lại cực lớn: tránh mất tiền, tránh duplicate data, tăng trust của client.
Quy tắc đơn giản: Nếu API của bạn là POST và có side effect → cần Idempotency Key.