Как реализуется паттерн Saga для координации транзакций между сервисами?

Ответ

Saga — это паттерн для управления последовательностью локальных транзакций в разных сервисах, которая в целом представляет собой бизнес-транзакцию. При сбое на каком-либо шаге выполняются компенсирующие транзакции (откаты) для предыдущих шагов.

Есть два основных стиля реализации:

1. Хореография (Choreography):

  • Принцип: Сервисы общаются через события (event-driven). Каждый сервис знает, на какие события реагировать и какие публиковать дальше.
  • Пример (Заказ товара):
    1. OrderService создает заказ в статусе Pending и публикует событие OrderCreated.
    2. PaymentService слушает OrderCreated, списывает деньги и публикует PaymentCompleted.
    3. InventoryService слушает PaymentCompleted, резервирует товар и публикует InventoryReserved.
    4. OrderService слушает InventoryReserved и меняет статус заказа на Completed.
  • Плюсы: Децентрализация, слабая связанность.
  • Минусы: Сложно отслеживать общий поток, циклические зависимости.

2. Оркестрация (Orchestration):

  • Принцип: Центральный координатор (оркестратор) управляет процессом, отправляя команды сервисам и обрабатывая их ответы.
  • Пример реализации с БД для отслеживания состояния:
    -- Таблица для хранения состояния каждой саги
    CREATE TABLE OrderSaga (
        SagaId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
        OrderId INT NOT NULL,
        CurrentStep INT NOT NULL,
        Status NVARCHAR(20) NOT NULL CHECK (Status IN ('Pending', 'Completed', 'Failed', 'Compensating')),
        SagaData NVARCHAR(MAX) -- JSON с данными для компенсации
    );
  • Логика оркестратора (псевдокод):

    // 1. Начать сагу, записать в БД
    var saga = new OrderSaga { OrderId = orderId, Status = "Pending" };
    _dbContext.Sagas.Add(saga);
    await _dbContext.SaveChangesAsync();
    
    // 2. Выполнить шаги последовательно
    try {
        await _paymentService.ChargeAsync(orderId);
        saga.CurrentStep = 1;
        await _inventoryService.ReserveAsync(orderId);
        saga.CurrentStep = 2;
        saga.Status = "Completed";
    }
    catch (Exception) {
        // 3. Запустить компенсацию в обратном порядке
        saga.Status = "Compensating";
        if (saga.CurrentStep >= 2) await _inventoryService.ReleaseAsync(orderId);
        if (saga.CurrentStep >= 1) await _paymentService.RefundAsync(orderId);
        saga.Status = "Failed";
    }
    finally {
        await _dbContext.SaveChangesAsync();
    }

Критически важные требования для Saga:

  • Идемпотентность: Каждый шаг и компенсация должны быть идемпотентными (повторный вызов с теми же данными не должен менять результат). Это защищает от повторной обработки сообщений.
  • Надежная доставка команд/событий: Достигается через паттерн Outbox (сохранение исходящего сообщения в БД в той же транзакции, что и изменение состояния) и фонового процесса-релея.

Инструменты: Для .NET можно использовать готовые фреймворки, такие как MassTransit или NServiceBus, которые предоставляют встроенную поддержку Saga с persistence-слоем.

Ответ 18+ 🔞

А, саги, блядь! Ну это ж классика, когда у тебя одна операция по бизнесу раскидана по куче сервисов, и надо, чтобы они все как-то согласованно отработали, а не накосячили. Представь, что ты заказываешь пиццу, пиво и чипсы в разных местах, но платишь одной картой. Если чипсы кончились, то и пиццу с пивом уже как-то не очень, и деньги назад вернуть надо. Вот сага примерно про это.

Так, есть два главных подхода, как эту кашу организовать:

