Idempotency Pattern — Thiết kế API chống xử lý trùng lặp trong Distributed Systems

Posted on: 4/21/2026 8:13:11 AM

Hãy tưởng tượng: người dùng nhấn nút "Thanh toán" hai lần vì mạng lag. Hệ thống trừ tiền hai lần. Khách hàng mất tiền oan, đội support nhận ticket, và bạn phải hotfix lúc 2 giờ sáng. Đây không phải kịch bản giả định — đây là bài toán thực tế mà mọi distributed system đều phải giải quyết. Giải pháp? Idempotency Pattern.

60%+ API failures do network timeout gây retry tự động
$2.5B Thiệt hại do duplicate transactions toàn cầu (2025)
99.99% SLA yêu cầu idempotency cho payment API
3-5x Retry trung bình của mobile client khi timeout

Idempotency là gì?

Trong toán học, một phép toán f được gọi là idempotent nếu f(f(x)) = f(x). Trong software engineering, một API endpoint là idempotent khi gọi nó nhiều lần với cùng input sẽ cho kết quả giống hệt như gọi một lần — không tạo side effect trùng lặp.

Định nghĩa chính thức

Một operation là idempotent nếu thực hiện nó 1 lần hay N lần đều tạo ra cùng một trạng thái cuối cùng (final state) trên hệ thống. Response có thể khác nhau (ví dụ: lần đầu trả 201 Created, lần sau trả 200 OK), nhưng state phải đồng nhất.

HTTP Methods và Idempotency tự nhiên

Không phải mọi HTTP method đều cần xử lý idempotency thủ công. RFC 7231 đã quy định rõ:

HTTP MethodIdempotent?Safe?Giải thích
GET✅ Có✅ CóChỉ đọc, không thay đổi state
PUT✅ Có❌ KhôngReplace toàn bộ resource — gọi 10 lần vẫn cùng kết quả
DELETE✅ Có❌ KhôngXóa lần đầu thành công, lần sau trả 404 — state không đổi
PATCH⚠️ Tùy❌ KhôngIncrement counter thì không idempotent, set value thì có
POST❌ Không❌ KhôngTạo resource mới mỗi lần gọi — cần xử lý thủ công

⚠️ Bẫy phổ biến

PATCH /api/accounts/123 {"balance_add": 100} — thêm 100đ vào tài khoản. Gọi 3 lần = thêm 300đ. Đây là PATCH không idempotent. Thay vào đó, dùng: PATCH /api/accounts/123 {"balance": 1100} (set absolute value) hoặc thêm idempotency key.

Idempotency Key Pattern

Đây là pattern phổ biến nhất, được Stripe, PayPal, và hầu hết payment gateway áp dụng. Client tạo một unique key cho mỗi operation và gửi kèm trong request header. Server lưu key + response, nếu nhận lại cùng key sẽ trả response đã lưu thay vì xử lý lại.

sequenceDiagram
    participant C as Client
    participant API as API Gateway
    participant S as Service
    participant DB as Database
    participant IS as Idempotency Store

    C->>API: POST /payments
Idempotency-Key: abc-123 API->>IS: Kiểm tra key "abc-123" IS-->>API: Chưa tồn tại API->>S: Xử lý payment S->>DB: INSERT transaction DB-->>S: OK S-->>API: 201 Created API->>IS: Lưu key + response API-->>C: 201 Created Note over C,IS: Mạng timeout, client retry... C->>API: POST /payments
Idempotency-Key: abc-123 API->>IS: Kiểm tra key "abc-123" IS-->>API: Đã tồn tại, trả cached response API-->>C: 201 Created (cached)

Hình 1: Luồng xử lý Idempotency Key — lần gọi thứ hai trả kết quả cached

Thiết kế Idempotency Store

Idempotency Store là thành phần lưu trữ mapping giữa idempotency key và response đã xử lý. Có nhiều cách triển khai tùy yêu cầu về hiệu năng và độ bền:

StorageLatencyDurabilityPhù hợp khi
Redis (SET NX + TTL)< 1msTrung bìnhHigh-throughput API, chấp nhận mất data khi Redis restart
PostgreSQL / SQL Server2-5msCaoFinancial transactions, cần ACID guarantee
DynamoDB< 5msCaoServerless, global scale, conditional write
In-memory + WAL< 0.1msTùy WAL configUltra-low latency, single-node deployment

Cấu trúc bảng Idempotency Store (SQL Server)

