پیاده‌سازی الگوی Outbox Pattern در میکروسرویس‌های دات‌نت

Gemini
Gemini
2026/09/05
زمان مطالعه زمان مطالعه: 18 دقیقه
36 بازدید
پیاده‌سازی الگوی Outbox Pattern در میکروسرویس‌های دات‌نت

مقدمه و ریشه‌یابی مشکل Dual-Write در میکروسرویس‌ها

در معماری میکروسرویس، هنگامی که یک تراکنش محلی دیتابیس را انجام می‌دهید و هم‌زمان می‌خواهید پیامی را به Message Broker ارسال کنید، با مشکل کلاسیک Dual-Write روبرو می‌شوید. اگر پایگاه داده به‌روز شود اما ارسال پیام به بروکر به دلیل قطع شبکه شکست بخورد، سیستم وارد یک حالت ناسازگار و بحرانی می‌شود.

توسعه‌دهندگان تازه‌کار معمولاً ابتدا تراکنش دیتابیس را انجام داده و بلافاصله در خط بعدی کد، متد ارسال پیام به بروکر را فراخوانی می‌کنند. این روش شکننده است زیرا خطاهای زیرساختی شبکه و ناپایداری‌های بروکر را کاملاً نادیده می‌گیرد.

// رویکرد سنتی و آسیب‌پذیر (The Naive Way)
using var transaction = await _dbContext.Database.BeginTransactionAsync();
try {
    _dbContext.Orders.Add(order);
    await _dbContext.SaveChangesAsync(); // تغییرات در دیتابیس ثبت می‌شود
    
    // نقطه بحرانی: اگر شبکه قطع شود، پیام گم می‌شود!
    await _messageBroker.PublishAsync(new OrderCreatedEvent(order.Id));
    
    await transaction.CommitAsync();
} catch {
    await transaction.RollbackAsync();
}

این قطعه کد در محیط توسعه بی‌نقص کار می‌کند، اما در محیط پروداکشن با اولین افت کیفیت شبکه، تناقض داده ایجاد می‌کند. تنظیمات تایم‌اوت شبکه مانند Timeout: 5s در اینجا حیاتی هستند زیرا مشخص می‌کنند سیستم چقدر باید منتظر پاسخ بروکر بماند قبل از اینکه کلاینت را دچار معطلی کند.

اگر در این لحظه ارتباط با بروکر قطع شود، خروجی خطای رایج زیر در لاگ‌ها ثبت خواهد شد:

RabbitMQ.Client.Exceptions.BrokerUnreachableException: None of the endpoints could be reached
   at RabbitMQ.Client.ConnectionFactory.CreateConnection()

الگوی Outbox Pattern با نوشتن رویدادها در همان جدول پایگاه داده تراکنش اصلی، اتمی بودن عملیات را تضمین کرده و مشکل Dual-Write را ریشه‌ای حل می‌کند. به جای ارسال مستقیم به بروکر، رویدادها در یک جدول Outbox ذخیره می‌شوند تا بعداً به صورت مطمئن پردازش شوند.

Request -> [API Service] -> Database (Write Data + Outbox Table) -> Success

نکته مهم: هرگز به ارسال هم‌زمان پیام به بروکر و دیتابیس در یک ترِد بدون مکانیزم جبرانی اعتماد نکنید.

برای درک بهتر، حالت‌های شکست سیستم را در دو سناریو مقایسه می‌کنیم:

  • شکست پایگاه داده: کل تراکنش به همراه جدول Outbox ریجکت می‌شود و هیچ داده‌ی یتیمی باقی نمی‌ماند.
  • شکست بروکر پیام: داده‌ها با موفقیت در دیتابیس ذخیره شده و پیام در جدول Outbox محفوظ می‌ماند تا در فرصت مناسب دوباره ارسال شود.

طراحی ساختار جدول Outbox در پایگاه داده

