Chuyển tới nội dung chính

14.10 — 9. Idempotency và Retry

Tóm tắt

Trong hệ phân tán, exactly-once không tồn tại — bạn chỉ chọn được giữa at-most-once (có thể mất) và at-least-once (có thể trùng). Vì mất dữ liệu thường tệ hơn trùng, gần như mọi hệ thống chọn at-least-once, và điều đó đẩy trách nhiệm sang bạn: mọi thao tác phải idempotent. Về retry, ba quy tắc quyết định: chỉ retry lỗi tạm thời (retry một lỗi 400 là vô nghĩa); phải có jitter, vì không có nó thì 1.000 client cùng retry ở giây thứ 2 và tạo ra đợt tải đồng bộ còn tệ hơn lần đầu; và cần circuit breaker, vì retry vào một dịch vụ đang sập chỉ làm nó sập lâu hơn.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Giải thích vì sao exactly-once không khả thi.
  • Cài idempotency bằng ba cách khác nhau.
  • Cấu hình retry có backoff và jitter.
  • Thêm circuit breaker đúng chỗ.
  • Nhận ra retry đang làm hệ thống tệ hơn.

Nội dung bài học​

14.10.1 — Vì sao exactly-once không tồn tại​

Client -----request----> Server
Server xử lý XONG
<----response--X Response BỊ MẤT
Client: không biết server đã xử lý chưa

Client chỉ có hai lựa chọn, và cả hai đều sai trong một trường hợp:

  • Không retry → at-most-once → mất nếu server chưa xử lý.
  • Retry → at-least-once → trùng nếu server đã xử lý.

Không có cách nào biết chắc, vì thông tin đó nằm trong response đã mất. Đây là giới hạn cơ bản của hệ phân tán, không phải vấn đề kỹ thuật chờ được giải.

Hệ quả: chọn at-least-once và làm mọi thứ idempotent. Đó là lý do bài này tồn tại.

14.10.2 — Idempotency là gì​

Thực hiện một lần hay nhiều lần đều cho cùng một trạng thái cuối.

// KHÔNG idempotent — chạy hai lần là cộng hai lần
balance += amount;

// Idempotent — chạy bao nhiêu lần cũng vậy
balance = newBalance;

Ba cách làm một thao tác idempotent:

1. Idempotency key — cho thao tác tạo mới:

public async Task<Result<Order>> CreateAsync(
CreateOrderRequest request, string idempotencyKey, CancellationToken ct)
{
var existing = await _db.IdempotencyRecords
.FirstOrDefaultAsync(r => r.Key == idempotencyKey, ct);

if (existing is not null)
return Result.Success(JsonSerializer.Deserialize<Order>(existing.Response)!);

var order = await _orders.CreateAsync(request, ct);

_db.IdempotencyRecords.Add(new IdempotencyRecord
{
Key = idempotencyKey,
Response = JsonSerializer.Serialize(order),
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
});

await _db.SaveChangesAsync(ct); // CÙNG transaction với tạo đơn hàng
return Result.Success(order);
}

Hai chi tiết: UNIQUE trên cột Key là hàng rào cuối cùng chống race condition, và bản ghi phải hết hạn để bảng không lớn vô hạn.

Mẫu này đã nói ở bài 9.2.

2. Kiểm tra trạng thái — cho thao tác chuyển trạng thái:

public async Task MarkAsPaidAsync(int orderId, CancellationToken ct)
{
var affected = await _db.Orders
.Where(o => o.Id == orderId && o.Status == OrderStatus.Pending) // DIEU KIEN
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, OrderStatus.Paid)
.SetProperty(o => o.PaidAt, DateTime.UtcNow), ct);

if (affected == 0)
_logger.LogInformation("Đơn hàng {Id} đã được thanh toán, bỏ qua", orderId);
}

Điều kiện trạng thái trong WHERE khiến lần chạy thứ hai không làm gì — và bạn biết điều đó qua số dòng bị ảnh hưởng.

3. Thao tác tự nhiên idempotent:

// Đặt giá trị — idempotent
customer.Status = CustomerStatus.Active;

// Thêm vào tập hợp — idempotent (Set, không phải List)
await db.SetAddAsync($"tags:{id}", "vip");

// Upsert — idempotent
await db.Customers.Upsert(customer).On(c => c.ExternalId).RunAsync(ct);