CREATE TABLE IdempotencyStore (
    IdempotencyKey  NVARCHAR(256) NOT NULL PRIMARY KEY,
    HttpMethod      VARCHAR(10)   NOT NULL,
    Endpoint        NVARCHAR(500) NOT NULL,
    RequestHash     CHAR(64)      NOT NULL,   -- SHA-256 của request body
    StatusCode      INT           NOT NULL,
    ResponseBody    NVARCHAR(MAX) NULL,
    CreatedAt       DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME(),
    ExpiresAt       DATETIME2     NOT NULL,

    INDEX IX_ExpiresAt (ExpiresAt)  -- Để cleanup job xóa expired entries
);

Redis approach (nhanh hơn, đơn giản hơn)

# Atomic check-and-set với NX (only set if Not eXists)
SET idempotency:abc-123 '{"status":201,"body":{...}}' NX EX 86400

# NX đảm bảo chỉ request đầu tiên "thắng"
# EX 86400 = tự xóa sau 24h

Implementation với .NET 10

Trong .NET, cách tiếp cận sạch nhất là dùng Action Filter hoặc Middleware để xử lý idempotency ở tầng cross-cutting, không cần mỗi endpoint tự implement.

Idempotency Filter

public class IdempotencyFilter(
    IIdempotencyStore store,
    TimeProvider timeProvider) : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        if (!context.HttpContext.Request.Headers
            .TryGetValue("Idempotency-Key", out var keyValues))
        {
            await next();
            return;
        }

        var key = keyValues.ToString();
        var cached = await store.GetAsync(key);

        if (cached is not null)
        {
            // Verify request body matches (chống misuse)
            var requestHash = await ComputeRequestHash(context.HttpContext.Request);
            if (cached.RequestHash != requestHash)
            {
                context.Result = new ConflictObjectResult(new {
                    error = "Idempotency key đã được dùng cho request khác"
                });
                return;
            }

            context.Result = new ObjectResult(cached.ResponseBody)
            {
                StatusCode = cached.StatusCode
            };
            return;
        }

        // Thực thi request gốc
        var executedContext = await next();

        // Lưu response vào store
        if (executedContext.Result is ObjectResult result)
        {
            await store.SaveAsync(new IdempotencyEntry
            {
                Key = key,
                RequestHash = await ComputeRequestHash(context.HttpContext.Request),
                StatusCode = result.StatusCode ?? 200,
                ResponseBody = result.Value,
                ExpiresAt = timeProvider.GetUtcNow().AddHours(24)
            });
        }
    }
}

Đăng ký cho endpoint cụ thể

app.MapPost("/api/payments", async (CreatePaymentRequest req, PaymentService svc) =>
{
    var result = await svc.CreatePaymentAsync(req);
    return Results.Created($"/api/payments/{result.Id}", result);
})
.AddEndpointFilter<IdempotencyFilter>();

Database-Level Idempotency

Không phải lúc nào cũng cần idempotency store riêng. Với nhiều use case, bạn có thể đạt idempotency ngay tại tầng database bằng các kỹ thuật sau:

graph TD
    A[Request đến] --> B{Có natural key?}
    B -->|Có| C[UNIQUE constraint
trên natural key] B -->|Không| D{Có idempotency key?} D -->|Có| E[Idempotency Store
Redis / SQL] D -->|Không| F{Operation type?} F -->|SET/PUT| G[Upsert pattern
MERGE / ON CONFLICT] F -->|INCREMENT| H[Conditional update
với version/timestamp] F -->|CREATE| I[⚠️ Cần thêm
idempotency key] style A fill:#e94560,stroke:#fff,color:#fff style C fill:#4CAF50,stroke:#fff,color:#fff style E fill:#4CAF50,stroke:#fff,color:#fff style G fill:#4CAF50,stroke:#fff,color:#fff style H fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style I fill:#ff9800,stroke:#fff,color:#fff

Hình 2: Decision tree — chọn chiến lược idempotency phù hợp

UNIQUE Constraint (Natural Key)

-- Order đã có mã đơn hàng unique
CREATE TABLE Orders (
    Id          INT IDENTITY PRIMARY KEY,
    OrderCode   VARCHAR(50) NOT NULL UNIQUE,  -- Natural idempotency key
    CustomerId  INT NOT NULL,
    TotalAmount DECIMAL(18,2) NOT NULL,
    CreatedAt   DATETIME2 DEFAULT SYSUTCDATETIME()
);

-- Insert sẽ fail nếu OrderCode đã tồn tại — client retry an toàn
INSERT INTO Orders (OrderCode, CustomerId, TotalAmount)
VALUES ('ORD-2026-04-21-001', 42, 1500000);

