Levelwise
فارسی
سیستم‌های توزیع‌شده

الگوی Saga

وقتی یک کار باید در چند سرویس انجام شود و تراکنش مشترک نداریم، کار را به چند قدم کوچک تقسیم کن. برای هر قدم یک کار جبرانی آماده داشته باش. اگر یک قدم شکست خورد، قدم‌های قبلی را جبران کن.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۵ دقیقهمثال خرید از فروشگاه اینترنتیکد C# و MassTransit

نویسنده: bezzad

مشکل: تراکنش در چند سرویس

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

در یک Monolith با یک دیتابیس، کار ساده است. همه را داخل یک تراکنش می‌گذاریم. اگر یک قدم شکست بخورد، دیتابیس همه را برمی‌گرداند (Rollback).

ولی در Microservices، هر سرویس دیتابیس خودش را دارد. تراکنش مشترک نداریم.

Monolithیک تراکنشسفارشانبارپرداختیک دیتابیسMicroservicesسفارشانبارپرداختسه دیتابیس، بدون تراکنش مشترک

حالا فرض کن پول گرفته شد، ولی انبار گفت «این کالا تمام شده». در سیستم ما:

  • پول مشتری کم شده است.
  • کالایی برای ارسال نیست.
  • هیچ دیتابیسی این را خودکار برنمی‌گرداند.

Saga راه حل همین مشکل است.

چرا تراکنش توزیع‌شده (2PC) نه؟

یک راه قدیمی تراکنش دو مرحله‌ای (Two-Phase Commit) است. یک هماهنگ‌کننده از همه دیتابیس‌ها می‌پرسد «آماده‌ای؟». اگر همه گفتند بله، به همه می‌گوید «ثبت کن». این روش در Microservices معمولاً مناسب نیست:

  1. قفل طولانی. تا آخرین سرویس جواب ندهد، همه رکوردها قفل می‌مانند.
  2. وابستگی به همه. اگر یک سرویس یا هماهنگ‌کننده از کار بیفتد، بقیه منتظر می‌مانند.
  3. پشتیبانی کم. Kafka، RabbitMQ، Redis و بیشتر دیتابیس‌های NoSQL در 2PC شرکت نمی‌کنند.

ایده Saga: قدم‌های کوچک و کار جبرانی

کار بزرگ را به چند تراکنش محلی تقسیم می‌کنیم. هر سرویس فقط تراکنش دیتابیس خودش را انجام می‌دهد. برای هر قدم، یک کار جبرانی (Compensation) هم می‌نویسیم که اثر آن قدم را خنثی می‌کند.

مسیر جلو ←۱. ثبت سفارشسرویس سفارش۲. رزرو کالاسرویس انبار۳. گرفتن پولسرویس پرداخت۴. ارسال بستهسرویس ارساللغو سفارشآزاد کردن کالا→ مسیر برگشت (جبران)نقطه بی‌بازگشتفقط تلاش دوبارهبعد از گرفتن پول، فقط جلو می‌رویم
اگر گرفتن پول شکست بخورد، کارهای جبرانی به ترتیب برعکس اجرا می‌شوند: اول آزاد کردن کالا، بعد لغو سفارش.

سه نوع قدم داریم:

  1. قدم قابل جبران. اگر بعداً مشکلی پیش آمد، کار جبرانی دارد. مثل رزرو کالا.
  2. قدم بی‌بازگشت (Pivot). اگر این قدم موفق شود، Saga دیگر برنمی‌گردد. در مثال ما، گرفتن پول.
  3. قدم تکرارشونده. بعد از نقطه بی‌بازگشت می‌آید و باید آخر کار موفق شود. اگر شکست خورد، دوباره تلاش می‌کنیم. مثل ارسال بسته.
چرا گرفتن پول بعد از رزرو کالا است؟ اول کاری را انجام بده که احتمال شکستش بیشتر و جبرانش ارزان‌تر است. آزاد کردن کالا برای مشتری اثری ندارد. ولی برگشت پول طول می‌کشد و مشتری را ناراحت می‌کند.