Thiết kế để thao tác tự nhiên idempotent luôn tốt hơn thêm một lớp kiểm tra.

14.10.3 — Chỉ retry lỗi tạm thời​

// Loi TAM THOI — retry co nghia
HttpRequestException // loi mang
TimeoutException
SqlException khi Number in [1205, 49918, 40501] // deadlock, throttling
HttpStatusCode.ServiceUnavailable // 503
HttpStatusCode.TooManyRequests // 429

// Loi VINH VIEN — retry VO NGHIA
HttpStatusCode.BadRequest // 400 — dữ liệu sai
HttpStatusCode.Unauthorized // 401 — token sai
HttpStatusCode.Forbidden // 403 — không đủ quyền
HttpStatusCode.NotFound // 404
ValidationException

Retry một lỗi 400 ba lần chỉ tốn ba lần thời gian để nhận cùng một câu trả lời — và làm nhiễu log, che mất lỗi thật.

// Polly qua Microsoft.Extensions.Http.Resilience
builder.Services.AddHttpClient<IPaymentGateway, PaymentGateway>()
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true; // BAT BUOC
options.Retry.Delay = TimeSpan.FromSeconds(1);

options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.MinimumThroughput = 10;
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);

options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30);
});

AddStandardResilienceHandler (.NET 8+) gom năm thứ: rate limiter, timeout tổng, retry, circuit breaker, timeout mỗi lần thử — theo đúng thứ tự hợp lý.

TotalRequestTimeout là chi tiết quan trọng: không có nó, 3 lần retry × 10 giây timeout cộng thời gian chờ có thể thành 45 giây — và request gốc đã timeout từ lâu.

14.10.4 — Jitter là bắt buộc​

1.000 client cùng gọi một API.
API tra 503.
Không có jitter:
Tất cả retry sau 1 giây -> 1.000 request ĐỒNG THỜI
Tất cả retry sau 2 giây -> 1.000 request ĐỒNG THỜI
=> Đợt tải đồng bộ, tệ hơn lần đầu

Đây là thundering herd, và nó biến một sự cố nhỏ thành sự cố kéo dài.

options.Retry.UseJitter = true;     // BAT BUOC

Với jitter, 1.000 client retry trải đều trong một khoảng, nên dịch vụ có cơ hội hồi phục.

Backoff mũ một mình không đủ — nó chỉ giãn khoảng cách chứ không phá tính đồng bộ. Cần cả hai.

Với hàng đợi và background job cũng vậy: DelaysInSeconds cố định của Hangfire khiến mọi job thất bại cùng lúc retry cùng lúc.

14.10.5 — Circuit breaker​

Dich vu thanh toan chet.
Không có circuit breaker:
Moi request cho 10 giay roi timeout
Thread pool can kiet
=> API CỦA BẠN cũng chết

Circuit breaker ngừng gọi sau một số lần thất bại:

Closed  -> goi binh thuong
Open -> từ chối NGAY, không gọi
Half-open -> thử MỘT request; thành công thì đóng lại
options.CircuitBreaker.FailureRatio = 0.5;              // 50% thất bại
options.CircuitBreaker.MinimumThroughput = 10; // toi thieu 10 request
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);

MinimumThroughput tránh mở mạch vì 2 request đầu tiên tình cờ thất bại.

Khi mạch mở, bạn cần phương án dự phòng:

try
{
return await _paymentGateway.ChargeAsync(request, ct);
}
catch (BrokenCircuitException)
{
await _queue.EnqueueAsync(new RetryPaymentLater(request), ct);
return PaymentResult.Pending("Đang xử lý, chúng tôi sẽ thông báo sau");
}

Trả Pending và xử lý sau tốt hơn nhiều so với báo lỗi — và nó đúng vì thao tác đã idempotent.

14.10.6 — Retry làm hệ thống tệ hơn​

Ba tình huống cần biết:

1. Retry chồng tầng.

Client retry 3 lan
-> API Gateway retry 3 lan
-> Service A retry 3 lan
-> Service B: 27 request cho MOT request goc

Mỗi tầng nhân lên. Quy tắc: chỉ retry ở một tầng — thường là tầng ngoài cùng hoặc tầng gọi trực tiếp dịch vụ hay lỗi, không phải cả hai.

2. Retry khi dịch vụ đang quá tải.

Dịch vụ trả 503 vì quá tải. Bạn retry, tăng tải, nó càng quá tải. Circuit breaker phá vòng này.