Upsert Pattern (MERGE)

-- Idempotent upsert — gọi bao nhiêu lần cũng cho cùng kết quả
MERGE INTO UserPreferences AS target
USING (VALUES (@UserId, @Theme, @Language)) AS source (UserId, Theme, Language)
ON target.UserId = source.UserId
WHEN MATCHED THEN
    UPDATE SET Theme = source.Theme, Language = source.Language
WHEN NOT MATCHED THEN
    INSERT (UserId, Theme, Language)
    VALUES (source.UserId, source.Theme, source.Language);

Idempotency trong Message Queue

Message queue (Kafka, RabbitMQ, Azure Service Bus) có at-least-once delivery — consumer có thể nhận cùng message nhiều lần. Idempotency ở consumer side là bắt buộc.

graph LR
    P[Producer] -->|Message + MessageId| Q[Message Queue]
    Q -->|Deliver| C1[Consumer Instance 1]
    Q -->|Redeliver
sau timeout| C1 C1 --> DS[(Dedup Store)] DS -->|Đã xử lý?| SKIP[Skip] DS -->|Chưa xử lý?| PROCESS[Process + Commit] style P fill:#2c3e50,stroke:#fff,color:#fff style Q fill:#e94560,stroke:#fff,color:#fff style C1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style DS fill:#4CAF50,stroke:#fff,color:#fff

Hình 3: Consumer-side deduplication trong message queue

public class IdempotentOrderConsumer(
    IIdempotencyStore dedupStore,
    OrderService orderService) : IConsumer<OrderCreatedEvent>
{
    public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
    {
        var messageId = context.MessageId?.ToString()
            ?? context.Message.OrderId.ToString();

        if (await dedupStore.ExistsAsync(messageId))
            return;  // Đã xử lý, skip

        await orderService.ProcessOrderAsync(context.Message);
        await dedupStore.SaveAsync(messageId, TimeSpan.FromDays(7));
    }
}

Payment System — Nơi Idempotency là sống còn

Trong hệ thống thanh toán, duplicate transaction không chỉ là bug — nó là sự cố tài chính. Các payment gateway lớn đều bắt buộc idempotency key:

Payment GatewayHeaderTTLBehavior khi trùng key
StripeIdempotency-Key24 giờTrả cached response, log warning
PayPalPayPal-Request-Id72 giờTrả 200 + original response
AdyenIdempotency-Key24 giờTrả cached response
VNPayvnp_TxnRef (unique)Vĩnh viễnReject duplicate txn reference

💡 Best Practice từ Stripe

Stripe khuyến nghị: Idempotency key nên là UUID v4 do client tạo ra, gắn với user action (click button), không phải với request object. Nếu user click "Pay" → tạo UUID → mọi retry của click đó dùng cùng UUID. User click "Pay" lần nữa (sau khi đã thấy kết quả) → tạo UUID mới.

Race Condition và Concurrent Requests

Khi hai request cùng idempotency key đến đồng thời (trước khi request đầu tiên hoàn thành), bạn cần cơ chế locking:

sequenceDiagram
    participant C1 as Request 1
    participant C2 as Request 2
    participant L as Lock Manager
    participant S as Service
    participant IS as Idempotency Store

    C1->>L: Acquire lock "key-abc"
    L-->>C1: Lock acquired ✅
    C2->>L: Acquire lock "key-abc"
    L-->>C2: Lock busy, wait... ⏳

    C1->>S: Process payment
    S-->>C1: 201 Created
    C1->>IS: Save response
    C1->>L: Release lock

    L-->>C2: Lock acquired ✅
    C2->>IS: Check key "key-abc"
    IS-->>C2: Found cached response
    C2-->>C2: Return cached 201
    C2->>L: Release lock

Hình 4: Distributed lock ngăn xử lý đồng thời cùng idempotency key

Redis Distributed Lock

public async Task<IActionResult> ProcessWithLock(string idempotencyKey)
{
    var lockKey = $"lock:idempotency:{idempotencyKey}";

    // Acquire lock với timeout 30s
    await using var lockHandle = await _redisLock
        .AcquireAsync(lockKey, TimeSpan.FromSeconds(30));

    if (lockHandle is null)
        return StatusCode(429, "Request đang được xử lý, vui lòng thử lại");

    // Check cached response
    var cached = await _store.GetAsync(idempotencyKey);
    if (cached is not null)
        return StatusCode(cached.StatusCode, cached.ResponseBody);

    // Process request
    var result = await _service.ExecuteAsync();

    // Cache response
    await _store.SaveAsync(idempotencyKey, result);

    return Ok(result);
}