بدون داشتن یک جدول ساختاریافته برای ذخیره موقت رویدادها، امکان ردیابی پیام‌های ارسالی وجود ندارد و سیستم در تاریکی تصمیم می‌گیرد. این جدول باید دقیقاً وضعیت پردازش پیام را حفظ کند تا هیچ رویدادی میان مسیر گم نشود.

یک توسعه‌دهنده تازه‌کار معمولاً تلاش می‌کند رویدادها را در همان جداول عمومی لاگ یا بدون فیلد وضعیت (Status) و تاریخچه پردازش ذخیره کند. این رویکرد ناپایدار باعث می‌شود پیام‌های ارسال‌شده و ارسال‌نشده با هم مخلوط شوند و سیستم نتواند مشخص کند کدام داده باید دوباره به بروکر فرستاده شود.

انتخاب مهندسی درست، استفاده از یک جدول Outbox تخصصی با فیلدهای مشخص است که بهینه‌سازی شده برای کوئری‌های سریع و خواندن مداوم توسط Background Workerهاست.

Order Table <--> Outbox Table (Pending Status)

این بخش به طراحی دقیق اسکیما می‌پردازد و فیلد Payload را به صورت JSON ذخیره می‌کند تا انعطاف‌پذیری داده در تغییرات آینده حفظ گردد. مدیریت این جدول باید کمترین سربار را روی دیتابیس اصلی بیزینس داشته باشد تا سرعت ثبت سفارش‌ها افت نکند.

برای پیاده‌سازی این ساختار در دیتابیس‌های رابطی مانند SQL Server یا PostgreSQL همراه با Entity Framework Core، از یک کلاس مدل‌سازی‌شده استفاده می‌کنیم.

public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; }
    public string Content { get; set; }
    public DateTime OccurredOn { get; set; }
    public DateTime? ProcessedDate { get; set; }
}

نوع داده Guid برای کلید اصلی انتخاب شده است تا از تداخل شناسه در سیستم‌های توزیع‌شده و مقیاس‌پذیر جلوگیری شود. فیلد Type مشخص می‌کند رویداد مربوط به چه بخشی است و Content داده‌های اصلی را حمل می‌کند.

ساختار نهایی ایجاد این جدول در پایگاه داده رابطه‌ای به شکل زیر تولید می‌شود:

CREATE TABLE OutboxMessages (
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    Type NVARCHAR(255) NOT NULL,
    Content NVARCHAR(MAX) NOT NULL,
    OccurredOn DATETIME2 NOT NULL,
    ProcessedDate DATETIME2 NULL
);

وقتی ستون ProcessedDate مقدار NULL دارد، یعنی پیام هنوز پردازش نشده و منتظر ارسال است. پس از ارسال موفق پیام توسط سرویس پس‌زمینه، این فیلد با تاریخ روز آپدیت می‌شود تا در کوئری‌های بعدی نادیده گرفته شود.

برای بهینه‌سازی کارایی این جدول، حالت‌های مختلف ایندکس‌گذاری روی ستون‌ها تفاوت چشمگیری در عملکرد ایجاد می‌کنند:

  • بدون ایندکس: Worker برای پیدا کردن پیام‌های جدید مجبور است کل جدول را اسکن کند که با افزایش حجم داده‌ها فاجعه‌بار است.
  • ایندکس ساده روی ProcessedDate: سرعت پیدا کردن رکوردهای NULL را بالا می‌برد اما کافی نیست.
  • ایندکس ترکیبی و فیلترشده (Filtered Index): بهترین انتخاب مهندسی برای سناریوی ماست که فقط روی رکوردهای پردازش‌نشده اعمال می‌شود.

نکته مهم: همیشه روی ستون‌هایی که برای خواندن پیام‌های پردازش‌نشده استفاده می‌شوند ایندکس بگذارید تا پایگاه داده زیر بار کوئری‌های مداوم Worker دچار افت پویایی نشود.

تراکنش‌های اتمی و ذخیره رویداد در دیتابیس با EF Core

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

توسعه‌دهندگان تازه‌کار معمولاً برای این سناریو از دو DbContext مختلف برای ثبت سفارش و ثبت Outbox به صورت جداگانه استفاده می‌کنند. این رویکرد به دلیل اجرای مستقل تراکنش‌ها، پایداری سیستم را به خطر می‌اندازد و داده‌های یتیم ایجاد می‌کند.

انتخاب مهندسی درست، استفاده از یک DbContext واحد و Entity Framework Core Transactions برای قرار دادن هر دو عملیات در یک Unit of Work است. این مکانیزم به پایگاه داده اعلام می‌کند که تغییرات بیزینس و پیام Outbox در قالب یک پکیج واحد اعمال شوند.

جریان داده در این الگوی اتمی به شکل زیر است:

Begin Transaction -> Save Order -> Save Outbox Message -> Commit Transaction

برای پیاده‌سازی این سناریو در دات‌نت، از متد Database.BeginTransactionAsync در EF Core استفاده می‌کنیم. اگر هر خطایی در این مسیر رخ دهد، کل تغییرات رولبک می‌شوند تا داده‌های یتیم ایجاد نگردند.

using var transaction = await _dbContext.Database.BeginTransactionAsync();
try {
    // Save entities and outbox
    await _dbContext.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch { 
    await transaction.RollbackAsync(); 
}

در این کد از سطح ایزولاسیون پیش‌فرض دیتابیس استفاده شده است تا از بروز بن‌بست یا همان Deadlock در تراکنش‌های هم‌زمان جلوگیری شود. تغییر این سطح به مقادیر سخت‌گیرانه‌تر نیازمند تحلیل دقیق بار روی جداول است.

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

خروجی ثبت موفقیت‌آمیز در پایگاه داده شامل لاگ‌های زیر خواهد بود که اتمی بودن تراکنش را تایید می‌کند:

[12:40:15 INF] Beginning transaction with IsolationLevel: ReadCommitted
[12:40:15 INF] Executing DbCommand [Parameters=[@p0='ord_9823', @p1='Pending'], CommandType='Text']
[12:40:15 INF] Committing transaction...
[12:40:15 INF] Transaction committed successfully. Outbox message dispatched to local queue table.

مدیریت تراکنش‌ها را می‌توان به جای کدهای صریح، با استفاده از TransactionScope در دات‌نت نیز پیاده‌سازی کرد.

  • رویکرد Database.BeginTransactionAsync: کنترل دقیق‌تر روی سطح ایزولاسیون و سازگاری کامل با ساختار داخلی DbContext.
  • رویکرد TransactionScope: مناسب برای سناریوهایی که چند منبع داده مختلف هم‌زمان درگیر یک تراکنش توزیع‌شده هستند.

اکنون که پیام‌ها امن و اتمی در دیتابیس ذخیره شده‌اند، به سراغ خواندن و انتشار آن‌ها می‌رویم.

پیاده‌سازی Background Service برای استخراج و ارسال پیام‌ها

پیام‌های ذخیره‌شده در جدول Outbox تا زمانی که به دست Message Broker نرسند بی‌فایده هستند و سیستم نیازمند یک پردازنده پس‌زمینه است که به طور مداوم این پیام‌ها را بخواند. اگر این ساختار را نداشته باشیم، داده‌های درون جدول انبار شده و همگام‌سازی بین سرویس‌ها به کلی متوقف می‌شود.

یک برنامه‌نویس تازه‌کار ممکن است برای حل این مسئله سراغ یک حلقه سنتی while(true) همراه با دستور Thread.Sleep برود. این روش ساده به نظر می‌رسد، اما منابع پردازنده را به شدت هدر می‌دهد، مدیریت تردها را مختل می‌کند و امکان لغو ایمن عملیات را از بین می‌برد.

به جای این رویکرد سنتی، از کلاس استاندارد BackgroundService در دات‌نت همراه با PeriodicTimer استفاده می‌کنیم. این انتخاب مهندسی به ما اجازه می‌دهد پردازش را به صورت تناوبی انجام دهیم و در عین حال، مصرف منابع سیستم را بهینه نگه داریم.

Background Service -> Query Unprocessed Outbox -> Publish to Broker -> Mark as Processed

برای پیاده‌سازی این مکانیزم، کلاس اصلی خود را از BackgroundService ارث‌بری می‌کنیم. متد ExecuteAsync نقطه ورود اصلی این سرویس است که چرخه حیات آن را مدیریت می‌کند.

protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
    var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
    while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) {
        await ProcessOutboxMessagesAsync();
    }
}