3. Retry sau khi client đã bỏ cuộc.

// SAI — retry du client da ngat ket noi
await _http.GetAsync(url);

// ĐÚNG — token của request được tôn trọng
await _http.GetAsync(url, ct);

Không truyền CancellationToken, bạn retry 3 lần cho một request mà không còn ai chờ (bài 6.5).

Luôn log mỗi lần retry — tỷ lệ retry tăng là tín hiệu sớm nhất của vấn đề:

options.Retry.OnRetry = args =>
{
_logger.LogWarning("Retry lan {Attempt} sau {Delay}ms: {Reason}",
args.AttemptNumber, args.RetryDelay.TotalMilliseconds,
args.Outcome.Exception?.Message);
return default;
};

Retry im lặng là retry bạn chỉ biết khi nó đã vượt giới hạn.

14.10.7 — Rà lại code của bạn​

Danh sách rà soát idempotency và retry

  • •Mọi consumer và job đều idempotent.
  • •Endpoint tạo tài nguyên liên quan tới tiền hỗ trợ Idempotency-Key.
  • •Bảng idempotency có ràng buộc UNIQUE và có cơ chế hết hạn.
  • •Chuyển trạng thái dùng điều kiện trong WHERE, không đọc rồi ghi.
  • •Chỉ retry lỗi tạm thời; lỗi 4xx không retry.
  • •Mọi retry đều có jitter.
  • •Có tổng timeout, không chỉ timeout mỗi lần thử.
  • •Có circuit breaker cho mọi dịch vụ ngoài.
  • •Circuit breaker có MinimumThroughput để không mở vì vài lỗi ngẫu nhiên.
  • •Có phương án dự phòng khi mạch mở.
  • •Chỉ retry ở một tầng, không chồng nhiều tầng.
  • •CancellationToken được truyền để không retry sau khi client bỏ cuộc.
  • •Mỗi lần retry đều được ghi log và có chỉ số theo dõi.

Bài tập áp dụng​

Bài 1 — Thundering herd và jitter​

Mô phỏng 500 client gọi một API trả 503, có và không có jitter. Vẽ biểu đồ số request theo thời gian ở mỗi trường hợp.

Tiêu chí hoàn thành: bạn giải thích được vì sao backoff theo cấp số nhân một mình không đủ, và chọn được kiểu jitter phù hợp.

Gợi ý và lời giải — Bài 1

Gợi ý. 500 client cùng thất bại tại t=0, rồi cùng chờ đúng 1 giây. Chuyện gì xảy ra lúc t=1?

Lời giải — mô phỏng 500 client, backoff nhân đôi từ 1 giây, 5 lần thử:

def mo_phong(jitter):
moc = []
for _ in range(500):
t = 0.0
for lan in range(5):
d = 1.0 * (2 ** lan) # 1, 2, 4, 8, 16 giây
if jitter == 'full':
d = random.uniform(0, d)
elif jitter == 'equal':
d = d / 2 + random.uniform(0, d / 2)
t += d
moc.append(t)
return moc
Chỉ tính các lần THỬ LẠI (bỏ lần gọi đầu), gom theo cửa sổ 0,25 giây:

Không jitter đỉnh 500 req/0,25s (2.000 req/s) trải trên 1,2s kết thúc 31,0s
Equal jitter đỉnh 257 req/0,25s (1.028 req/s) trải trên 24,2s kết thúc 29,7s
Full jitter đỉnh 193 req/0,25s (772 req/s) trải trên 27,2s kết thúc 27,8s

Nhìn vào vị trí các đỉnh khi không có jitter:

t =  0s   500 request
t = 1s 500 request <- đúng bằng đỉnh ban đầu
t = 3s 500 request
t = 7s 500 request
t = 15s 500 request
t = 31s 500 request

Sáu đợt sóng, mỗi đợt lớn bằng đợt đầu. Dịch vụ đang quá tải nhận đúng cùng một mức tải, lặp lại sáu lần, trong 31 giây.

Vì sao backoff theo cấp số nhân một mình không đủ. Backoff giải quyết bài toán "mỗi client gọi lại quá nhanh" — nó kéo giãn các lần thử của một client.

Nhưng bài toán thật là "mọi client gọi lại cùng lúc", và backoff tất định không đụng tới nó. Ngược lại, nó còn làm mọi client đồng bộ hoá với nhau: tất cả đều chờ đúng 1 giây, rồi đúng 2 giây, rồi đúng 4 giây.

Backoff giải quyết:  mỗi client gọi lại quá nhanh       (trục thời gian của một client)
Backoff KHÔNG giải: mọi client gọi lại cùng thời điểm (trục đồng bộ giữa các client)

Điều làm nó tệ hơn: sự cố ban đầu chính là thứ đồng bộ hoá các client. Trước sự cố, 500 client gọi rải rác ngẫu nhiên. Sau khi dịch vụ trả 503 cho tất cả trong cùng một khoảnh khắc, chúng được căn chỉnh vào cùng một đồng hồ — và giữ nguyên sự căn chỉnh đó qua mọi lần thử lại.

Hậu quả thực tế: dịch vụ vừa hồi phục lúc t=0,5s sẽ bị dội 500 request lúc t=1s và sập lại. Chu kỳ này có thể lặp nhiều lần, và mỗi lần hệ thống chỉ "gần như" hồi phục.

Ba kiểu jitter:

// Full jitter — ngẫu nhiên trong toàn khoảng [0, delay]
var delay = TimeSpan.FromSeconds(Random.Shared.NextDouble() * Math.Pow(2, lan));

// Equal jitter — nửa cố định, nửa ngẫu nhiên
var coBan = Math.Pow(2, lan);
var delay = TimeSpan.FromSeconds(coBan / 2 + Random.Shared.NextDouble() * coBan / 2);

// Decorrelated jitter — dựa trên độ trễ TRƯỚC ĐÓ, không dựa trên số lần thử
var delay = TimeSpan.FromSeconds(
Math.Min(maxDelay, Random.Shared.NextDouble() * (truocDo * 3 - coBan) + coBan));
KiểuĐỉnh tảiThời gian hoàn tấtDùng khi
Không jitter2.000 req/s31,0 sKhông bao giờ
Equal jitter1.028 req/s29,7 sMặc định tốt — vẫn giữ tính chất backoff
Full jitter772 req/s27,8 sTải rất cao, muốn phân tán tối đa
DecorrelatedThấp nhấtThay đổiNhiều client, cần thích nghi động

Equal jitter là lựa chọn mặc định tốt, vì nó giữ được điều quan trọng của backoff: độ trễ luôn tăng theo số lần thử. Với full jitter, lần thử thứ năm có thể rơi vào 0,1 giây — nhanh hơn cả lần thử đầu — nên một client xui xẻo có thể thử lại dồn dập dù đã thất bại nhiều lần.

Đổi lại, full jitter phân tán tốt hơn (772 so với 1.028 req/s) vì nó không có "sàn" cố định.

Trong .NET, dùng Polly, đừng tự viết:

services.AddHttpClient<IEmailClient, EmailClient>()
.AddResilienceHandler("email", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // <- một dòng, và nó là dòng quan trọng nhất
Delay = TimeSpan.FromSeconds(1),
});
});

UseJitter = true là mặc định nên bật ở mọi chính sách retry. Không có lý do chính đáng nào để tắt nó.

Và jitter không chỉ dành cho retry. Mọi lúc nhiều tiến trình làm cùng một việc theo cùng một lịch, bạn cần jitter:

// TTL cache — tránh mọi instance cùng hết hạn (bài 14.1)
var ttl = TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(Random.Shared.Next(0, 120));

// Job định kỳ — tránh mọi pod cùng chạy lúc đúng phút tròn
await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 30)), ct);

// Health check — tránh mọi pod cùng gọi database lúc 0 giây
services.AddHealthChecks().AddCheck<DbCheck>("db", tags: ["ready"]);

Đo trên production, đừng chỉ mô phỏng. Dấu hiệu thiếu jitter rất dễ nhận:

Biểu đồ request theo thời gian có các ĐỈNH ĐỀU ĐẶN cách nhau đúng
1s, 2s, 4s, 8s... sau mỗi lần có sự cố

Nếu bạn thấy hình răng cưa đều đặn như vậy sau một sự cố, có một chính sách retry ở đâu đó chưa bật jitter — và nó đang kéo dài mọi sự cố của bạn.


Bài 2 — Retry chồng tầng​

Dựng ba tầng service mỗi tầng retry 3 lần, và đếm số request thật sự tới tầng cuối cho một request gốc.