Kiến trúc tổng thể — Idempotency trong Microservices

graph TD
    CLIENT[Mobile / Web Client] -->|Idempotency-Key header| GW[API Gateway]
    GW --> IF{Idempotency Filter}

    IF -->|Cache hit| CACHED[Return Cached Response]
    IF -->|Cache miss| LOCK[Acquire Distributed Lock]

    LOCK --> SVC[Business Service]
    SVC --> DB[(Primary Database)]
    SVC --> MQ[Message Queue]
    MQ --> CONSUMER[Consumer Service]
    CONSUMER --> DEDUP{Dedup Check}
    DEDUP -->|Đã xử lý| SKIP2[Skip]
    DEDUP -->|Mới| PROC[Process + Save MessageId]

    SVC -->|Response| SAVE[Save to Idempotency Store]
    SAVE --> RELEASE[Release Lock]
    RELEASE --> CLIENT

    style CLIENT fill:#2c3e50,stroke:#fff,color:#fff
    style GW fill:#e94560,stroke:#fff,color:#fff
    style IF fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style LOCK fill:#ff9800,stroke:#fff,color:#fff
    style SVC fill:#4CAF50,stroke:#fff,color:#fff
    style CONSUMER fill:#4CAF50,stroke:#fff,color:#fff
    style DB fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style MQ fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Hình 5: Idempotency xuyên suốt kiến trúc microservices — từ API Gateway đến Message Consumer

Anti-Patterns cần tránh

1. Dùng timestamp làm idempotency key

// ❌ SAI — timestamp có thể trùng giữa các request khác nhau
headers: { 'Idempotency-Key': Date.now().toString() }

// ✅ ĐÚNG — UUID v4 đảm bảo unique
headers: { 'Idempotency-Key': crypto.randomUUID() }

2. Không verify request body

// Request 1: POST /payments, Key: abc, Body: {amount: 100}
// Request 2: POST /payments, Key: abc, Body: {amount: 999}
// Nếu chỉ check key mà không check body → trả response của 100đ cho request 999đ
// → PHẢI hash request body và so sánh

3. TTL quá ngắn hoặc quá dài

⚠️ Chọn TTL phù hợp

Quá ngắn (1 phút): Client retry sau 2 phút → key đã expired → xử lý duplicate.
Quá dài (30 ngày): Store phình to, tốn memory/storage không cần thiết.
Khuyến nghị: 24h cho API thông thường, 72h cho payment, 7 ngày cho async workflow.

4. Idempotency key ở sai scope

// ❌ Key per user — 2 payment khác nhau cùng user bị block
Idempotency-Key: user-42

// ❌ Key per endpoint — mọi request đến endpoint bị coi là trùng
Idempotency-Key: create-payment

// ✅ Key per user-action — mỗi click tạo key riêng
Idempotency-Key: user-42-pay-order-789-attempt-1

Checklist triển khai Idempotency

#Hạng mụcChi tiết
1Xác định endpoints cần idempotencyPOST endpoints tạo resource, mutation endpoints có side effect
2Chọn storage cho idempotency storeRedis cho high-throughput, SQL cho ACID guarantee
3Implement request body hashingSHA-256 của normalized request body
4Thêm distributed lockNgăn race condition khi concurrent requests
5Set TTL hợp lý24h standard, 72h payment, 7d async
6Handle edge casesKey conflict (khác body), lock timeout, store unavailable
7Monitoring & alertingTrack duplicate hit rate, lock contention, store latency
8Consumer-side dedupMessage queue consumer cũng cần idempotency check

Kết luận

Idempotency không phải feature "nice-to-have" — nó là yêu cầu kiến trúc bắt buộc cho bất kỳ distributed system nào cần đảm bảo data consistency. Từ API Gateway đến Message Consumer, mỗi tầng trong hệ thống đều cần chiến lược xử lý duplicate phù hợp. Pattern Idempotency Key + distributed lock + consumer dedup tạo thành bộ ba phòng thủ toàn diện, giúp hệ thống của bạn vững vàng trước network failure, client retry, và message redelivery.

💡 Nguyên tắc vàng

Thiết kế mọi write operation như thể nó sẽ được gọi ít nhất 2 lần. Nếu gọi 2 lần mà kết quả khác gọi 1 lần — bạn có bug. Idempotency by design, không phải idempotency by afterthought.

Nguồn tham khảo: