الگوی Saga
وقتی یک کار باید در چند سرویس انجام شود و تراکنش مشترک نداریم، کار را به چند قدم کوچک تقسیم کن. برای هر قدم یک کار جبرانی آماده داشته باش. اگر یک قدم شکست خورد، قدمهای قبلی را جبران کن.
نویسنده: bezzad
مشکل: تراکنش در چند سرویس
مشتری یک خرید انجام میدهد. سیستم باید چهار کار انجام دهد: سفارش را ثبت کند، کالا را در انبار رزرو کند، پول را بگیرد و بسته را ارسال کند.
در یک Monolith با یک دیتابیس، کار ساده است. همه را داخل یک تراکنش میگذاریم. اگر یک قدم شکست بخورد، دیتابیس همه را برمیگرداند (Rollback).
ولی در Microservices، هر سرویس دیتابیس خودش را دارد. تراکنش مشترک نداریم.
حالا فرض کن پول گرفته شد، ولی انبار گفت «این کالا تمام شده». در سیستم ما:
- پول مشتری کم شده است.
- کالایی برای ارسال نیست.
- هیچ دیتابیسی این را خودکار برنمیگرداند.
Saga راه حل همین مشکل است.
چرا تراکنش توزیعشده (2PC) نه؟
یک راه قدیمی تراکنش دو مرحلهای (Two-Phase Commit) است. یک هماهنگکننده از همه دیتابیسها میپرسد «آمادهای؟». اگر همه گفتند بله، به همه میگوید «ثبت کن». این روش در Microservices معمولاً مناسب نیست:
- قفل طولانی. تا آخرین سرویس جواب ندهد، همه رکوردها قفل میمانند.
- وابستگی به همه. اگر یک سرویس یا هماهنگکننده از کار بیفتد، بقیه منتظر میمانند.
- پشتیبانی کم. Kafka، RabbitMQ، Redis و بیشتر دیتابیسهای NoSQL در 2PC شرکت نمیکنند.
ایده Saga: قدمهای کوچک و کار جبرانی
کار بزرگ را به چند تراکنش محلی تقسیم میکنیم. هر سرویس فقط تراکنش دیتابیس خودش را انجام میدهد. برای هر قدم، یک کار جبرانی (Compensation) هم مینویسیم که اثر آن قدم را خنثی میکند.
سه نوع قدم داریم:
- قدم قابل جبران. اگر بعداً مشکلی پیش آمد، کار جبرانی دارد. مثل رزرو کالا.
- قدم بیبازگشت (Pivot). اگر این قدم موفق شود، Saga دیگر برنمیگردد. در مثال ما، گرفتن پول.
- قدم تکرارشونده. بعد از نقطه بیبازگشت میآید و باید آخر کار موفق شود. اگر شکست خورد، دوباره تلاش میکنیم. مثل ارسال بسته.
مثال زنده
یک سناریو را انتخاب کن و ببین Saga قدم به قدم چه میکند:
جبران، Rollback نیست
Rollback دیتابیس طوری رفتار میکند که انگار هیچ اتفاقی نیفتاده است. ولی در Saga، هر قدم واقعاً انجام شده و ممکن است دیگران آن را دیده باشند.
Rollback دیتابیس
- تغییر هیچ وقت دیده نشد.
- خودکار است.
- اثری باقی نمیماند.
کار جبرانی در Saga
- تغییر انجام شده و شاید دیده شده است.
- خودمان باید آن را بنویسیم.
- اثرش میماند. مثلاً مشتری یک پیامک «سفارش لغو شد» میگیرد.
برای همین، کار جبرانی یک کار کسبوکاری است، نه یک حذف ساده. مثلاً اگر سفارشی بعد از پرداخت لغو شود، جبرانِ پرداخت «برگشت پول» است. رکورد پرداخت را پاک نمیکنیم. یک رکورد برگشت پول اضافه میکنیم.
دو روش اجرا: Choreography و Orchestration
روش اول: Choreography (هر سرویس به رویداد قبلی گوش میدهد)
هیچ مدیری وجود ندارد. هر سرویس کارش را انجام میدهد و یک رویداد میفرستد. سرویس بعدی به آن رویداد گوش میدهد.
روش دوم: Orchestration (یک مدیر، دستور میدهد)
یک بخش به اسم Orchestrator کل جریان را میداند. به هر سرویس دستور میدهد، جواب را میگیرد و قدم بعدی را تصمیم میگیرد.
| Choreography | Orchestration | |
|---|---|---|
| کجا جریان را میبینیم؟ | پخش در همه سرویسها | یک جا، داخل Orchestrator |
| وابستگی سرویسها | خیلی کم | همه به Orchestrator وابستهاند |
| اضافه کردن قدم جدید | چند سرویس تغییر میکند | فقط Orchestrator تغییر میکند |
| پیدا کردن خطا | سخت، باید رویدادها را دنبال کرد | آسان، وضعیت هر Saga ذخیره شده است |
| مناسب برای | جریان کوتاه با ۲ یا ۳ قدم | جریان طولانی یا با شرطهای زیاد |
کد
نسخه ساده برای فهمیدن ایده
هر قدم که موفق شد، کار جبرانیاش را روی یک پشته (Stack) میگذاریم. اگر قدمی شکست خورد، کارهای جبرانی را به ترتیب برعکس اجرا میکنیم.
public sealed class PlaceOrderSaga(
IOrders orders, IWarehouse warehouse, IPayments payments, IShipping shipping)
{
public async Task RunAsync(Order order, CancellationToken ct)
{
var compensations = new Stack<Func<Task>>();
try
{
await orders.CreateAsync(order, ct);
compensations.Push(() => orders.CancelAsync(order.Id));
await warehouse.ReserveAsync(order.Id, order.Items, ct);
compensations.Push(() => warehouse.ReleaseAsync(order.Id));
await payments.ChargeAsync(order.Id, order.Total, ct);
// Pivot: after a successful payment we only move forward.
}
catch
{
while (compensations.TryPop(out var compensate))
await compensate();
throw;
}
await shipping.ShipWithRetryAsync(order.Id, ct);
}
}
نسخه واقعی: وضعیت در دیتابیس
در Production، وضعیت هر Saga باید در دیتابیس ذخیره شود. کتابخانه MassTransit این کار را با State Machine انجام میدهد. هر سفارش یک ردیف وضعیت دارد. هر پیام، Saga را از یک وضعیت به وضعیت بعدی میبرد:
public sealed class OrderState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; } // = OrderId
public string CurrentState { get; set; } = "";
}
public sealed class OrderSaga : MassTransitStateMachine<OrderState>
{
public State Reserving { get; private set; } = null!;
public State Paying { get; private set; } = null!;
public State Completed { get; private set; } = null!;
public State Cancelled { get; private set; } = null!;
public Event<OrderCreated> OrderCreated { get; private set; } = null!;
public Event<StockReserved> StockReserved { get; private set; } = null!;
public Event<StockFailed> StockFailed { get; private set; } = null!;
public Event<PaymentDone> PaymentDone { get; private set; } = null!;
public Event<PaymentFailed> PaymentFailed { get; private set; } = null!;
public OrderSaga()
{
// Every message has a CorrelationId (the OrderId),
// so MassTransit finds the right saga row by itself.
InstanceState(x => x.CurrentState);
Initially(
When(OrderCreated)
.Publish(ctx => new ReserveStock(ctx.Saga.CorrelationId))
.TransitionTo(Reserving));
During(Reserving,
When(StockReserved)
.Publish(ctx => new ChargePayment(ctx.Saga.CorrelationId))
.TransitionTo(Paying),
When(StockFailed)
.Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId))
.TransitionTo(Cancelled));
During(Paying,
When(PaymentDone)
.Publish(ctx => new ShipOrder(ctx.Saga.CorrelationId))
.TransitionTo(Completed),
When(PaymentFailed)
.Publish(ctx => new ReleaseStock(ctx.Saga.CorrelationId))
.Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId))
.TransitionTo(Cancelled));
}
}
این کد دقیقاً همان شکل بالا است. هر خط During یک وضعیت است. هر When یک پیام است که Saga منتظر آن است. وضعیت را میشود با EF Core یا Redis ذخیره کرد.
قانونهای مهم
- هر قدم و هر کار جبرانی Idempotent باشد. پیام ممکن است دو بار برسد. اگر «آزاد کردن کالا» دو بار اجرا شود، نباید دو برابر کالا آزاد شود. با شناسه سفارش چک کن که قبلاً انجام شده یا نه.
- کار جبرانی نباید شکست نهایی بخورد. اگر خطا داد، با فاصله دوباره تلاش کن. اگر بعد از چند بار باز هم نشد، به صف خطا (Dead Letter) بفرست و به یک آدم هشدار بده. هیچ وقت آن را بیصدا رها نکن.
- وضعیت Saga را ذخیره کن. بعد از restart، Saga باید از همان قدمی که بود ادامه دهد.
- تغییر دیتابیس و ارسال پیام با هم باشد. از الگوی Outbox استفاده کن. وگرنه ممکن است دیتابیس تغییر کند، ولی پیام هیچ وقت ارسال نشود.
- Timeout بگذار. اگر سرویس انبار هیچ وقت جواب نداد، Saga نباید تا ابد منتظر بماند. بعد از مدت مشخص، جبران را شروع کن.
- کار جبرانی ممکن است قبل از خود کار برسد. مثلاً «لغو رزرو» قبل از «رزرو» برسد. سرویس باید این را ثبت کند تا وقتی «رزرو» رسید، آن را انجام ندهد.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| نگه داشتن وضعیت Saga فقط در حافظه | بعد از restart، Saga نیمهکاره میماند. | وضعیت در دیتابیس. |
| جبران با حذف رکورد | تاریخچه از بین میرود و حسابرسی ممکن نیست. | یک رکورد جبرانی اضافه کن، مثل «برگشت پول». |
| قدمهای غیر Idempotent | پیام تکراری، پول را دو بار کم میکند. | شناسه یکتا برای هر قدم و چک تکراری بودن. |
| نشان دادن «تأیید شد» قبل از پایان Saga | مشتری فکر میکند خرید تمام شده، ولی بعد لغو میشود. | وضعیت «در حال پردازش» تا پایان Saga. |
| Choreography با قدمهای زیاد | هیچ کس کل جریان را نمیفهمد. | Orchestration. |
| بدون Timeout | Saga تا ابد منتظر یک جواب میماند. | Timeout و شروع جبران. |
چه وقت Saga؟
مناسب
- یک کار کسبوکاری در چند سرویس با دیتابیس جدا.
- هر قدم یک جبران معقول دارد.
- ناهماهنگی چند ثانیهای برای کسبوکار قابل قبول است.
نامناسب
- همه داده در یک دیتابیس است. یک تراکنش معمولی کافی است.
- کسبوکار هیچ لحظه ناهماهنگی را قبول نمیکند.
- مرز سرویسها غلط است و هر کار ساده به چند سرویس نیاز دارد. اول مرزها را درست کن.
خلاصه در شش خط
- در Microservices تراکنش مشترک نداریم.
- Saga کار بزرگ را به چند تراکنش محلی تقسیم میکند.
- برای هر قدم یک کار جبرانی داریم که به ترتیب برعکس اجرا میشود.
- جبران یک کار کسبوکاری است، نه پاک کردن داده.
- برای جریان کوتاه Choreography، برای جریان طولانی Orchestration.
- وضعیت ذخیرهشده، Idempotency، Outbox و Timeout را فراموش نکن.
سؤالهای مرتبط: ۵. پیام گمشده و پیام تکراری · ۱۵. تکرار درخواست در پرداخت · ۲۱. Idempotency Key · ۲۵. شکست در جبران کار، Saga