Tiêu chí hoàn thành: bạn tính được con số trước khi chạy, và nêu được nguyên tắc quyết định tầng nào được retry.

Gợi ý và lời giải — Bài 2

Gợi ý. Mỗi lần tầng A gọi B, B thử C mấy lần?

Lời giải — tính trước:

Tầng A gọi B:  1 lần đầu + 3 retry = 4 lần
Mỗi lần A gọi B, B gọi C: 4 lần
Mỗi lần B gọi C, C gọi database: 4 lần

Tổng số lần chạm database = 4 × 4 × 4 = 64

Một request của người dùng trở thành 64 truy vấn database.

Gateway  -> API      -> Service  -> Database
4× 4× 4×

1 request người dùng = 4 lời gọi API = 16 lời gọi Service = 64 truy vấn database

Con số nhân lên, không cộng lại — và đó là chỗ trực giác sai. Bốn tầng, mỗi tầng retry 3 lần, cho 256 truy vấn.

Thời gian cũng nhân lên:

Backoff 1s, 2s, 4s ở mỗi tầng
Tầng C: 1 + 2 + 4 = 7 giây cho một chuỗi
Tầng B: 4 chuỗi của C, cộng backoff riêng = khoảng 40 giây
Tầng A: 4 chuỗi của B = khoảng 3 phút

Người dùng đã đóng tab từ 2 phút 50 giây trước.

Và đây là phần tệ nhất: toàn bộ 64 truy vấn vẫn được thực hiện, tiêu tài nguyên cho một request mà không ai còn chờ kết quả.

Nguyên tắc quyết định tầng nào được retry:

Retry ở tầng gần nhất với nguyên nhân lỗi, và chỉ ở đúng một tầng trong mỗi chuỗi.

Cụ thể:

Gateway  -> KHÔNG retry (hoặc chỉ cho lỗi kết nối tới API, không cho lỗi nghiệp vụ)
API -> KHÔNG retry
Service -> KHÔNG retry
Database -> CÓ retry, cho lỗi thoáng qua của chính nó

Lý do: tầng trong cùng là tầng biết rõ nhất lỗi đó có đáng thử lại không. Tầng ngoài chỉ thấy "lời gọi thất bại" và không phân biệt được timeout mạng với vi phạm ràng buộc.

// Tầng trong cùng — biết rõ mình đang nói chuyện với ai
services.AddDbContext<CrmDbContext>(o =>
o.UseSqlServer(conn, sql => sql.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null))); // chỉ retry các mã lỗi thoáng qua đã biết
// Các tầng ngoài — không retry, chỉ truyền lỗi lên
services.AddHttpClient<IServiceClient, ServiceClient>()
.AddResilienceHandler("service", b =>
{
b.AddTimeout(TimeSpan.FromSeconds(10));
b.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { FailureRatio = 0.5 });
// KHÔNG có AddRetry
});

Nhưng "chỉ retry ở tầng trong cùng" đôi khi không đủ, vì tầng ngoài cũng gặp lỗi mạng thật giữa nó và tầng kế tiếp. Ba cách xử lý:

1. Ngân sách retry (retry budget) — giới hạn tổng số retry theo tỷ lệ lưu lượng:

// Chỉ cho phép retry tối đa 10% tổng số request
if (_demRetry.TyLe() > 0.10)
{
_logger.LogWarning("Đã vượt ngân sách retry, không thử lại");
throw;
}

Đây là cách các hệ thống lớn dùng, vì nó đặt trần cho tổng tải retry bất kể có bao nhiêu tầng.

2. Truyền hạn chót (deadline propagation) — mỗi tầng biết còn bao nhiêu thời gian:

var hanChot = context.Request.Headers["X-Deadline"];
var conLai = DateTime.Parse(hanChot) - DateTime.UtcNow;

if (conLai <= TimeSpan.Zero)
return Results.StatusCode(StatusCodes.Status504GatewayTimeout);

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(conLai);
await GoiTangTiepTheoAsync(cts.Token);
// Truyền tiếp xuống tầng dưới, đã trừ thời gian đã tiêu
request.Headers.Add("X-Deadline", DateTime.UtcNow.Add(conLai).ToString("O"));

Cách này giải quyết vấn đề triệt để hơn ngân sách: khi người dùng đã bỏ đi, không tầng nào còn thử lại, vì hạn chót đã qua. Nó biến 3 phút vô ích thành một lỗi trả về sau 10 giây.

