← All tutorials

Outbox Pattern

وقتی هم دیتابیس و هم message broker باید با هم «درست» به‌روز شوند

بنر Outbox: سفارش و رکورد Outbox در یک تراکنش دیتابیس، سپس ارسال به message broker.
رویداد همراه داده ذخیره می‌شود و بعد از commit به صف می‌رود.

در این آموزش چه یاد می‌گیریم؟

فرض کنید سفارش را در دیتابیس ذخیره می‌کنید و هم‌زمان می‌خواهید رویداد OrderPlaced را به RabbitMQ یا Kafka بفرستید. اگر یکی موفق شود و دیگری نه، سیستم شما ناهماهنگ می‌شود. Outbox Pattern راه‌حل رایج این مشکل است.

در این مطلب ساده توضیح می‌دهیم Outbox چیست، چطور کار می‌کند، یک مثال کوتاه با EF Core می‌بینیم و مهم‌تر از همه — چالش‌های واقعی پیاده‌سازی را مرور می‌کنیم.

۱. Outbox Pattern چیست؟

Outbox Pattern یعنی به‌جای اینکه مستقیم بعد از ذخیره در دیتابیس، پیام را به message broker بفرستید، همان پیام را داخل همان transaction دیتابیس در یک جدول به نام Outbox (یا OutboxMessages) ذخیره می‌کنید.

بعداً یک فرایند جدا — معمولاً یک background worker — ردیف‌های جدید Outbox را می‌خواند، به broker می‌فرستد و وقتی ارسال موفق بود، آن ردیف را به‌عنوان «ارسال‌شده» علامت می‌زند.

نام Outbox از ایده‌ی «صندوق خروجی پست» می‌آید: نامه (پیام) اول داخل صندوق می‌ماند، بعد پستچی (worker) آن را تحویل مقصد می‌دهد.

۲. مشکل اصلی: Dual Write

سناریوی معمول:

  1. سفارش را در SQL Server ذخیره می‌کنید.
  2. رویداد OrderPlaced را به RabbitMQ publish می‌کنید.

این دو کار دو سیستم جدا هستند. transaction دیتابیس روی broker اثر ندارد و برعکس. سه حالت بد ممکن است:

حالتنتیجه
دیتابیس OK، broker خطاسفارش ثبت شده ولی هیچ‌کس خبردار نشده — ایمیل نرفته، انبار خالی مانده.
broker OK، دیتابیس rollbackپیام ارسال شده ولی سفارش وجود ندارد — مصرف‌کننده‌ها سردرگم می‌شوند.
هر دو OK ولی وسط crashممکن است یکی انجام شده باشد و دیگری نه؛ بدون لاگ دقیق تشخیص سخت است.

به این «نوشتن هم‌زمان در دو جا» می‌گویند dual write problem. Outbox Pattern می‌گوید: فقط یک جا بنویس — دیتابیس. broker بعداً از همان دیتابیس بخواند.

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

۱. درخواست HTTP می‌آید (مثلاً ثبت سفارش).
↓
۲. در یک transaction: ردیف سفارش + ردیف Outbox با payload رویداد ذخیره می‌شود.
↓
۳. transaction commit می‌شود — هر دو با هم موفق یا هر دو rollback.
↓
۴. background worker ردیف‌های پردازش‌نشده Outbox را می‌خواند.
↓
۵. پیام به broker publish می‌شود.
↓
۶. بعد از تأیید broker، ردیف Outbox به‌عنوان Processed علامت می‌خورد.
نکته کلیدی: دیتابیس منبع حقیقت (source of truth) است. broker فقط «کپی تاخیری» رویدادهاست.

۴. مثال ساده با دات‌نت و EF Core

یک entity ساده برای Outbox:

public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = "";
    public string Payload { get; set; } = "";
    public DateTime CreatedAt { get; set; }
    public DateTime? ProcessedAt { get; set; }
}

هنگام ثبت سفارش، هر دو را در یک transaction ذخیره می‌کنید:

await using var tx = await _db.Database.BeginTransactionAsync();

var order = new Order { /* ... */ };
_db.Orders.Add(order);

_db.OutboxMessages.Add(new OutboxMessage
{
    Id = Guid.NewGuid(),
    Type = "OrderPlaced",
    Payload = JsonSerializer.Serialize(new { order.Id, order.Total }),
    CreatedAt = DateTime.UtcNow
});

await _db.SaveChangesAsync();
await tx.CommitAsync();

و worker ساده (ایده کلی — نه production-ready):

var pending = await _db.OutboxMessages
    .Where(m => m.ProcessedAt == null)
    .OrderBy(m => m.CreatedAt)
    .Take(50)
    .ToListAsync();

