Độ tin cậy Nâng cao 5 phút đọc

Idempotency và transactional outbox trong .NET

Cách làm cho HTTP endpoint và queue consumer idempotent, và pattern outbox đảm bảo write database và publish queue luôn nhất quán.

Mục lục
  1. Khi nào idempotency hết tuỳ chọn?
  2. Endpoint HTTP idempotent trong .NET trông ra sao?
  3. Outbox pattern đầu cuối trông thế nào?
  4. Cấu hình .NET 10 cho outbox?
  5. Combo này tạo failure mode nào?
  6. Khi nào nên bỏ qua hai pattern này?
  7. Đi tiếp đâu từ đây?

Bug đắt nhất trong service .NET là write trùng không ai bắt được: trừ tiền hai lần, gửi hai email "đã giao", giảm inventory dưới 0. Mọi cái đều giải bằng cùng hai pattern - idempotency ở biên, outbox ở giữa - và chương này hướng dẫn cấu hình cả hai vào ASP.NET Core theo cách bạn copy lên production ngày mai.

Khi nào idempotency hết tuỳ chọn?

Ba tín hiệu.

Thao tác có side effect bên ngoài. Trừ tiền, gửi email, gọi API partner. Retry của user khi nhận 500 có thể đã thành công; làm hai lần thì side effect xảy ra hai lần.

Thao tác chạy sau queue. Queue giao at-least-once (chương 6). Consumer của bạn sẽ thấy duplicate sớm muộn - có lẽ tuần này.

Thao tác có nhất quán cross-service. Một write database thành công phải kèm publish event - nếu service crash giữa, hệ không nhất quán. Outbox là câu trả lời.

Nếu không cái nào áp dụng (endpoint chỉ đọc, write một DB trong một transaction), bạn không cần chương này.

Endpoint HTTP idempotent trong .NET trông ra sao?

Ba phần: client gửi UUID mỗi request, server check trước khi áp dụng, check là database insert với unique constraint.

public record IdempotencyKey(Guid Key) { }

public class IdempotencyMiddleware(AppDbContext db) : IMiddleware
{
    public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
    {
        if (!ctx.Request.Headers.TryGetValue("Idempotency-Key", out var keyStr)
            || !Guid.TryParse(keyStr, out var key))
        {
            await next(ctx);
            return;
        }

        // Thử insert - unique constraint là cổng.
        var entry = new IdempotencyRecord { Key = key, CreatedAt = DateTimeOffset.UtcNow };
        try
        {
            db.IdempotencyRecords.Add(entry);
            await db.SaveChangesAsync(ctx.RequestAborted);
        }
        catch (DbUpdateException) when (IsUniqueViolation())
        {
            // Đã xử lý - serve response đã lưu.
            var existing = await db.IdempotencyRecords.FindAsync([key], ctx.RequestAborted);
            if (existing!.ResponseBody is not null)
            {
                ctx.Response.StatusCode = existing.StatusCode;
                await ctx.Response.WriteAsync(existing.ResponseBody);
            }
            return;
        }

        // Lần đầu - chạy handler và bắt response.
        var memory = new MemoryStream();
        var original = ctx.Response.Body;
        ctx.Response.Body = memory;
        try
        {
            await next(ctx);
            entry.StatusCode = ctx.Response.StatusCode;
            entry.ResponseBody = Encoding.UTF8.GetString(memory.ToArray());
            await db.SaveChangesAsync(ctx.RequestAborted);
            memory.Position = 0;
            await memory.CopyToAsync(original);
        }
        finally { ctx.Response.Body = original; }
    }
}

Unique constraint trên IdempotencyRecord.Key là điểm tuần tự hoá. Hai request đồng thời cùng key: chính xác một thắng insert, cái kia nhận unique-violation và serve response đã lưu. Đây là hình duy nhất của HTTP idempotent tôi tin được.

Outbox pattern đầu cuối trông thế nào?

flowchart LR
    API[ASP.NET Core] -->|tx: write row + outbox row| DB[(Postgres)]
    DB --> Outbox[(outbox_messages)]
    Worker[Outbox publisher] -->|poll| Outbox
    Worker -->|publish| Queue[(RabbitMQ)]
    Worker -->|đánh dấu sent| Outbox
    Queue --> Downstream[Consumer]