مقدار ۵ ثانیه برای تایمر تناوبی انتخاب شده است چون تعادل مناسبی بین تأخیر انتشار پیام و بار روی دیتابیس ایجاد می‌کند. اگر این بازه را روی میلی‌ثانیه تنظیم کنیم، دیتابیس زیر فشار کوئری‌های مداوم زانو می‌زند و اگر روی دقایق طولانی بگذاریم، سیستم کند و تاخیردار به نظر می‌رسد.

نکته مهم: حتماً از CancellationToken برای خاتمه امن سرویس پس‌زمینه استفاده کنید تا هنگام متوقف شدن اپلیکیشن، تردها به صورت ناگهانی قطع نشوند و پردازش‌های در حال انجام به اتمام برسند.

برای سناریوهای مختلف ترافیکی، می‌توانیم بازه‌های زمانی متفاوتی را به کار بگیریم. به عنوان مثال، در سیستم‌های پرتردد از تایمرهای کوتاه‌تر و در سیستم‌های عادی از بازه‌های طولانی‌تر استفاده می‌شود تا مصرف منابع سخت‌افزاری کنترل شود.

نمونه‌ای از لاگ خروجی پردازش دسته‌ای پیام‌ها در کنسول به شکل زیر نمایش داده می‌شود:

[10:15:00 INF] Starting outbox message processing batch...
[10:15:00 INF] Successfully published 4 messages to broker.
[10:15:05 INF] Outbox table is clean. Waiting for next tick...

این لاگ نشان می‌دهد که سرویس به صورت منظم کار خود را انجام داده و پیام‌ها را بدون ایجاد تداخل به بروکر منتقل کرده است.

ارسال پیام به Message Broker و مدیریت وضعیت در دیتابیس

پس از خواندن پیام از جدول Outbox، اگر پیام به بروکر ارسال شود اما علامت‌گذاری آن به عنوان «ارسال‌شده» با خطا مواجه شود، سیستم دچار عدم قطعیت شده و پیام‌ها ناخواسته تکرار خواهند شدند. این سناریو در محیط‌های توزیع‌شده یک کابوس واقعی است زیرا دیتابیس فکر می‌کند کار تمام شده، در حالی که Message Broker هرگز پیام را دریافت نکرده است.

راهکار ساده‌لوحانه که اکثر توسعه‌دهندگان ابتدا امتحان می‌کنند، حذف رکورد از جدول Outbox بلافاصله پس از خواندن و قبل از دریافت تأییدیه است. این روش فاجعه‌بار است چون اگر ارتباط با بروکر در همان لحظه قطع شود، داده برای همیشه از دست می‌رود. انتخاب مهندسی صحیح این است که به‌روزرسانی فیلد ProcessedDate فقط پس از دریافت پاسخ موفق یا همان Ack از سوی بروکر انجام شود تا تضمین سطح تحویل At-Least-Once Delivery به دست بیاید.

برای درک بهتر چرخه حیات داده، مسیر حرکت پیام را از دیتابیس تا صف بررسی می‌کنیم:

Read Outbox -> Send to RabbitMQ -> Receive Ack -> Update Status = Processed

در این بخش مکانیزم ارتباط با بروکر و به‌روزرسانی وضعیت رکوردها در جدول Outbox پیاده‌سازی می‌شود. مدیریت خطا در این مرحله بسیار حیاتی است تا اگر بروکر در دسترس نبود، سیستم در چرخه بعدی مجدداً تلاش کند. ما از کلاینت‌های استاندارد دات‌نت مانند RabbitMQ Client بومی برای این ارتباط استفاده می‌کنیم تا کنترل دقیقی روی پروتکل داشته باشیم.