1. Хореография (Chореография). Типа анархия, мать её. Каждый сервис сам за себя. Один что-то сделал — крикнул об этом всему миру (событие кинул). Другой услышал, сделал своё дело и тоже крикнул. И так по цепочке.

  • Как это выглядит на примере заказа:
    1. Сервис заказов (OrderService) создаёт заказ в состоянии «ждун» и орет: OrderCreated!
    2. Сервис оплаты (PaymentService) слышит этот крик, списывает бабки и орет: PaymentCompleted!
    3. Сервис склада (InventoryService) слышит про оплату, резервирует товар и орет: InventoryReserved!
    4. Сервис заказов снова в деле, слышит, что товар зарезервирован, и переводит заказ в «готово».
  • Плюсы: Все независимые, связанность слабая, сервисы не знают друг о друге напрямую.
  • Минусы: А чёрт его знает, кто сейчас что делает! Поток событий проследить — голову сломаешь. И если логика сложная, можно накрутить таких циклических зависимостей, что сам потом будешь плакать.

2. Оркестрация. Тут уже появляется главный по тарелочкам — оркестратор. Он, как мама в семье, всем командует: ты сделай то, ты сделай это. И сам же помнит, кто что уже сделал.

  • Оркестратору надо где-то состояние хранить. Обычно в БД.
    -- Таблица, где оркестратор свои дела записывает
    CREATE TABLE OrderSaga (
        SagaId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
        OrderId INT NOT NULL,
        CurrentStep INT NOT NULL, -- На каком шаге остановился
        Status NVARCHAR(20) NOT NULL CHECK (Status IN ('Pending', 'Completed', 'Failed', 'Compensating')),
        SagaData NVARCHAR(MAX) -- Тут можно данные для отката в JSON затолкать
    );
  • А вот как он примерно рулит (псевдокод):

    // 1. Начинаем сагу, записываем в базу факт старта
    var saga = new OrderSaga { OrderId = orderId, Status = "Pending" };
    _dbContext.Sagas.Add(saga);
    await _dbContext.SaveChangesAsync();
    
    // 2. Гоняем сервисы по очереди
    try {
        await _paymentService.ChargeAsync(orderId); // Шаг 1: списать деньги
        saga.CurrentStep = 1;
        await _inventoryService.ReserveAsync(orderId); // Шаг 2: резервировать товар
        saga.CurrentStep = 2;
        saga.Status = "Completed"; // Ура, всё заебись!
    }
    catch (Exception) {
        // 3. Если где-то пиздец (эксепшн), начинаем откатывать
        saga.Status = "Compensating";
        // Откатываем в обратном порядке! Сначала склад, потом оплату
        if (saga.CurrentStep >= 2) await _inventoryService.ReleaseAsync(orderId);
        if (saga.CurrentStep >= 1) await _paymentService.RefundAsync(orderId);
        saga.Status = "Failed"; // Помечаем, что всё просрано
    }
    finally {
        await _dbContext.SaveChangesAsync(); // Сохраняем итоговое состояние саги
    }

Но вот что самое, блядь, важное, без чего сага развалится как карточный домик:

  • Идемпотентность. Это пиздец как важно! Каждый шаг и каждая компенсирующая операция должны быть идемпотентными. То есть если ты их вызовешь два раза с одними данными — результат должен быть как от одного вызова. А то представь, сообщение продублировалось, и оплату с тебя списали дважды. Или откат резервации товара выполнился два раза. Короче, пиздец, а не система.
  • Надёжная доставка. Команды от оркестратора или события между сервисами должны доходить гарантированно. Для этого обычно используют паттерн Outbox — сначала записываешь исходящее сообщение в свою же базу (в той же транзакции, что и изменение состояния), а потом фоновый работяга (релей) его оттуда выгребает и гарантированно отправляет куда надо. Чтобы не было ситуации «деньги списались, а событие об этом потерялось».

Инструменты: Не городи велосипед, чувак. Для .NET есть готовые катки, типа MassTransit или NServiceBus. Они из коробки умеют и саги с состоянием хранить, и идемпотентность обрабатывать, и outbox паттерн реализовывать. Голыми руками это всё делать — тот ещё геморрой, ей-богу.