Idempotency Key — Giải pháp chống duplicate request trong hệ thống phân tán

7 phút đọc

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:

  1. User nhấn "Thanh toán" → request gửi lên server
  2. Network timeout → client không nhận được response
  3. User hoảng → nhấn lại "Thanh toán" lần 2
  4. Server xử lý cả 2 requesttrừ 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ả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: POSTPATCHnon-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 để:

  1. Lần đầu: xử lý request → lưu key + response vào storage
  2. 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 processing quá 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.


Tham khảo

Bài viết liên quan

Đang cập nhật...