در اینجا یک نمونه کد پایه‌ای برای حلقه انتشار پیام و به‌روزرسانی وضعیت را می‌بینید:

foreach (var message in messages) {
    await _bus.Publish(JsonConvert.DeserializeObject(message.Content));
    message.ProcessedDate = DateTime.UtcNow;
}
await _dbContext.SaveChangesAsync();

در این کد، مقدار DateTime.UtcNow برای جلوگیری از خطاهای ناشی از اختلاف ساعت سرورها و مناطق زمانی مختلف استفاده می‌شود. استفاده از زمان محلی در سیستم‌های توزیع‌شده می‌تواند ترتیب رویدادها را به هم بریزد. پس از اجرای موفق این حلقه، خروجی کنسول یا لاگ سیستم باید چیزی شبیه به این را نشان دهد:

[11:00:12 INF] Outbox message ID 8492 successfully acknowledged by broker.
[11:00:12 INF] Database transaction committed. ProcessedDate updated to 2024-05-15T11:00:12Z.

این لاگ نشانگر ثبت دقیق موفقیت ارسال است که در آن شناسه یکتای پیام به همراه زمان دقیق پردازش درج شده است. برای بهینه‌سازی عملکرد و کاهش تعداد رفت‌وبرگشت‌ها به دیتابیس، می‌توانیم از الگوهای متفاوتی استفاده کنیم.

مدیران سیستم اغلب بین دو رویکرد زیر یکی را انتخاب می‌کنند:

  • ارسال تکی (Single Publish): امنیت بالا در ردیابی خطا، اما تعداد درخواست‌های I/O را افزایش می‌دهد.
  • ارسال دسته‌ای (Batch Publishing): ارسال گروهی پیام‌ها به بروکر که باعث افزایش کارایی و کاهش بار شبکه می‌شود.

نکته مهم: همیشه انتشار پیام و به‌روزرسانی وضعیت Outbox را به صورت منطقی مدیریت کنید تا پیام‌های تکراری کمترین اثر مخرب را داشته باشند و سیستم در برابر خطای شبکه مقاوم بماند.

مدیریت همگامی و جلوگیری از پردازش تکراری با Locking

اگر چندین نمونه از میکروسرویس شما به صورت هم‌زمان اجرا شوند، ممکن است هر دو یک پیام Outbox مشابه را بخوانند و دوبار به بروکر ارسال کنند. در معماری توزیع‌شده، این یک چالش جدی است. وقتی مقیاس سیستم را از یک سرور به چند سرور ارتقا می‌دهیم، درخواست‌های خواندن با هم تداخل پیدا می‌کنند. بدون مکانیزم‌های کنترلی، داده‌ها دچار ناسازگاری می‌شوند و پیام‌ها تکرار خواهند شد.

راهکار ساده‌لوحانه این است که قفل‌گذاری را کاملاً فراموش کنیم و به این امید باشیم که سرورها تداخل زمانی ندارند. این روش در محیط عملیاتی و پربار سریعاً شکست می‌خورد و باعث ارسال دوباره پیام‌ها به صف می‌شود. مهندسی نرم‌افزار به ما می‌آموزد که به جای شانس، به مکانیزم‌های ساختاری دیتابیس اتکا کنیم.

برای حل این مشکل از قفل‌گذاری رکوردها حین خواندن استفاده می‌کنیم. با این کار، ترد یا سرور دوم تا پایان کار سرور اول منتظر می‌ماند یا از رکورد قفل‌شده عبور می‌کند.

  • Instance 1 & 2 -> Query with Row Lock -> Instance 1 Locks Batch -> Instance 2 Skips Locked Rows

در این بخش با استفاده از دستورات خاص SQL یا قابلیت‌های EF Core، رکوردها را هنگام خواندن قفل می‌کنیم تا سایر تردها یا سرورها نتوانند به آن‌ها دست بزنند. این کار امنیت پردازش توزیع‌شده را تضمین می‌کند و از بروز خطاهای همگامی جلوگیری می‌کند.

