9.12 - Ví dụ thực tế nhanh
Tóm tắt: Một vấn đề kinh điển của mọi API có thao tác ghi: người dùng bấm nút "Tạo đơn" hai lần — hoặc mạng chậm nên họ bấm lại, hoặc trình duyệt tự gửi lại request khi kết nối chập chờn — và hệ thống tạo hai đơn hàng. Cách chữa hay gặp nhất là vô hiệu hoá nút ở frontend, và nó không đủ: nó không xử lý được việc request bị gửi lại ở tầng mạng, không giúp gì khi client là một hệ thống khác, và người dùng vẫn mở được hai tab. Giải pháp đúng nằm ở backend: chuẩn
Idempotency-Key, cùng một cơ chế mà các cổng thanh toán lớn dùng.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích vì sao chống trùng ở frontend không đủ.
- Cài đặt
Idempotency-Keyđúng cách. - Xử lý trường hợp hai request trùng tới cùng lúc.
- Biết endpoint nào cần và không cần idempotency.
Nội dung bài học
9.12.1 — Vấn đề
10:02:31 POST /api/orders { items: [...], total: 5.000.000 }
10:02:31 -> Server bắt đầu xử lý (gọi cổng thanh toán, tốn 4 giây)
10:02:34 Người dùng sốt ruột, bấm lại
10:02:34 POST /api/orders { items: [...], total: 5.000.000 } <- request THU HAI
10:02:35 -> Đơn hàng #1 tạo xong
10:02:39 -> Đơn hàng #2 tạo xong
Kết quả: 2 đơn hàng, 2 lần trừ tiềnBốn nguyên nhân khiến request bị gửi hai lần, và chỉ nguyên nhân đầu sửa được ở frontend:
| Nguyên nhân | Frontend chặn được? |
|---|---|
| Người dùng bấm hai lần | Có |
| Trình duyệt tự gửi lại khi mạng chập chờn | Không |
| Người dùng mở hai tab | Không |
| Client là hệ thống khác có retry tự động | Không |
Vì vậy chống trùng phải nằm ở backend.
9.12.2 — Idempotency-Key
Client sinh một khoá duy nhất cho mỗi ý định, gửi kèm request:
POST /api/orders
Idempotency-Key: 8f3a1b2c-4d5e-6f70-8192-a3b4c5d6e7f8
Content-Type: application/json
{ "items": [...], "total": 5000000 }Bấm lại nút không sinh khoá mới — client dùng lại khoá của lần đầu. Server thấy khoá đã xử lý thì trả về kết quả cũ thay vì tạo đơn mới.
Request #1 với key K -> Xử lý -> Lưu kết quả -> Trả 201 + đơn hàng #1
Request #2 với key K -> Thấy K đã có -> Trả 201 + đơn hàng #1 (KẾT QUẢ CŨ)Điểm quan trọng: request thứ hai trả về thành công với kết quả cũ, không phải lỗi. Với client, hai lần gọi trông như nhau — đó chính là ý nghĩa của idempotent.
9.12.3 — Cài đặt
public sealed class IdempotencyFilter(AppDbContext db) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
var key = context.HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(key))
{
context.Result = new BadRequestObjectResult(
new ProblemDetails { Title = "Thiếu header Idempotency-Key" });
return;
}
var ct = context.HttpContext.RequestAborted;
// Đã xử lý chưa?
var daXuLy = await db.IdempotencyRecords
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Key == key, ct);
if (daXuLy is not null)
{
context.HttpContext.Response.StatusCode = daXuLy.StatusCode;
context.Result = new ContentResult
{
Content = daXuLy.ResponseBody,
ContentType = "application/json",
StatusCode = daXuLy.StatusCode
};
return; // KHÔNG chạy action
}
var executed = await next(); // chay action that
// Lưu kết quả — UNIQUE INDEX trên Key
if (executed.Result is ObjectResult { StatusCode: >= 200 and < 300 } result)
{
db.IdempotencyRecords.Add(new IdempotencyRecord
{
Key = key,
StatusCode = result.StatusCode!.Value,
ResponseBody = JsonSerializer.Serialize(result.Value),
CreatedUtc = DateTime.UtcNow
});
try { await db.SaveChangesAsync(ct); }
catch (DbUpdateException ex) when (ex.IsUniqueViolation())
{
// Request trùng đã lưu trước — không sao
}
}
}
}[HttpPost]
[ServiceFilter(typeof(IdempotencyFilter))]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)9.12.4 — Cái bẫy: hai request cùng lúc
Đoạn code trên vẫn có một khoảng hở:
t=0ms Request #1: kiểm tra key -> chưa có
t=1ms Request #2: kiểm tra key -> chưa có <- CẢ HAI đều thấy chưa có
t=2ms Request #1: tạo đơn hàng
t=3ms Request #2: tạo đơn hàng <- VẪN TRÙNGĐây đúng là race condition của mẫu "kiểm tra trước rồi ghi" (bài 17.2).
Sửa: giành chỗ trước khi xử lý.
// Ghi bản ghi "đang xử lý" NGAY, trước khi chạy action
try
{
db.IdempotencyRecords.Add(new IdempotencyRecord
{
Key = key,
Status = IdempotencyStatus.InProgress,
CreatedUtc = DateTime.UtcNow
});
await db.SaveChangesAsync(ct); // UNIQUE INDEX chan request thu hai
}
catch (DbUpdateException ex) when (ex.IsUniqueViolation())
{
// Request khác đang xử lý cùng key
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "Yêu cầu đang được xử lý",
Detail = "Một yêu cầu với cùng Idempotency-Key đang được xử lý. Vui lòng thử lại sau."
}) { StatusCode = StatusCodes.Status409Conflict };
return;
}
var executed = await next();
// ... cập nhật bản ghi thành Completed kèm kết quảUnique constraint của database là thứ duy nhất đảm bảo tính nguyên tử ở đây — một câu if luôn có khoảng hở.
Trả 409 cho request thứ hai là hợp lý: nó nói "yêu cầu của bạn đang được xử lý", và client retry sau vài giây sẽ nhận được kết quả đã lưu.
9.12.5 — Endpoint nào cần
| Method | Idempotent sẵn? | Cần Idempotency-Key? |
|---|---|---|
GET | Có | Không |
PUT | Có (ghi đè toàn bộ) | Không |
DELETE | Có (xoá lần hai không đổi gì) | Không |
PATCH | Tuỳ | Nếu là phép cộng dồn |
POST | Không | Có, nếu tạo thứ tốn kém |
PATCH phụ thuộc nội dung: { "status": "Won" } là idempotent (đặt giá trị), còn { "addPoints": 100 } thì không (cộng dồn).
Không phải mọi POST đều cần. Áp dụng cho thao tác mà việc lặp lại tốn kém hoặc không đảo ngược được:
CẦN: tạo đơn hàng, thanh toán, gửi email hàng loạt, tạo hoá đơn
KHÔNG CẦN: tìm kiếm bằng POST, ghi log, tính toán không lưu trữBắt buộc Idempotency-Key cho mọi endpoint là thêm gánh nặng cho client mà không đổi lại được gì.
9.12.6 — Dọn bảng
DELETE TOP (5000) FROM IdempotencyRecords
WHERE CreatedUtc < DATEADD(hour, -24, SYSUTCDATETIME());Giữ 24 giờ là đủ: retry hợp lệ xảy ra trong vòng vài giây tới vài phút. Giữ lâu hơn chỉ làm bảng phình to.
Xoá theo lô để không khoá bảng, và chạy định kỳ bằng job nền (bài 14.6).
9.12.7 — Rà lại code của bạn
Danh sách rà soát idempotency
- Không dựa vào frontend để chống trùng thao tác quan trọng.
- Endpoint POST tạo thứ tốn kém đều yêu cầu Idempotency-Key.
- Client dùng lại cùng khoá khi retry, không sinh khoá mới.
- Bản ghi idempotency được ghi trước khi xử lý để giành chỗ.
- Chống trùng dựa vào unique constraint, không dựa vào câu if.
- Request trùng trả về kết quả cũ, không trả lỗi.
- Request đang xử lý trùng khoá trả 409 với thông điệp rõ ràng.
- Bảng idempotency có job dọn dữ liệu cũ.
- Không bắt buộc Idempotency-Key cho endpoint không cần.
Bài tập áp dụng
Bài 1 — Tái hiện đơn hàng trùng
Gửi hai request POST giống hệt cách nhau 100ms và đếm số bản ghi tạo ra.
Tiêu chí hoàn thành: bạn nêu được vì sao client đúng vẫn tạo ra request thứ hai — chứ không phải "người dùng bấm nhầm".
Gợi ý và lời giải — Bài 1
Gợi ý. Đặt câu hỏi ngược lại: một client bị mất kết nối giữa chừng có cách nào biết server đã xử lý hay chưa không?
Lời giải — tái hiện:
BODY='{"customerId":"8f3a1b2c-0000-0000-0000-000000000001","lines":[{"productId":"...","quantity":2}]}'
curl -s -X POST https://localhost:5001/api/v1/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$BODY" &
sleep 0.1
curl -s -X POST https://localhost:5001/api/v1/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$BODY" &
waitSELECT COUNT(*) FROM Orders WHERE CustomerId = '8f3a1b2c-...' AND CreatedUtc > DATEADD(minute,-1,SYSUTCDATETIME());
-- 2Hai đơn hàng, hai lần trừ tiền.
Câu trả lời cho tiêu chí — client không có cách nào biết. Khi kết nối đứt sau lúc server đã xử lý nhưng trước lúc response về tới nơi, client thấy đúng một thứ: timeout. Từ phía nó, ba tình huống sau hoàn toàn giống nhau:
Server chưa nhận request -> phải gửi lại
Server đang xử lý dở, rồi crash -> phải gửi lại
Server xử lý XONG, response mất -> KHÔNG được gửi lạiKhông có thông tin nào trong tay client phân biệt được ba trường hợp đó. Nên dù client viết đúng đến mấy, nó chỉ còn hai lựa chọn: gửi lại (rủi ro trùng) hoặc không gửi lại (rủi ro mất đơn). Cả hai đều sai trong một nửa số trường hợp.
Bốn nguồn sinh ra request trùng, và chỉ một liên quan tới người dùng:
| Nguồn | Có phải lỗi người dùng? |
|---|---|
| Người dùng sốt ruột bấm lại | Có |
| Client retry tự động sau timeout | Không — đây là hành vi đúng |
| Load balancer retry khi upstream không phản hồi | Không |
| Người dùng bấm nút Back rồi gửi lại form | Không hẳn |
Vô hiệu hoá nút bấm ở giao diện chỉ chặn được dòng đầu tiên. Ba dòng còn lại nằm ngoài tầm với của frontend, và chúng mới là phần lớn.
Vì sao POST không tự idempotent. GET, PUT, DELETE đều mô tả một trạng thái đích: gọi lại cho cùng kết quả. POST mô tả một hành động: "tạo thêm một đơn hàng". Gọi hai lần nghĩa là hai đơn — về mặt ngữ nghĩa HTTP thì server đang làm đúng.
Vì vậy tính idempotent phải do bạn thêm vào, và phải dựa trên một khoá do client sinh — thứ duy nhất giống nhau giữa hai lần gửi. Xem bài 2.
Bài 2 — Cài Idempotency-Key và kiểm chứng
Thêm filter với ghi trước khi xử lý, chạy lại bài 1 và xác nhận chỉ tạo một bản ghi.
Tiêu chí hoàn thành: request thứ hai nhận đúng response của request thứ nhất, không phải một response mới tình cờ giống.
Gợi ý và lời giải — Bài 2
Gợi ý. Để phân biệt "trả lại kết quả cũ" với "xử lý lại rồi tình cờ ra kết quả giống", hãy so id của bản ghi trong hai response.
Lời giải — chạy lại bài 1 với khoá:
KEY=$(uuidgen)
for i in 1 2; do
curl -s -X POST https://localhost:5001/api/v1/orders \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" -d "$BODY" | jq -r '.id'
sleep 0.1
donec4f1a9d2-7b3e-4a55-9f01-2d6e8b4c1a77
c4f1a9d2-7b3e-4a55-9f01-2d6e8b4c1a77 <- CÙNG idSELECT COUNT(*) FROM Orders WHERE CustomerId = '8f3a1b2c-...';
-- 1Cùng id chính là bằng chứng cần cho tiêu chí. Nếu filter xử lý lại rồi tạo đơn mới, id sẽ khác — dù mọi trường còn lại giống hệt.
Ba điều bắt buộc trong bản ghi idempotency, và vì sao:
public sealed class IdempotencyRecord
{
public string Key { get; init; } = null!; // UNIQUE INDEX
public string RequestHash { get; init; } = null!; // 1
public IdempotencyStatus Status { get; set; } // 2
public string? ResponseBody { get; set; } // 3
public int? ResponseStatusCode { get; set; }
public DateTime CreatedUtc { get; init; }
}1. RequestHash — chống dùng lại khoá cho nội dung khác. Client sinh khoá một lần rồi vô tình gửi kèm body khác là một lỗi thật sự cần báo:
if (stored.RequestHash != currentHash)
{
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Idempotency-Key đã được dùng cho một nội dung khác"
}) { StatusCode = 422 };
return;
}Không có kiểm tra này, người dùng gửi đơn thứ hai với khoá cũ sẽ lặng lẽ nhận lại đơn cũ và tin rằng đơn mới đã được tạo.
2. Status — phân biệt "đang chạy" với "đã xong". Cần cho bài 3.
3. ResponseBody — trả lại đúng cái cũ. Chỉ chặn không tạo bản ghi trùng là chưa đủ; client cần nhận được id để đi tiếp.
Khoá phải do client sinh, và phải bền qua lần retry:
// SAI — mỗi lần retry một khoá mới, không chặn được gì
async function createOrder(body) {
return post('/orders', body, { 'Idempotency-Key': crypto.randomUUID() });
}
// ĐÚNG — sinh một lần cho mỗi ý định của người dùng, dùng lại khi retry
async function createOrder(body) {
const key = crypto.randomUUID();
return retry(() => post('/orders', body, { 'Idempotency-Key': key }));
}Đây là chỗ sai phổ biến nhất khi cài phía client, và server không có cách nào phát hiện.
Bài 3 — Hai mươi request song song cùng một khoá
Gửi 20 request song song cùng một Idempotency-Key và đếm số 201, số 409.
Tiêu chí hoàn thành: tổng số 201 cộng 409 bằng 20, số 201 bằng đúng 1, và bảng Orders có đúng 1 dòng.
Gợi ý và lời giải — Bài 3
Gợi ý. Nếu kết quả ra 2 hay 3 bản ghi, gần như chắc chắn code đang kiểm tra bằng if rồi mới ghi. Chỉ ràng buộc UNIQUE của database mới đóng được khoảng hở đó.
Lời giải — thử tải song song:
KEY=$(uuidgen)
seq 1 20 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
-X POST https://localhost:5001/api/v1/orders \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" -d "$BODY" | sort | uniq -cVới bản chỉ kiểm tra bằng if:
3 201
17 409SELECT COUNT(*) FROM Orders WHERE CustomerId = '8f3a1b2c-...';
-- 3Ba đơn hàng. Ba request cùng chạy AnyAsync trước khi bất kỳ ai kịp ghi, nên cả ba đều thấy "chưa có".
Với bản giành chỗ bằng UNIQUE INDEX:
1 201
19 409-- 1Đạt cả ba tiêu chí.
Vì sao ràng buộc database là thứ duy nhất đủ. Giữa AnyAsync và Add luôn có một khoảng thời gian, dù nhỏ tới đâu. Mọi cách "kiểm tra rồi ghi" ở tầng ứng dụng đều chỉ thu hẹp khoảng đó chứ không xoá được — đúng lập luận ở bài 17.2. Ràng buộc UNIQUE thì được kiểm tra nguyên tử bên trong lệnh ghi.
Index bắt buộc:
CREATE UNIQUE INDEX UX_IdempotencyRecords_Key ON IdempotencyRecords(Key);Thiếu nó thì SaveChangesAsync không ném gì cả, và bạn quay lại kịch bản ba đơn hàng.
Hai điều cần hoàn thiện trước khi dùng thật:
Bản ghi
InProgressbị kẹt. Nếu tiến trình chết giữa chừng, khoá nằm mãi ởInProgressvà mọi lần retry đều nhận409vĩnh viễn. Cần một job giải phóng:sqlUPDATE IdempotencyRecords SET Status = 'Failed' WHERE Status = 'InProgress' AND CreatedUtc < DATEADD(minute, -5, SYSUTCDATETIME());409nên kèmRetry-After. Client cần biết chờ bao lâu thay vì quay vòng liên tục:csharpcontext.HttpContext.Response.Headers.RetryAfter = "2";
Một lựa chọn thiết kế đáng cân nhắc. Thay vì 409, có hệ thống cho request thứ hai chờ bản ghi chuyển sang Completed rồi trả luôn kết quả. Trải nghiệm mượt hơn, nhưng đổi lại là giữ kết nối và thread trong lúc chờ — chính là kiểu tải sinh ra thread pool starvation ở bài 6.11. Với API công khai, 409 kèm Retry-After an toàn hơn.
Tự kiểm tra
Vì sao chống trùng ở frontend không đủ?
Vì nó chỉ chặn được việc người dùng bấm hai lần. Nó không xử lý được việc trình duyệt tự gửi lại khi mạng chập chờn, người dùng mở hai tab, hay client là một hệ thống khác có retry tự động.
Idempotency-Key hoạt động thế nào?
Client sinh một khoá duy nhất cho mỗi ý định và dùng lại đúng khoá đó khi retry. Server thấy khoá đã xử lý thì trả về kết quả cũ thay vì tạo mới, nên với client hai lần gọi trông như nhau.
Vì sao request trùng trả về thành công chứ không phải lỗi?
Vì mục đích là để client không phân biệt được giữa gọi một lần và gọi nhiều lần. Trả lỗi buộc client phải xử lý thêm một nhánh, và trong nhiều trường hợp client cũng không biết phải làm gì với lỗi đó.
Cái bẫy race condition trong cài đặt đơn giản là gì?
Hai request cùng kiểm tra và cùng thấy khoá chưa tồn tại, rồi cả hai cùng xử lý. Cách sửa là ghi bản ghi đang xử lý ngay từ đầu để giành chỗ, và để unique constraint của database chặn request thứ hai.
PATCH có idempotent không?
Tuỳ nội dung. Đặt trạng thái thành một giá trị cụ thể là idempotent, còn cộng thêm một lượng vào giá trị hiện tại thì không, vì gọi hai lần cho kết quả khác gọi một lần.
Có nên bắt buộc Idempotency-Key cho mọi endpoint POST không?
Không. Chỉ áp dụng cho thao tác mà việc lặp lại tốn kém hoặc không đảo ngược được, như tạo đơn hàng hay thanh toán. Bắt buộc cho mọi endpoint là thêm gánh nặng cho client mà không đổi lại được gì.
Kết luận
Ba điều đáng nhớ nhất:
- Chống trùng phải ở backend — frontend chỉ chặn được một trong bốn nguyên nhân.
- Ghi bản ghi trước khi xử lý để giành chỗ, và để unique constraint quyết định.
- Request trùng trả kết quả cũ, không trả lỗi.
Tham khảo
Điều hướng
- Bài trước: 9.11 — Mini case study
- Bài tiếp theo: 9.13 — Review and Assessment
- Về module: Trang mục lục