مثال زنده

یک سناریو را انتخاب کن و ببین Saga قدم به قدم چه می‌کند:

اجرای Saga خرید
۱
ثبت سفارشسرویس سفارش
جبران: لغو سفارش
۲
رزرو کالاسرویس انبار
جبران: آزاد کردن کالا
بی‌بازگشت
۳
گرفتن پولسرویس پرداخت
بعد از آن فقط جلو
۴
ارسال بستهسرویس ارسال
اگر شکست خورد: دوباره
یک سناریو را انتخاب کن.

    جبران، Rollback نیست

    Rollback دیتابیس طوری رفتار می‌کند که انگار هیچ اتفاقی نیفتاده است. ولی در Saga، هر قدم واقعاً انجام شده و ممکن است دیگران آن را دیده باشند.

    Rollback دیتابیس

    • تغییر هیچ وقت دیده نشد.
    • خودکار است.
    • اثری باقی نمی‌ماند.

    کار جبرانی در Saga

    • تغییر انجام شده و شاید دیده شده است.
    • خودمان باید آن را بنویسیم.
    • اثرش می‌ماند. مثلاً مشتری یک پیامک «سفارش لغو شد» می‌گیرد.

    برای همین، کار جبرانی یک کار کسب‌وکاری است، نه یک حذف ساده. مثلاً اگر سفارشی بعد از پرداخت لغو شود، جبرانِ پرداخت «برگشت پول» است. رکورد پرداخت را پاک نمی‌کنیم. یک رکورد برگشت پول اضافه می‌کنیم.

    ناهماهنگی موقت: بین قدم‌ها، سیستم برای مدت کوتاهی ناهماهنگ است. مثلاً کالا رزرو شده، ولی هنوز پولی گرفته نشده. پس وضعیت سفارش را «در حال پردازش» نشان بده، نه «تأیید شد». به این کار Semantic Lock می‌گوییم.

    دو روش اجرا: Choreography و Orchestration

    روش اول: Choreography (هر سرویس به رویداد قبلی گوش می‌دهد)

    هیچ مدیری وجود ندارد. هر سرویس کارش را انجام می‌دهد و یک رویداد می‌فرستد. سرویس بعدی به آن رویداد گوش می‌دهد.

    سفارشانبارپرداختارسالOrderCreatedStockReservedPaymentDoneکل جریان در هیچ جای کد یک‌جا دیده نمی‌شود

    روش دوم: Orchestration (یک مدیر، دستور می‌دهد)

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

    Saga Orchestratorوضعیت را در دیتابیس نگه می‌داردانبارپرداختارسالدستورجواب
    Choreography Orchestration
    کجا جریان را می‌بینیم؟ پخش در همه سرویس‌ها یک جا، داخل Orchestrator
    وابستگی سرویس‌ها خیلی کم همه به Orchestrator وابسته‌اند
    اضافه کردن قدم جدید چند سرویس تغییر می‌کند فقط Orchestrator تغییر می‌کند
    پیدا کردن خطا سخت، باید رویدادها را دنبال کرد آسان، وضعیت هر Saga ذخیره شده است
    مناسب برای جریان کوتاه با ۲ یا ۳ قدم جریان طولانی یا با شرط‌های زیاد
    قانون ساده: اگر برای فهمیدن جریان باید کد چهار سرویس را باز کنی، به Orchestration برو.

    کد

    نسخه ساده برای فهمیدن ایده

    هر قدم که موفق شد، کار جبرانی‌اش را روی یک پشته (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 فقط در حافظه است. اگر سرور وسط کار restart شود، هیچ کس نمی‌داند کدام قدم‌ها انجام شده‌اند. کالا رزرو می‌ماند و هیچ وقت آزاد نمی‌شود.

    نسخه واقعی: وضعیت در دیتابیس

    در Production، وضعیت هر Saga باید در دیتابیس ذخیره شود. کتابخانه MassTransit این کار را با State Machine انجام می‌دهد. هر سفارش یک ردیف وضعیت دارد. هر پیام، Saga را از یک وضعیت به وضعیت بعدی می‌برد:

    ReservingPayingCompletedCancelledکالا رزرو شدپول گرفته شدکالا نبودپرداخت رد شد
    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 ذخیره کرد.

    قانون‌های مهم

    1. هر قدم و هر کار جبرانی Idempotent باشد. پیام ممکن است دو بار برسد. اگر «آزاد کردن کالا» دو بار اجرا شود، نباید دو برابر کالا آزاد شود. با شناسه سفارش چک کن که قبلاً انجام شده یا نه.
    2. کار جبرانی نباید شکست نهایی بخورد. اگر خطا داد، با فاصله دوباره تلاش کن. اگر بعد از چند بار باز هم نشد، به صف خطا (Dead Letter) بفرست و به یک آدم هشدار بده. هیچ وقت آن را بی‌صدا رها نکن.
    3. وضعیت Saga را ذخیره کن. بعد از restart، Saga باید از همان قدمی که بود ادامه دهد.
    4. تغییر دیتابیس و ارسال پیام با هم باشد. از الگوی Outbox استفاده کن. وگرنه ممکن است دیتابیس تغییر کند، ولی پیام هیچ وقت ارسال نشود.
    5. Timeout بگذار. اگر سرویس انبار هیچ وقت جواب نداد، Saga نباید تا ابد منتظر بماند. بعد از مدت مشخص، جبران را شروع کن.
    6. کار جبرانی ممکن است قبل از خود کار برسد. مثلاً «لغو رزرو» قبل از «رزرو» برسد. سرویس باید این را ثبت کند تا وقتی «رزرو» رسید، آن را انجام ندهد.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    نگه داشتن وضعیت Saga فقط در حافظه بعد از restart، Saga نیمه‌کاره می‌ماند. وضعیت در دیتابیس.
    جبران با حذف رکورد تاریخچه از بین می‌رود و حسابرسی ممکن نیست. یک رکورد جبرانی اضافه کن، مثل «برگشت پول».
    قدم‌های غیر Idempotent پیام تکراری، پول را دو بار کم می‌کند. شناسه یکتا برای هر قدم و چک تکراری بودن.
    نشان دادن «تأیید شد» قبل از پایان Saga مشتری فکر می‌کند خرید تمام شده، ولی بعد لغو می‌شود. وضعیت «در حال پردازش» تا پایان Saga.
    Choreography با قدم‌های زیاد هیچ کس کل جریان را نمی‌فهمد. Orchestration.
    بدون Timeout Saga تا ابد منتظر یک جواب می‌ماند. Timeout و شروع جبران.

    چه وقت Saga؟

    مناسب

    • یک کار کسب‌وکاری در چند سرویس با دیتابیس جدا.
    • هر قدم یک جبران معقول دارد.
    • ناهماهنگی چند ثانیه‌ای برای کسب‌وکار قابل قبول است.

    نامناسب

    • همه داده در یک دیتابیس است. یک تراکنش معمولی کافی است.
    • کسب‌وکار هیچ لحظه ناهماهنگی را قبول نمی‌کند.
    • مرز سرویس‌ها غلط است و هر کار ساده به چند سرویس نیاز دارد. اول مرزها را درست کن.

    خلاصه در شش خط

    1. در Microservices تراکنش مشترک نداریم.
    2. Saga کار بزرگ را به چند تراکنش محلی تقسیم می‌کند.
    3. برای هر قدم یک کار جبرانی داریم که به ترتیب برعکس اجرا می‌شود.
    4. جبران یک کار کسب‌وکاری است، نه پاک کردن داده.
    5. برای جریان کوتاه Choreography، برای جریان طولانی Orchestration.
    6. وضعیت ذخیره‌شده، Idempotency، Outbox و Timeout را فراموش نکن.

    سؤال‌های مرتبط: ۵. پیام گم‌شده و پیام تکراری · ۱۵. تکرار درخواست در پرداخت · ۲۱. Idempotency Key · ۲۵. شکست در جبران کار، Saga