var messages = await _dbContext.OutboxMessages
    .Where(m => m.ProcessedDate == null)
    .Take(20)
    .FromSqlRaw("SELECT * FROM OutboxMessages WHERE ProcessedDate IS NULL WITH (UPDLOCK, ROWLOCK)")
    .ToListAsync();

در کوئری بالا، محدود کردن دسته به ۲۰ پیام انتخاب شده است تا از مسدود شدن طولانی مدت جداول جلوگیری شود. عدد ۲۰ تعادل مناسبی بین سرعت پردازش و حجم تراکنش ایجاد می‌کند. اگر این عدد خیلی بزرگ باشد، قفل دیتابیس طولانی می‌شود و بقیه بخش‌های سیستم معطل می‌مانند.

خروجی واقعی اجرای این کوئری در پایگاه داده شامل قفل شدن انحصاری رکوردهای خوانده‌شده است:

Id Content ProcessedDate LockStatus
101 {"Event": "UserCreated"} NULL Locked by SPID 54
102 {"Event": "OrderPlaced"} NULL Locked by SPID 54

برای پایگاه داده PostgreSQL، ساختار کوئری قفل‌گذاری به شکل متفاوتی نوشته می‌شود تا با مکانیزم داخلی آن سازگار باشد.

var pgMessages = await _dbContext.OutboxMessages
    .FromSqlRaw("SELECT * FROM \"OutboxMessages\" WHERE \"ProcessedDate\" IS NULL FOR UPDATE SKIP LOCKED")
    .Take(20)
    .ToListAsync();

عبارت SKIP LOCKED یک قابلیت کلیدی است که به سرور دوم اجازه می‌دهد رکوردهایی را که سرور اول قفل کرده نادیده بگیرد. این یعنی هیچ سروری معطل نمی‌شود و هر کدام به سراغ پیام‌های آزاد بعدی می‌روند.

نکته مهم: قفل‌های دیتابیس نباید طولانی باشند در غیر این صورت کارایی سیستم به شدت افت می‌کند.

علاوه بر قفل دیتابیس، می‌توانیم از Distributed Locks مانند Redis Redlock استفاده کنیم. قفل‌های توزیع‌شده مبتنی بر حافظه کش هستند و بار روی پایگاه داده رابطه‌ای را کاهش می‌دهند. این روش برای سیستم‌های کلان‌مقیاس که پایگاه داده اصلی نباید درگیر محاسبات قفل صف شود بسیار کارآمد است.

مدیریت خطاهای ارسال و مکانیزم Dead Letter محلی

برخی پیام‌ها به دلیل خطاهای ساختاری مانند فرمت نامعتبر داده یا ناسازگاری‌های غیرقابل پیش‌بینی، هرگز با موفقیت ارسال نمی‌شوند و اگر سیستم را به حال خود رها کنید، در یک حلقه تلاش مجدد بی‌پایان گیر می‌کند. تصور کنید یک رکورد معیوب در جدول Outbox دارید که هر بار سیستم تلاش می‌کند آن را به سمت بروکرهای پیام بفرستد، با خطای دیتابیس یا سریالایزشن مواجه می‌شود و پردازش بقیه پیام‌های حیاتی را مسدود می‌کند.

روش ساده‌لوحانه این است که یک حلقه تکرار بدون سقف بسازیم یا فاقد مکانیزم شمارش خطا باشیم که در نتیجه آن، تلاش بی‌نهایت برای ارسال یک پیام معیوب کل صف پردازش Outbox را به کام قفل شدن می‌کشد.

انتخاب مهندسی ما افزودن ستون تعداد تلاش‌ها یعنی RetryCount و انتقال هوشمندانه پیام‌های دارای خطای مداوم به جدول Dead Letter یا تغییر وضعیت صریح به Failed است تا چرخه حیات سیستم پایدار بماند.

Failed Send -> Increment RetryCount -> If > MaxRetries -> Mark as Failed/Dead Letter