3. Circuit breaker ở mỗi tầng — không ngăn chồng tầng, nhưng chặn nó sớm khi sự cố đã rõ:

b.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(15),
});

Sau vài chục lời gọi thất bại, mạch ngắt và 64 lời gọi trở thành 0.

Ba cách này bổ sung nhau, và một hệ thống nhiều tầng nên có cả ba: hạn chót cắt theo thời gian, ngân sách cắt theo khối lượng, circuit breaker cắt theo tỷ lệ lỗi.

Và trước hết, hãy đếm xem bạn đang có bao nhiêu tầng retry:

grep -rn "AddRetry\|WaitAndRetry\|EnableRetryOnFailure\|UseMessageRetry\|AutomaticRetry" \
--include="*.cs" src/

Mỗi kết quả là một hệ số nhân. Vẽ chúng lên một sơ đồ theo đường đi của request, nhân các hệ số lại, và bạn sẽ có con số thật — nó thường lớn hơn nhiều so với dự đoán của cả nhóm.

Một cảnh báo đặc biệt về EnableRetryOnFailure của EF Core: nó im lặng và dễ quên. Nhiều nhóm bật nó theo mẫu có sẵn trong tài liệu, rồi thêm Polly ở tầng HTTP mà không nhớ rằng database cũng đang retry. Với ba tầng HTTP mỗi tầng 3 lần và EF Core 3 lần, con số là 4 × 4 × 4 × 4 = 256.

Nó còn có một tác dụng phụ ít ai biết: khi bật retry, EF Core không cho bạn dùng transaction do người dùng khởi tạo theo cách thông thường, và bắt bạn phải bọc trong một execution strategy:

var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
// ...
await tx.CommitAsync(ct);
});

Nếu bạn chưa bao giờ viết đoạn này mà đã bật EnableRetryOnFailure, hãy kiểm tra lại — có thể có chỗ đang ném exception lúc chạy mà chưa ai gặp.


Bài 3 — Idempotency key​

Cài endpoint tạo đơn hàng với Idempotency-Key, bắn 10 request đồng thời với cùng một key, và xác nhận chỉ một đơn hàng được tạo.

Tiêu chí hoàn thành: bạn nêu được vì sao kiểm tra "đã tồn tại chưa" bằng SELECT rồi INSERT không đủ, và thiết kế được hành vi cho request thứ hai.

Gợi ý và lời giải — Bài 3

Gợi ý. Mười request đồng thời cùng chạy SELECT. Tất cả thấy gì?

Lời giải — bản có lỗi:

app.MapPost("/don-hang", async (TaoDonRequest req, HttpContext http,
CrmDbContext db, CancellationToken ct) =>
{
var key = http.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(key)) return Results.BadRequest("Thiếu Idempotency-Key");

// KIỂM TRA
if (await db.DonHang.AnyAsync(d => d.IdempotencyKey == key, ct))
return Results.Ok(await db.DonHang.FirstAsync(d => d.IdempotencyKey == key, ct));

// rồi HÀNH ĐỘNG — có khe hở ở giữa
var don = new DonHang { IdempotencyKey = key, TongTien = req.TongTien };
db.DonHang.Add(don);
await db.SaveChangesAsync(ct);
return Results.Created($"/don-hang/{don.Id}", don);
});
for i in $(seq 1 10); do
curl -s -X POST http://localhost:5000/don-hang \
-H "Idempotency-Key: abc-123" \
-d '{"tongTien": 5000000}' &
done; wait
SELECT COUNT(*) FROM DonHang WHERE IdempotencyKey = 'abc-123';
-- 7

Bảy đơn hàng, và khách hàng bị trừ tiền bảy lần.

Vì sao SELECT rồi INSERT không đủ. Đây lại là mẫu kiểm-tra-rồi-hành-động ở bài 13.8, lần này ở tầng HTTP:

t=0,000  Request 1..10 cùng chạy SELECT -> tất cả thấy "chưa tồn tại"
t=0,010 Request 1..10 cùng chạy INSERT -> tất cả thành công

Kết quả của SELECT là một ảnh chụp đã cũ tại thời điểm INSERT. Và khoảng cách giữa hai lệnh không cần lớn — vài mili giây là đủ cho mười request đồng thời chen vào.

