الگوی Outbox در دات‌نت: رویدادهای مطمئن بدون تراکنش توزیع‌شده

پاسخ کوتاه: وقتی یک درخواست باید هم دیتابیس شما را تغییر دهد و هم سیستم دیگری را خبر کند، دو نوشتن جداگانه انجام ندهید. تغییر اصلی و یک ردیف Outbox که رویداد را توصیف می‌کند در یک تراکنش دیتابیس ذخیره کنید و بگذارید یک Worker پس‌زمینه Outbox را بخواند و منتشر کند. یا هر دو ردیف وجود دارند یا هیچ‌کدام؛ پس هیچ‌وقت پرداختی ذخیره نمی‌شود که رویدادش گم شده باشد، و هیچ‌وقت پرداختی اعلام نمی‌شود که rollback شده باشد.

یک مثال واقعی: پرداختی که هیچ‌کس خبردار نشد

شارژ کیف‌پول موفق می‌شود. کد پرداخت را ذخیره می‌کند و بعد به message broker می‌گوید PaymentSucceeded را منتشر کند تا سرویس پیامک و باشگاه مشتریان واکنش نشان دهند. broker هشت ثانیه در حال ری‌استارت است. انتشار exception می‌دهد، کاربر خطا می‌بیند، ولی ردیف پرداخت قبلاً commit شده.

پول در کیف‌پول است، پیامکی نمی‌رود، امتیازی اضافه نمی‌شود و پشتیبانی تیکتی می‌گیرد که «پول کم شد ولی هیچ اتفاقی نیفتاد». ترتیب را عوض کنید (اول انتشار، بعد ذخیره) و باگ برعکس می‌شود: باشگاه مشتریان برای پرداختی امتیاز می‌دهد که تراکنشش بعداً rollback شده.

هیچ ترتیبی از دو نوشتن مستقل امن نیست. Outbox نوشتن دوم را از مسیر درخواست بیرون می‌برد.

Outbox چطور کار می‌کند

  1. در درخواست، موجودیت و یک ردیف OutboxMessage را با یک بار صدا زدن SaveChangesAsync ذخیره کنید. EF Core هر دو را در یک تراکنش می‌گذارد.
  2. یک BackgroundService ردیف‌های پردازش‌نشده را می‌خواند، هر کدام را منتشر و علامت «پردازش‌شده» می‌زند.
  3. اگر انتشار شکست بخورد، ردیف پردازش‌نشده می‌ماند و در دور بعد دوباره تلاش می‌شود.
  4. مصرف‌کننده‌ها با شناسه پیام، تکراری‌ها را حذف می‌کنند؛ چون یک پیام ممکن است بیش از یک بار برسد.

کد قابل اجرا

جدول Outbox:

public sealed class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Type { get; set; } = "";
    public string Payload { get; set; } = "";
    public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;
    public DateTime? ProcessedAtUtc { get; set; }
    public int Attempts { get; set; }
    public string? LastError { get; set; }
}

public sealed record PaymentSucceeded(Guid PaymentId, Guid UserId, long AmountRials);

ذخیره هم‌زمان وضعیت و رویداد:

app.MapPost("/payments/{id:guid}/confirm", async (Guid id, AppDbContext db, CancellationToken ct) =>
{
    var payment = await db.Payments.FindAsync([id], ct);
    if (payment is null) return Results.NotFound();

    payment.MarkSucceeded();

    db.OutboxMessages.Add(new OutboxMessage
    {
        Type = nameof(PaymentSucceeded),
        Payload = JsonSerializer.Serialize(
            new PaymentSucceeded(payment.Id, payment.UserId, payment.AmountRials)),
    });

    await db.SaveChangesAsync(ct); // یک تراکنش: هر دو ردیف یا هیچ‌کدام
    return Results.Ok();
});

Dispatcher:

public sealed class OutboxDispatcher(
    IServiceScopeFactory scopes,
    ILogger<OutboxDispatcher> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
        while (await timer.WaitForNextTickAsync(ct))
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var publisher = scope.ServiceProvider.GetRequiredService<IEventPublisher>();

            var batch = await db.OutboxMessages
                .Where(m => m.ProcessedAtUtc == null && m.Attempts < 10)
                .OrderBy(m => m.OccurredAtUtc)
                .Take(50)
                .ToListAsync(ct);

            foreach (var msg in batch)
            {
                try
                {
                    await publisher.PublishAsync(msg.Id, msg.Type, msg.Payload, ct);
                    msg.ProcessedAtUtc = DateTime.UtcNow;
                }
                catch (Exception ex)
                {
                    msg.Attempts++;
                    msg.LastError = ex.Message;
                    log.LogWarning(ex, "Outbox message {MessageId} failed", msg.Id);
                }
            }

            await db.SaveChangesAsync(ct);
        }
    }
}