در این بخش منطق مدیریت خطای مقاوم در برابر خطا یا همان Fault Tolerant را پیاده‌سازی می‌کنیم که به سیستم اجازه می‌دهد به جای تسلیم شدن در برابر یک خطای ساختاری، جلوی فلج شدن کل زیرساخت را بگیرد. اگر پیامی بیش از ۳ بار شکست بخورد، از چرخه عادی خارج شده و به تیم فنی هشدار داده می‌شود تا داده‌های معیوب بررسی شوند.

if (message.RetryCount >= 3) {
    message.Status = "Failed";
    // Log critical error or send to DLQ
} else {
    message.RetryCount++;
}

در قطعه کد بالا، عدد ۳ به عنوان آستانه استاندارد تلاش مجدد قبل از اعلام شکست قطعی انتخاب شده است تا خطاهای موقت شبکه فرصت جبران داشته باشند اما خطاهای دائمی منابع سیستم را نبلعند.

اگر این آستانه را بیش از حد بالا ببرید، صف پردازش شما معطل پیام‌های معیوب می‌ماند و اگر آن را خیلی پایین بگیرید، خطاهای لحظه‌ای و کوچک شبکه بلافاصله پیام‌ها را می‌سوزانند.

پس از اعمال این منطق و رسیدن شمارنده به حد نصاب، خروجی سیستم در لاگ‌ها نشان می‌دهد که پیام از جدول اصلی خارج شده است.

[202X-XX-XX 10:30:15] [WARN] Outbox Message ID: 4892 with RetryCount: 3 reached max limit. Status changed to Failed and moved to Dead Letter queue.

این لاگ به تیم مهندسی اعلام می‌کند که مداخله دستی یا بررسی داده‌های ورودی برای این شناسه خاص الزامی است.

برای بهینه‌سازی بیشتر این مکانیزم، می‌توانیم از الگوهای متفاوتی در به‌روزرسانی وضعیت استفاده کنیم:

  • متد Linear Backoff: تلاش مجدد با فاصله‌های زمانی ثابت مثلاً هر ۱۰ دقیقه.
  • متد Exponential Backoff: افزایش تصاعدی فاصله زمانی مانند ۲، ۴ و سپس ۸ دقیقه بین هر تلاش برای کاهش بار روی سرورهای مقصد.
  • متد Immediate Drop: انتقال مستقیم به وضعیت Failed در صورت بروز خطاهای ساختاری و غیرقابل بازیابی.

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

اکنون آماده‌ایم تمامی این اجزا را در یک پروژه واقعی و یکپارچه ترکیب کنیم تا چرخه حیات پیام از تولید تا ارسال نهایی و مدیریت خطا کامل شود.

یکپارچه‌سازی نهایی و سناریوی جامع ثبت سفارش در میکروسرویس

داشتن قطعات منفرد مانند جدول Outbox، مکانیسم قفل‌گذاری و سرویس پس‌زمینه کافی نیست؛ باید دید چگونه تمام این اجزا در یک API واقعی برای ثبت سفارش در کنار هم کار می‌کنند تا سازگاری داده‌ها تضمین شود.

ترکیب پراکنده و بدون ساختار کدهای پایگاه داده و بروکر بدون معماری تمیز، در اولین خطای شبکه منجر به گم شدن پیام‌ها یا ثبت سفارش‌های تکراری می‌شود.

به جای این رویکرد شکننده، از یکپارچه‌سازی کامل الگو در یک معماری تمیز (Clean Architecture) همراه با تزریق وابستگی و مدیریت چرخه حیات بهره می‌گیریم تا تراکنش دیتابیس و ثبت Outbox کاملاً اتمی اجرا شوند.

Client API -> Order Controller -> EF Core Transaction (Order + Outbox) -> Background Worker -> Message Broker

در این بخش یک سناریوی کامل از ثبت سفارش تا انتشار رویداد OrderCreated را به صورت کدنویسی‌شده پیاده‌سازی می‌کنیم. تمام مفاهیم قبلی شامل تراکنش اتمی، سرویس پس‌زمینه، قفل‌گذاری و مدیریت خطا در این سناریو به کار گرفته می‌شوند.