foreach (var msg in pending)
{
    await _publisher.PublishAsync(msg.Type, msg.Payload);
    msg.ProcessedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync();
این worker برای آموزش است. در production باید lock، retry، idempotency و مانیتورینگ هم داشته باشد — در بخش چالش‌ها توضیح می‌دهیم.

۵. چالش‌های پیاده‌سازی

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

۵.۱ پیام تکراری (at-least-once delivery)

worker پیام را به broker فرستاده، ولی قبل از اینکه ProcessedAt را ذخیره کند سرور crash می‌کند. بعد از restart همان پیام دوباره ارسال می‌شود.

Outbox معمولاً at-least-once تضمین می‌کند — یعنی پیام حداقل یک‌بار می‌رسد، نه «دقیقاً یک‌بار». پس مصرف‌کننده‌ها باید idempotent باشند: اگر همان OrderId را دوباره دیدند، نباید دوباره ایمیل بفرستند یا موجودی را دوباره کم کنند.

راه‌حل رایج: نگه‌داشتن MessageId یا IdempotencyKey در جدول جدا و چک کردن قبل از پردازش.

۵.۲ ترتیب پیام‌ها (ordering)

اگر چند worker هم‌زمان Outbox را بخوانند، ممکن است رویداد OrderPaid قبل از OrderCreated به broker برسد.

  • برای یک aggregate (مثلاً یک سفارش) معمولاً ترتیب مهم است.
  • راه ساده: partition بر اساس OrderId — فقط یک worker هر بار برای همان کلید کار کند.
  • یا یک worker تک‌نخی (ساده ولی محدودیت throughput دارد).

۵.۳ Polling یا CDC؟

دو روش رایج برای خواندن Outbox:

روشمزیتمعایب
Polling
worker هر چند ثانیه SELECT می‌زند
ساده، بدون ابزار اضافه تأخیر (latency)، فشار روی دیتابیس با interval کوتاه
CDC
مثلاً Debezium از transaction log می‌خواند
سریع‌تر، کمتر SELECT اضافه پیچیده‌تر، نیاز به Kafka Connect یا ابزار مشابه

برای شروع، Polling کافی است. وقتی حجم رویداد بالا رفت، CDC را بررسی کنید.

۵.۴ رشد جدول Outbox

هر رویداد یک ردیف می‌ماند. اگر ردیف‌های پردازش‌شده را پاک نکنید، جدول بی‌نهایت بزرگ می‌شود و queryهای worker کند می‌شوند.

  • job پاک‌سازی دوره‌ای (مثلاً ردیف‌های قدیمی‌تر از ۷ روز).
  • یا آرشیو به جدول/فایل جدا.
  • index روی ProcessedAt و CreatedAt فراموش نشود.

۵.۵ broker از کار افتاده

Outbox جلوی از دست رفتن رویداد را می‌گیرد — پیام‌ها در دیتابیس امن هستند. ولی اگر RabbitMQ یا Kafka چند ساعت down باشد:

  • جدول Outbox پر می‌شود.
  • بعد از برگشت broker، burst بزرگی از پیام publish می‌شود.
  • مصرف‌کننده‌ها باید آماده spike باشند.

پس Outbox جایگزین مانیتورینگ broker نیست؛ فقط پل امن بین دیتابیس و broker است.

۵.۶ پیچیدگی عملیاتی

  • یک سرویس دیگر (worker) برای deploy و نگه‌داری دارید.
  • باید بدانید Outbox lag چقدر است — اگر worker عقب بیفتد، رویدادها دیر می‌رسند.
  • در multi-instance، دو worker نباید همان ردیف را هم‌زمان بگیرند — به row lock یا FOR UPDATE SKIP LOCKED (در PostgreSQL) یا claim با status نیاز دارید.
  • ابزارهای آماده مثل MassTransit، NServiceBus یا EF Core Outbox در برخی frameworkها بخشی از این کار را انجام می‌دهند.

۶. کی لازم است و کی نه؟

Outbox مناسب است وقتی…شاید اضافه باشد وقتی…
  • رویداد باید حتماً بعد از commit دیتابیس برسد
  • چند سرویس از همان رویداد استفاده می‌کنند
  • از message broker در معماری microservices استفاده می‌کنید
  • یک monolith ساده با background job داخلی کافی است
  • فقط یک ایمیل async می‌خواهید و از دست رفتن گاه‌به‌گاه مهم نیست
  • هنوز message broker ندارید و فقط HTTP کافی است

۷. جمع‌بندی

  • Outbox Pattern مشکل dual write بین دیتابیس و broker را حل می‌کند.
  • رویداد را در همان transaction دیتابیس ذخیره می‌کنید؛ worker بعداً به broker می‌فرستد.
  • تضمین معمول at-least-once است — مصرف‌کننده باید idempotent باشد.
  • چالش‌های واقعی: ترتیب پیام، رشد جدول، انتخاب Polling vs CDC، و نگه‌داری worker.
  • برای سیستم‌های جدی با رویدادهای مهم، ارزشش را دارد؛ برای پروژه‌های کوچک شاید overkill باشد.

Outbox ساده به نظر می‌رسد؛ درست پیاده‌سازی کردنش همان جایی است که تجربه‌ی واقعی شروع می‌شود.