Hai write trong một transaction: row nghiệp vụ và outbox row báo "publish event này". Worker poll bảng outbox, publish lên queue, đánh dấu row đã gửi. Nếu worker crash giữa publish, lần chạy sau thấy row chưa gửi và publish lại - ổn vì consumer idempotent.

Cấu hình .NET 10 cho outbox?

// Migration
public class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string MessageType { get; set; } = "";
    public string Payload { get; set; } = "";
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? SentAt { get; set; }
}

// Phía producer - code nghiệp vụ thêm outbox row trong cùng Save.
public async Task PlaceOrderAsync(OrderDto dto, CancellationToken ct)
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);

    var order = new Order(dto);
    db.Orders.Add(order);

    db.OutboxMessages.Add(new OutboxMessage
    {
        MessageType = nameof(OrderPlaced),
        Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, order.UserId)),
    });

    await db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
}

// Worker - BackgroundService riêng rút outbox.
public class OutboxPublisher(IServiceProvider sp, IPublishEndpoint bus, ILogger<OutboxPublisher> log)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        while (!stop.IsCancellationRequested)
        {
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            var batch = await db.OutboxMessages
                .Where(o => o.SentAt == null)
                .OrderBy(o => o.CreatedAt)
                .Take(100)
                .ToListAsync(stop);

            foreach (var msg in batch)
            {
                try
                {
                    await PublishAsync(bus, msg, stop);
                    msg.SentAt = DateTimeOffset.UtcNow;
                }
                catch (Exception ex)
                {
                    log.LogError(ex, "Outbox publish lỗi cho {Id}", msg.Id);
                    break; // dừng batch khi lỗi đầu - retry vòng sau
                }
            }
            await db.SaveChangesAsync(stop);
            await Task.Delay(TimeSpan.FromSeconds(1), stop);
        }
    }
}

Ba chi tiết. Transaction tx bao cả write nghiệp vụ và insert outbox - không lệch được. Worker đơn luồng trên outbox (FOR UPDATE SKIP LOCKED trong SQL nếu scale ngang). Consumer downstream vẫn idempotent - contract từ chương 6.

Combo này tạo failure mode nào?

Chương 13 theo dõi cả bốn metric.

Khi nào nên bỏ qua hai pattern này?

Khi thao tác chỉ có một side effect, và side effect đó là write database trong một transaction. Lúc đó database đã exactly-once - commit hoặc rollback. Thêm hạ tầng idempotency key và outbox cho service chỉ ghi DB của mình là overhead không lợi. Pattern đáng giá khi có side effect thứ hai (queue, email, API partner) phải giữ đồng bộ với cái đầu.

Đi tiếp đâu từ đây?

Chương kế tiếp: circuit breaker với Polly - cách dừng gọi dependency hỏng trước khi nó kéo sập service. Sau đó, saga mở rộng outbox sang workflow nhiều bước. Cùng nhau ba chương reliability tạo xương sống cho mọi service .NET production.

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

Sao không dùng exactly-once mode của queue?
Vì exactly-once là từ marketing. Vendor đảm bảo trong hệ của họ, nhưng vừa khi handler ghi database, gửi email, hay gọi API third-party, ranh giới chuyển sang database/email/API và queue không giúp được. Idempotency phía bạn ở app mới là cái thực sự giao exactly-once effect. Coi mode queue là best-effort và đừng phụ thuộc vào nó.
Lưu idempotency key ở đâu?
Cùng database với side effect, cùng transaction. Cho HTTP idempotency: idempotency_keys (key uniqueidentifier primary key, response_body jsonb, created_at timestamptz). Cho consumer idempotency: bảng processed_messages khoá theo message ID. Transaction bọc cả write nghiệp vụ và record idempotency - đó là toàn bộ mẹo.
Sao 'check rồi write' không đủ?
Vì race giữa check và write. Hai request cùng idempotency key đều check (không record), đều tiến hành write side effect, đều insert key record - một thắng, một fail unique constraint, nhưng side effect đã xảy ra hai lần. Dùng unique constraint làm cổng, không phải post-check: insert key trước, rồi làm việc nếu insert thành công.
Outbox có quá liều cho service nhỏ không?
Có nếu side effect duy nhất là database thôi - một transaction là đủ. Outbox đáng giá khi write database phải ghép với publish queue, gửi email, hay gọi API third-party. Lỗi kinh điển nó ngăn: ghi row, crash trước khi publish event, downstream không biết. Outbox + worker gộp hai write thành một transaction bền vững.