در پروژه استاندارد ASP.NET Core Web API، کنترلر سفارش مسئول دریافت درخواست کلاینت و اعمال تراکنش پایگاه داده است.

[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto) {
    // Implementation combining transaction and outbox insertion
    return Ok(new { Message = "Order created successfully" });
}

کدهای پاسخ HTTP استاندارد مانند 200 OK یا 201 Created برای موفقیت عملیات استفاده می‌شوند تا کلاینت از ثبت قطعی سفارش مطمئن شود.

پاسخ JSON موفقیت‌آمیز به کلاینت شامل شناسه‌ی سفارش و وضعیت پردازش است که هم‌زمان با ذخیره شدن رویداد در جدول Outbox بازگردانده می‌شود.

{
  "orderId": "d3b07384-d113-4ec2-a499-e37ada749c2d",
  "status": "PendingPublish",
  "message": "Order created successfully"
}

نکته مهم: تست‌های یکپارچگی (Integration Tests) برای سناریوهای Outbox بسیار حیاتی هستند؛ حتماً سناریوهای قطع اتصال بروکر را در تست‌های خود شبیه‌سازی کنید تا مطمئن شوید تراکنش دیتابیس مانع از دست رفتن رویدادها می‌شود.

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

بهینه‌سازی عملکرد، پاکسازی داده‌ها و نکات مقیاس‌پذیری

وقتی جدول Outbox شما بدون وقفه پیام‌های جدید ثبت می‌کند، عملکرد دیتابیس به مرور افت می‌کند زیرا ایندکس‌ها سنگین و جستجوها کند می‌شوند.

راه Junior این است که جدول Outbox را به حال خود رها کند تا میلیون‌ها رکورد قدیمی انباشته شوند و سیستم در نهایت زیر بار کوئری‌های سنگین متوقف شود. به جای این، یک مکانیزم پاکسازی خودکار پیاده‌سازی می‌کنیم تا داده‌های قدیمی را مدیریت کند و کارایی پایگاه داده پایدار بماند.

Cleanup Worker -> Query Processed Messages Older than X Days -> Delete/Archive -> Optimize DB

برای مدیریت حجم جدول و نگهداری لاگ‌ها در سطح تولید، یک Background Service می‌سازیم که پیام‌های پردازش‌شده قدیمی را پاک کند. این کار از انفجار حجم جدول جلوگیری می‌کند.

var oldMessages = _dbContext.OutboxMessages
    .Where(m => m.ProcessedDate < DateTime.UtcNow.AddDays(-7));
_dbContext.OutboxMessages.RemoveRange(oldMessages);
await _dbContext.SaveChangesAsync();

در این کد، بازه ۷ روزه برای نگهداری پیام‌های پردازش‌شده انتخاب شده است تا اگر خطایی رخ داد، زمان کافی برای عیب‌یابی و حسابرسی وجود داشته باشد. کمتر از این مقدار، شانس دیباگ را از بین می‌برد و بیشتر از آن، حجم جدول را بی‌جهت بزرگ نگه می‌دارد.

پس از اجرای این عملیات، لاگ تعداد رکورد پاکسازی‌شده را در کنسول یا سیستم مانیتورینگ ثبت می‌کنیم تا وضعیت سلامت سیستم شفاف باشد.

_logger.LogInformation("Cleaned up {Count} processed outbox messages.", oldMessages.Count());

نکته مهم: عملیات پاکسازی را همیشه در ساعات کم‌ترافیک سیستم اجرا کنید تا فشاری روی منابع دیتابیس در زمان پیک مصرف وارد نشود.

برای سیستم‌های حساس مالی یا سازمانی که حذف کامل داده‌ها مجاز نیست، به جای حذف مستقیم، از مکانیزم آرشیو استفاده می‌کنیم. در این سناریو، رکوردها به جدول OutboxArchives منتقل می‌شوند تا سابقه داده‌ها حفظ شود.

این مطلب رو دوست داشتی؟ با دوستات به اشتراک بزار 😋