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
Table of contents
- Idempotency là gì?
- HTTP Methods và Idempotency tự nhiên
- Idempotency Key Pattern
- Thiết kế Idempotency Store
- Implementation với .NET 10
- Database-Level Idempotency
- Idempotency trong Message Queue
- Payment System — Nơi Idempotency là sống còn
- Race Condition và Concurrent Requests
- Kiến trúc tổng thể — Idempotency trong Microservices
- Anti-Patterns cần tránh
- Checklist triển khai Idempotency
- Kết luận
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.
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 Method | Idempotent? | Safe? | Giải thích |
|---|---|---|---|
GET | ✅ Có | ✅ Có | Chỉ đọc, không thay đổi state |
PUT | ✅ Có | ❌ Không | Replace toàn bộ resource — gọi 10 lần vẫn cùng kết quả |
DELETE | ✅ Có | ❌ Không | Xóa lần đầu thành công, lần sau trả 404 — state không đổi |
PATCH | ⚠️ Tùy | ❌ Không | Increment counter thì không idempotent, set value thì có |
POST | ❌ Không | ❌ Không | Tạ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:
| Storage | Latency | Durability | Phù hợp khi |
|---|---|---|---|
| Redis (SET NX + TTL) | < 1ms | Trung bình | High-throughput API, chấp nhận mất data khi Redis restart |
| PostgreSQL / SQL Server | 2-5ms | Cao | Financial transactions, cần ACID guarantee |
| DynamoDB | < 5ms | Cao | Serverless, global scale, conditional write |
| In-memory + WAL | < 0.1ms | Tùy WAL config | Ultra-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 Gateway | Header | TTL | Behavior khi trùng key |
|---|---|---|---|
| Stripe | Idempotency-Key | 24 giờ | Trả cached response, log warning |
| PayPal | PayPal-Request-Id | 72 giờ | Trả 200 + original response |
| Adyen | Idempotency-Key | 24 giờ | Trả cached response |
| VNPay | vnp_TxnRef (unique) | Vĩnh viễn | Reject 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ục | Chi tiết |
|---|---|---|
| 1 | Xác định endpoints cần idempotency | POST endpoints tạo resource, mutation endpoints có side effect |
| 2 | Chọn storage cho idempotency store | Redis cho high-throughput, SQL cho ACID guarantee |
| 3 | Implement request body hashing | SHA-256 của normalized request body |
| 4 | Thêm distributed lock | Ngăn race condition khi concurrent requests |
| 5 | Set TTL hợp lý | 24h standard, 72h payment, 7d async |
| 6 | Handle edge cases | Key conflict (khác body), lock timeout, store unavailable |
| 7 | Monitoring & alerting | Track duplicate hit rate, lock contention, store latency |
| 8 | Consumer-side dedup | Message 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:
Webhook Design Patterns — Thiết kế hệ thống event notification đáng tin cậy
Dapr — 13 Building Block giải quyết mọi bài toán Microservices phân tán
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.