Nâng mức cô lập transaction lên Serializable cũng không giúp, vì không có dòng nào để khoá — bạn đang khoá sự vắng mặt của một dòng, và điều đó cần khoá phạm vi (range lock), vốn dễ gây deadlock.

Bản sửa — unique constraint, để database quyết định:

builder.Entity<DonHang>().HasIndex(d => d.IdempotencyKey).IsUnique();
app.MapPost("/don-hang", async (TaoDonRequest req, HttpContext http,
CrmDbContext db, CancellationToken ct) =>
{
var key = http.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(key)) return Results.BadRequest("Thiếu Idempotency-Key");

var don = new DonHang { IdempotencyKey = key, TongTien = req.TongTien };
db.DonHang.Add(don);

try
{
await db.SaveChangesAsync(ct);
return Results.Created($"/don-hang/{don.Id}", don);
}
catch (DbUpdateException ex) when (LaViPhamUnique(ex))
{
// Ai đó đã tạo với key này — trả về đơn hàng của họ
var daCo = await db.DonHang.AsNoTracking()
.FirstAsync(d => d.IdempotencyKey == key, ct);
return Results.Ok(daCo);
}
});
10 request đồng thời -> 1 đơn hàng, 9 request nhận lại chính đơn đó

Phép kiểm tra được đẩy xuống database, nơi nó nguyên tử. Unique index bảo đảm đúng một INSERT thành công, bất kể bao nhiêu luồng cùng thử — và đó là bảo đảm mà không mức cô lập nào ở tầng ứng dụng cho được.

Thiết kế hành vi cho request thứ hai — ba câu hỏi:

1. Trả mã gì?

201 Created    cho request ĐẦU TIÊN
200 OK cho các request sau, kèm kết quả đã tạo

Đừng trả 409 Conflict: từ góc nhìn của client, nó không xung đột với ai cả — nó chỉ đang thử lại chính request của mình, và nó muốn biết kết quả. Trả 409 buộc client phải xử lý một nhánh lỗi cho một tình huống thành công.

2. Nếu request đầu tiên vẫn đang chạy thì sao? Đây là trường hợp khó nhất, và cách xử lý đúng là ghi nhận trạng thái "đang xử lý":

public class BanGhiIdempotency
{
public string Khoa { get; set; } = null!; // unique
public string TrangThai { get; set; } = null!; // DangXuLy | HoanTat | ThatBai
public string? KetQuaJson { get; set; }
public DateTime TaoLuc { get; set; }
public string HashRequest { get; set; } = null!;
}
if (banGhi.TrangThai == "DangXuLy")
{
http.Response.Headers.RetryAfter = "1";
return Results.StatusCode(StatusCodes.Status409Conflict);
}

Ở đây 409 là đúng, vì client thật sự cần thử lại sau. Kèm Retry-After để client biết chờ bao lâu.

3. Nếu cùng key nhưng body khác nhau thì sao? Đây là lỗi của client, và phải báo rõ:

var hash = Convert.ToHexString(SHA256.HashData(
JsonSerializer.SerializeToUtf8Bytes(req)));

if (banGhi.HashRequest != hash)
return Results.BadRequest(new ProblemDetails
{
Title = "Idempotency-Key đã được dùng với nội dung khác",
Detail = "Mỗi Idempotency-Key chỉ dùng cho đúng một nội dung request.",
});

Không có kiểm tra này, một client sinh key sai — ví dụ dùng lại key cho mọi request — sẽ nhận về kết quả của một đơn hàng hoàn toàn khác, và tin rằng đơn mới đã được tạo.

Hai chi tiết vận hành:

1. Key phải có hạn. Không dọn thì bảng lớn vô hạn:

DELETE FROM BanGhiIdempotency WHERE TaoLuc < DATEADD(DAY, -1, SYSUTCDATETIME());

24 giờ là mốc thường dùng (Stripe dùng đúng con số này), và nó dài hơn mọi chuỗi retry hợp lý.

2. Client phải sinh key, không phải server. Và key phải ổn định qua các lần thử lại của cùng một thao tác:

// Đúng — sinh một lần, dùng cho mọi lần thử lại
var key = Guid.NewGuid().ToString();
for (var lan = 0; lan < 3; lan++)
{
try { return await GoiApiAsync(req, key, ct); }
catch (HttpRequestException) { await Task.Delay(1000 * (lan + 1), ct); }
}

