در این آموزش چه یاد میگیریم؟
فرض کنید سفارش را در دیتابیس ذخیره میکنید و همزمان میخواهید رویداد OrderPlaced را به RabbitMQ یا Kafka بفرستید. اگر یکی موفق شود و دیگری نه، سیستم شما ناهماهنگ میشود. Outbox Pattern راهحل رایج این مشکل است.
در این مطلب ساده توضیح میدهیم Outbox چیست، چطور کار میکند، یک مثال کوتاه با EF Core میبینیم و مهمتر از همه — چالشهای واقعی پیادهسازی را مرور میکنیم.
۱. Outbox Pattern چیست؟
Outbox Pattern یعنی بهجای اینکه مستقیم بعد از ذخیره در دیتابیس، پیام را به message broker بفرستید، همان پیام را داخل همان transaction دیتابیس در یک جدول به نام Outbox (یا OutboxMessages) ذخیره میکنید.
بعداً یک فرایند جدا — معمولاً یک background worker — ردیفهای جدید Outbox را میخواند، به broker میفرستد و وقتی ارسال موفق بود، آن ردیف را بهعنوان «ارسالشده» علامت میزند.
۲. مشکل اصلی: Dual Write
سناریوی معمول:
- سفارش را در SQL Server ذخیره میکنید.
- رویداد
OrderPlacedرا به RabbitMQ publish میکنید.
این دو کار دو سیستم جدا هستند. transaction دیتابیس روی broker اثر ندارد و برعکس. سه حالت بد ممکن است:
| حالت | نتیجه |
|---|---|
| دیتابیس OK، broker خطا | سفارش ثبت شده ولی هیچکس خبردار نشده — ایمیل نرفته، انبار خالی مانده. |
| broker OK، دیتابیس rollback | پیام ارسال شده ولی سفارش وجود ندارد — مصرفکنندهها سردرگم میشوند. |
| هر دو OK ولی وسط crash | ممکن است یکی انجام شده باشد و دیگری نه؛ بدون لاگ دقیق تشخیص سخت است. |
به این «نوشتن همزمان در دو جا» میگویند dual write problem. Outbox Pattern میگوید: فقط یک جا بنویس — دیتابیس. broker بعداً از همان دیتابیس بخواند.
۳. Outbox چطور کار میکند؟
Processed علامت میخورد.۴. مثال ساده با داتنت و 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();
۵. چالشهای پیادهسازی
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 مناسب است وقتی… | شاید اضافه باشد وقتی… |
|---|---|
|
|
۷. جمعبندی
- Outbox Pattern مشکل dual write بین دیتابیس و broker را حل میکند.
- رویداد را در همان transaction دیتابیس ذخیره میکنید؛ worker بعداً به broker میفرستد.
- تضمین معمول at-least-once است — مصرفکننده باید idempotent باشد.
- چالشهای واقعی: ترتیب پیام، رشد جدول، انتخاب Polling vs CDC، و نگهداری worker.
- برای سیستمهای جدی با رویدادهای مهم، ارزشش را دارد؛ برای پروژههای کوچک شاید overkill باشد.
Outbox ساده به نظر میرسد؛ درست پیادهسازی کردنش همان جایی است که تجربهی واقعی شروع میشود.