Уровень 5 · Асинхронность Глава 19 9 мин

Sagas: распределённые транзакции

Orchestration vs Choreography. Compensating actions. Как декомпозировать долгую бизнес-операцию на цепочку локальных транзакций.

TL;DR

  • Saga — длинная бизнес-операция, разбитая на локальные транзакции + компенсации при откате.
  • Orchestration: центральный координатор запускает шаги. Проще отлаживать, риск SPOF.
  • Choreography: сервисы реагируют на события друг друга. Слабее связаны, сложнее визуализировать.
  • Compensating action ≠ rollback. Она делает семантически обратное, не восстанавливает состояние.

Зачем эта глава

Классическая проблема микросервисов: одна бизнес-операция затрагивает несколько сервисов. PlaceOrder резервирует товар (inventory), списывает деньги (billing), назначает shipping. Что если после резерва billing упал?

Раньше — 2PC (two-phase commit). Работало в монолите, плохо масштабируется в микросервисах. В современных системах — Sagas.

Идея не нова: Garcia-Molina & Salem описали Sagas в 1987 году для длинных транзакций в БД. В микросервисах паттерн переоткрыт и адаптирован.

Что такое Saga

Определение Saga (сага)

Последовательность локальных транзакций. Каждая транзакция обновляет один сервис и публикует событие/команду. При ошибке — цепочка компенсирующих транзакций отменяет предыдущие.

Пример PlaceOrder:

1. Order Service:      создать Order(status=PENDING)
2. Inventory Service:  зарезервировать товар
3. Billing Service:    списать оплату
4. Shipping Service:   создать shipment
5. Order Service:      Order.confirm()

Если на шаге 3 (Billing) ошибка:

Compensate 2. Inventory: отменить резерв
Compensate 1. Order:     Order.cancel(reason='billing failed')

Каждый шаг — обычная локальная транзакция в своём сервисе. Никакого 2PC.

Orchestration vs Choreography

Два способа реализовать Sagas.

Orchestration

Определение Saga Orchestration (оркестрация)

Центральный координатор (Saga orchestrator) явно управляет шагами: посылает команды сервисам, ждёт результата, при ошибке инициирует компенсацию.

class PlaceOrderSaga:
    async def execute(self, order_id: OrderId) -> None:
        try:
            await self._commands.send(ReserveInventory(order_id))
            reserved = await self._events.wait_for(InventoryReserved, order_id)

            await self._commands.send(ChargePayment(order_id))
            charged = await self._events.wait_for(PaymentCharged, order_id)

            await self._commands.send(CreateShipment(order_id))
            shipped = await self._events.wait_for(ShipmentCreated, order_id)

            await self._commands.send(ConfirmOrder(order_id))

        except SagaStepFailed as e:
            await self._compensate(order_id, failed_step=e.step)

Плюсы:

  • Явный контроль. Видно, что происходит.
  • Легко отлаживать — весь flow в одном месте.
  • Легко добавлять новые шаги.

Минусы:

  • Coordinator — потенциальная точка отказа.
  • Централизация — против духа микросервисов.
  • Логика бизнес-процесса собирается в одном сервисе.

Choreography

Определение Saga Choreography (хореография)

Нет координатора. Каждый сервис подписан на события других, реагирует на них своими локальными транзакциями и публикует свои события.

async def on_order_placed(event: OrderPlaced):
    try:
        await reserve_stock(event.order_id, event.items)
        await publish(InventoryReserved(event.order_id))
    except OutOfStock:
        await publish(InventoryReservationFailed(event.order_id, reason='out of stock'))
async def on_inventory_reserved(event: InventoryReserved):
    try:
        await charge(event.order_id)
        await publish(PaymentCharged(event.order_id))
    except CardDeclined:
        await publish(PaymentFailed(event.order_id, reason='card declined'))

Каждый сервис — независимый актор. Обмен через events.

Плюсы:

  • Максимально decoupled — сервисы не знают друг о друге.
  • Нет SPOF.
  • Соответствует духу event-driven.

Минусы:

  • Business flow не виден в одном месте. Чтобы понять saga, нужно читать код 5 сервисов.
  • Сложно добавлять шаги — нужно менять несколько сервисов согласованно.
  • Легко потерять контроль над цепочкой.

Compensating Actions ≠ Rollback

Это ключевое понятие Sagas.

Пример: ChargePayment списал 5000 ₽. Компенсация — RefundPayment(5000). Не «списание отменяется», а новая транзакция возврата.

Почему важно:

  • В аудите видны обе операции (списание + возврат).
  • Возврат может быть частичным (списали 5000, возврат 4000 из-за комиссии).
  • Compensating action может занять время (возврат на карту — до 30 дней).

Проектирование компенсаций

Каждый шаг saga имеет обратную операцию. Проектируем парами.

Прямое действиеКомпенсация
ReserveInventory(order)ReleaseInventory(order)
ChargePayment(order)RefundPayment(order)
CreateShipment(order)CancelShipment(order)
ConfirmOrder(order)CancelOrder(order)
SendEmail(...)❌ — email нельзя «отозвать»

Последнее — важный урок: не всё компенсируется. Некоторые side effects необратимы. Проектировать saga надо так, чтобы необратимые операции были последними — до них можно откатить всё, после них — уже нельзя.

Как правильно: полный сценарий

Пример: PlaceOrder через orchestration.