// Program.cs
builder.Services.AddHostedService<OutboxDispatcher>();

IEventPublisher هر چیزی است که رویداد را واقعاً تحویل می‌دهد: RabbitMQ، Azure Service Bus، یک وب‌هوک HTTP یا حتی یک هندلر داخل همان پروسه. برای Outbox فرقی نمی‌کند.

جزئیاتی که در محیط واقعی مهم است

  • روی ردیف‌های پردازش‌نشده ایندکس بگذارید. یک filtered index روی OccurredAtUtc با شرط ProcessedAtUtc IS NULL پرس‌وجوی دوره‌ای را با بزرگ شدن جدول ارزان نگه می‌دارد.
  • پاک‌سازی کنید. ردیف‌های پردازش‌شده قدیمی‌تر از چند روز را در یک job شبانه حذف کنید، وگرنه این جدول بزرگ‌ترین جدول شما می‌شود.
  • پیام‌های مسموم. شرط Attempts < 10 نمی‌گذارد یک پیام خراب تا ابد تکرار شود. برای ردیف‌هایی که به سقف رسیده‌اند هشدار بگذارید.
  • مصرف‌کننده‌ها باید idempotent باشند. msg.Id را به‌عنوان شناسه پیام بفرستید و مصرف‌کننده شناسه‌های پردازش‌شده را نگه دارد؛ همان ایده کلید Idempotency برای APIهای پرداخت.
  • چند نمونه از سرویس. دو Dispatcher ممکن است ردیف‌های یکسان بخوانند. در SQL Server از هینت‌های UPDLOCK, READPAST, ROWLOCK در یک کوئری SQL خام استفاده کنید یا Dispatcher را فقط روی یک نمونه اجرا کنید.

مزایا و معایب

مزایا

  • نه رویداد گم‌شده دارید نه رویداد خیالی: تراکنش دیتابیس تنها منبع حقیقت است.
  • درخواست دیگر به بالا بودن broker وابسته نیست؛ قطعی broker رویدادها را عقب می‌اندازد، نه این‌که پرداخت را خراب کند.
  • همه رویدادها ذخیره می‌شوند و یک سابقه رایگان از این‌که چه چیزی کی اعلام شده دارید.

معایب

  • رویدادها با کمی تأخیر (فاصله polling) تحویل می‌شوند، نه فوری.
  • تحویل «حداقل یک‌بار» یعنی هر مصرف‌کننده‌ای باید تکراری‌ها را حذف کند.
  • یک جدول و یک پروسه پس‌زمینه بیشتر برای پایش.

جریان پرداخت یا کیف‌پولی می‌سازید که رویدادهای بین سرویس‌ها نباید در آن گم شوند؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

آیا Outbox تحویل دقیقاً یک‌باره (exactly-once) را تضمین می‌کند؟

نه. تضمینش «حداقل یک‌بار» است: ممکن است پیام منتشر شود و Worker قبل از علامت‌زدن آن کرش کند و پیام دوباره منتشر شود. مصرف‌کننده‌ها باید با نگه داشتن شناسه پیام‌های پردازش‌شده، idempotent باشند.

بهتر نیست به‌جای نوشتن Outbox از MassTransit یا CAP استفاده کنم؟

اگر از قبل کتابخانه پیام‌رسانی دارید، از Outbox داخلی خودش استفاده کنید؛ قفل‌گذاری، retry و پاک‌سازی را مدیریت می‌کند. Outbox دست‌نویس برای یک سرویس و چند نوع پیام کاملاً کافی است و برای فهمیدن کار آن کتابخانه‌ها هم مفید است.

اگر چند نمونه (instance) از سرویس اجرا شود چه؟

دو Worker ممکن است ردیف‌های یکسانی بردارند. در SQL Server دسته را با هینت‌های UPDLOCK و READPAST و ROWLOCK داخل یک تراکنش بخوانید تا هر نمونه ردیف‌های قفل‌شده را رد کند، یا Dispatcher را فقط روی یک نمونه اجرا کنید.