// Sai — key mới mỗi lần thử -> mỗi lần thử tạo một đơn hàng
for (var lan = 0; lan < 3; lan++)
try { return await GoiApiAsync(req, Guid.NewGuid().ToString(), ct); } catch { }

Đoạn sai ở dưới là lỗi phổ biến nhất khi tích hợp, và nó vô hiệu hoá hoàn toàn cơ chế idempotency trong khi trông như đang dùng nó đúng cách.

Đưa vào middleware để mọi endpoint ghi được hưởng:

app.UseMiddleware<IdempotencyMiddleware>();
public class IdempotencyMiddleware
{
private static readonly string[] PhuongThucCanKhoa = ["POST", "PUT", "PATCH"];

public async Task InvokeAsync(HttpContext ctx, CrmDbContext db)
{
if (!PhuongThucCanKhoa.Contains(ctx.Request.Method))
{
await _next(ctx);
return;
}
// ... kiểm tra key, ghi bản ghi, chạy _next, lưu kết quả
}
}

Và một test đồng thời thật — đây là loại lỗi mà chỉ đồng thời thật mới phát hiện:

[Fact]
public async Task Muoi_request_cung_key_chi_tao_mot_don_hang()
{
var key = Guid.NewGuid().ToString();

var ketQua = await Task.WhenAll(Enumerable.Range(0, 10).Select(_ =>
{
var req = new HttpRequestMessage(HttpMethod.Post, "/don-hang")
{
Content = JsonContent.Create(new { tongTien = 5_000_000 }),
};
req.Headers.Add("Idempotency-Key", key);
return _client.SendAsync(req);
}));

ketQua.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1);
ketQua.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(9);

(await DemDonHangAsync(key)).Should().Be(1);
}

Test này cần database thật — in-memory provider không thực thi unique index theo cách có khoá, nên nó sẽ cho test xanh ngay cả với bản có lỗi ở đầu bài.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao exactly-once không tồn tại?

Khi response bị mất, client không biết server đã xử lý hay chưa. Không retry thì mất nếu server chưa xử lý; retry thì trùng nếu server đã xử lý. Thông tin cần thiết nằm trong response đã mất, nên đây là giới hạn cơ bản của hệ phân tán chứ không phải vấn đề kỹ thuật chờ được giải.

Ba cách làm một thao tác idempotent là gì?

Idempotency key cho thao tác tạo mới, lưu khoá cùng kết quả và trả lại kết quả cũ khi thấy khoá đã tồn tại. Kiểm tra trạng thái trong mệnh đề WHERE cho thao tác chuyển trạng thái. Và thiết kế thao tác tự nhiên idempotent như đặt giá trị, thêm vào tập hợp, hay upsert.

Những lỗi nào không nên retry?

Lỗi vĩnh viễn: 400 dữ liệu sai, 401 token sai, 403 không đủ quyền, 404 không tồn tại, và lỗi validation. Retry chúng chỉ tốn thời gian để nhận cùng câu trả lời, làm nhiễu log và che mất lỗi thật.

Vì sao jitter là bắt buộc?

Không có nó, mọi client cùng retry ở đúng cùng một thời điểm, tạo đợt tải đồng bộ còn tệ hơn lần đầu và biến sự cố nhỏ thành sự cố kéo dài. Backoff mũ một mình chỉ giãn khoảng cách chứ không phá tính đồng bộ, nên cần cả hai.

Circuit breaker giải quyết gì?

Khi một dịch vụ ngoài chết, mọi request chờ tới timeout và làm cạn thread pool, khiến API của bạn cũng chết. Circuit breaker ngừng gọi sau một tỷ lệ thất bại, từ chối ngay thay vì chờ, và thử lại một request sau một khoảng để biết dịch vụ đã hồi phục chưa.

Retry chồng tầng gây ra gì?

Mỗi tầng nhân số request lên, nên ba tầng mỗi tầng retry ba lần thành 27 request tới tầng cuối cho một request gốc. Quy tắc là chỉ retry ở một tầng, thường là tầng gọi trực tiếp dịch vụ hay lỗi.

Kết luận​

Ba điều đáng nhớ nhất:

  1. At-least-once là mặc định. Mọi thao tác phải idempotent.
  2. Jitter là bắt buộc — không có nó, retry tạo đợt tải đồng bộ tệ hơn lần đầu.
  3. Chỉ retry ở một tầng. Chồng tầng nhân số request lên theo cấp số nhân.

Tham khảo​

Điều hướng​