class PlaceOrderSaga:
    STEPS = [
        ('reserve_inventory', 'release_inventory'),
        ('charge_payment', 'refund_payment'),
        ('create_shipment', 'cancel_shipment'),
        ('confirm_order', None),  # necessary conclusion — не компенсируется
    ]

    async def execute(self, order_id: OrderId) -> None:
        completed_steps: list[str] = []
        try:
            for step_name, _ in self.STEPS:
                await self._run_step(step_name, order_id)
                completed_steps.append(step_name)
        except SagaStepFailed:
            await self._compensate(completed_steps, order_id)

    async def _compensate(self, completed_steps: list[str], order_id: OrderId) -> None:
        # компенсируем в обратном порядке
        for step_name in reversed(completed_steps):
            compensation = dict(self.STEPS)[step_name]
            if compensation:
                await self._run_step(compensation, order_id)

Ключевое: компенсации выполняются в обратном порядке. Если шаги были 1 → 2 → 3, компенсация — comp(3) → comp(2) → comp(1).

Состояние saga

Долгие saga переживают перезапуск сервиса. Значит, состояние надо где-то хранить.

CREATE TABLE saga_state (
    saga_id       UUID PRIMARY KEY,
    saga_type     TEXT NOT NULL,
    current_step  TEXT NOT NULL,
    payload       JSONB NOT NULL,
    status        TEXT NOT NULL,  -- 'running', 'completed', 'compensating', 'failed'
    created_at    TIMESTAMPTZ NOT NULL,
    updated_at    TIMESTAMPTZ NOT NULL
);

После каждого шага — commit нового состояния. Перезапуск — восстанавливаем saga с последнего сохранённого шага.

Как не надо

1. Sync-цепочка вместо Saga

async def place_order(cmd):
    order = await orders.save(Order.from_cmd(cmd))
    await inventory_api.reserve(order.items)                    # sync HTTP    
    await billing_api.charge(order.customer, order.total)       # sync HTTP    
    await shipping_api.create(order.address, order.items)       # sync HTTP    
    order.confirm()
    await orders.save(order)

Все шаги в одной функции, sync. При падении на шаге 3 — как откатить 1 и 2? Ручной catch → API-вызовы к откату → ошибка в откате → плохо.

Правильно: события + автоматизированная saga через orchestrator.

2. Отсутствие компенсаций

class PlaceOrderSaga:
    async def execute(self, order_id):
        try:
            await self._reserve_inventory(order_id)
            await self._charge_payment(order_id)
            await self._create_shipment(order_id)
        except:
            logger.error('saga failed')                          # !!    
            # inventory зарезервирован, payment списан, shipping нет — inconsistent

Ошибка → просто логируем. Ресурсы утекают, деньги списаны, товар зарезервирован.

Правильно: явные compensating actions для каждого выполненного шага.

3. Компенсация как физический rollback

async def _release_inventory(self, order_id):
    await self._db.execute("UPDATE inventory SET reserved = reserved - ...")   # !! прямая правка чужого сервиса   

Один сервис лезет в БД другого. Микросервисы разрушены. Distributed monolith.

Правильно: ReleaseInventoryCommand → Inventory service сам компенсирует.

4. Sagas без идемпотентности

Retry-механизм повторил ChargePayment. Клиенту списано дважды.

Правильно: каждый шаг saga идемпотентен. Payment service ловит duplicate saga_id — не списывает второй раз.

5. Choreography без визуализации

30 сервисов, у каждого 5 подписок. Никто не знает, как проходит PlaceOrder.

Правильно: явная документация цепочки. Диаграмма. Логирование корреляции. Distributed tracing.

Trade-offs

АспектOrchestrationChoreography
Явный flow
Легко отлаживатьСложно
Легко изменить/добавить шагНужно менять N сервисов
ТестируемостьПрощеСложнее
CouplingСреднийНизкий
SPOF (координатор)РискНет
Соответствие духу микросервисовМеньшеБольше
Complex flow (условия, циклы)ЛучшеХуже

Правило большого пальца:

  • Orchestration — для сложных бизнес-процессов с ветвлениями и условиями.
  • Choreography — для простых линейных цепочек между loosely coupled сервисами.

Часто комбинируют: коллекция choreography’й для простых цепочек + orchestration для сложных сценариев.

В твоём же коде

Анонимизированный микросервис — не saga. Один сервис, одна бизнес-операция, один aggregate. Saga тут не нужна.

Saga понадобилась бы, если бы Standardize был частью большего процесса:

1. Item Import      (import service)
2. Standardize      (наш сервис)
3. Match candidates (matcher service)
4. Notify user      (notification service)

При ошибке на шаге 2 (после успешного 1) — нужно откатить import? Или пометить его как «pending»? Это уже дизайн saga.

Пока сервис изолирован, focus — на надёжной доставке события (Outbox + идемпотентность).

Дальнейшее чтение

  • Hector Garcia-Molina, Kenneth Salem. Sagas. 1987. Оригинальная статья.
  • Chris Richardson. Microservices Patterns. Chapter 4 — Sagas детально, оба вида.
  • microservices.io/patterns/data/saga.html. Каталог с примерами.
  • Udi Dahan. Race Conditions Don’t Exist. Одна из ранних работ по saga в микросервисах.
  • Bernd Rücker. Practical Process Automation. Обзор saga через workflow engines (Camunda, Temporal).

Проверьте себя

Мини-quiz · закрепить

Проверьте себя

  1. Q1. Saga — это...

  2. Q2. Compensating action отличается от rollback тем, что...

  3. Q3. Orchestration vs Choreography — когда что выбрать?

  4. Q4. Порядок выполнения компенсирующих действий:

  5. Q5. Что не компенсируется?