Saga pattern в микросервисах: как управлять распределёнными транзакциями без двухфазного коммита

Заказ оформлен, деньги списаны, но склад не обновился - сервис упал в середине цепочки. Что делать? Откатить платёж? Но транзакция уже зафиксирована в другой базе данных. Повторить вызов склада? А вдруг он уже сработал, просто не ответил? Именно в этот момент начинаешь понимать, почему консистентность данных в распределённых системах - отдельная дисциплина, а не просто «добавь транзакцию».

В монолите всё просто: один вызов BEGIN / COMMIT, и либо всё сохранилось, либо ничего. В мире, где пять сервисов имеют пять независимых баз, этот трюк не работает. Классическое решение - двухфазный коммит (2PC) - существует давно, но в production на микросервисах его почти не используют: слишком медленный, слишком хрупкий, слишком плохо масштабируется.

Альтернатива - saga pattern. Это способ разбить длинную бизнес-операцию на последовательность локальных транзакций, каждая из которых фиксируется независимо, а при сбое запускается обратная цепочка компенсаций. В статье разберём, как это работает на практике: два варианта реализации с кодом, типичные подводные камни и критерии выбора подхода.

Коротко:

  • Saga - это цепочка локальных транзакций с компенсирующими шагами на случай сбоя, альтернатива 2PC в распределённых системах.
  • Двухфазный коммит блокирует ресурсы на всех участниках до завершения - в микросервисах это приводит к деградации производительности и единой точке отказа.
  • Есть два способа координации: хореография (сервисы реагируют на события самостоятельно) и оркестрация (центральный координатор управляет последовательностью шагов).
  • Компенсирующие транзакции не откатывают изменения в смысле ROLLBACK - они создают новую операцию, которая семантически отменяет предыдущую.
  • Подход даёт eventual consistency, а не строгую изоляцию - это нужно принять как условие задачи, а не как недостаток реализации.
  • Хореография проще в старте, оркестрация удобнее при сложных сценариях и нужде в наблюдаемости.

Почему двухфазный коммит не подходит для микросервисов

2PC работает так: координатор сначала просит всех участников подготовиться к фиксации (фаза prepare), затем, если все согласны, отправляет команду commit. Если кто-то отказал - всем участникам уходит rollback.

В теории - надёжно. На практике в микросервисной среде возникает несколько серьёзных проблем:

  • Блокировки. Между фазой prepare и commit все участники держат ресурсы заблокированными. Если один сервис зависает или перезагружается, остальные ждут. В системе с десятками сервисов это превращается в постоянные деградации.
  • Единая точка отказа. Если координатор падает после prepare, но до commit - участники не знают, что делать дальше. Протокол требует ждать восстановления координатора.
  • Несовместимость с автономией. Микросервисы должны быть независимы. 2PC требует, чтобы все участники умели говорить на одном протоколе и синхронно координировались - это разрушает изоляцию команд и технологическую независимость.

Поэтому в распределённых системах принято идти другим путём: принять eventual consistency как норму и проектировать компенсирующую логику явно.

Что такое Saga и как она работает

Термин ввёл Хектор Гарсия-Молина в статье 1987 года про длинные транзакции в базах данных. Идея простая: если транзакция слишком длинная, чтобы держать блокировку, разбей её на шаги. Каждый шаг фиксируется немедленно. Если на каком-то шаге происходит сбой - запускаются компенсирующие операции в обратном порядке.

Схематично для оформления заказа это выглядит так:

  1. Создать заказ (статус: pending)
  2. Зарезервировать товар на складе
  3. Списать деньги с карты
  4. Подтвердить заказ (статус: confirmed)

Если шаг 3 (списание) падает, запускаются компенсации в обратном порядке: освободить резерв на складе, перевести заказ в статус cancelled. Шаг 1 уже зафиксирован - это нормально. Компенсация создаст новую запись или обновит статус, а не откатит SQL-транзакцию.

Важно понять: компенсирующая транзакция - это не ROLLBACK. Это бизнес-операция, которая семантически отменяет предыдущий шаг. Если заказ был создан - его нужно явно отменить, а не «стереть» запись. Это меняет требования к проектированию: каждый шаг должен иметь пару - прямое действие и его компенсацию.

Два способа координации: хореография и оркестрация

Договориться о том, кто и когда запускает следующий шаг, можно двумя способами. Они отличаются архитектурно и имеют разные сильные стороны.

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

Каждый сервис публикует событие после успешного выполнения своего шага. Другие сервисы подписываются на нужные события и реагируют самостоятельно. Центрального координатора нет - сервисы «договариваются» через брокер сообщений.

Пример на Python (упрощённо):

# Order Service
def create_order(order_data):
    order = Order.create(order_data, status='pending')
    event_bus.publish('order.created', {
        'order_id': order.id,
        'items': order.items,
        'user_id': order.user_id
    })
    return order

# Inventory Service - слушает order.created
def on_order_created(event):
    order_id = event['order_id']
    try:
        reserve_items(event['items'])
        event_bus.publish('inventory.reserved', {'order_id': order_id})
    except InsufficientStockError:
        event_bus.publish('inventory.reservation_failed', {'order_id': order_id})

# Payment Service - слушает inventory.reserved
def on_inventory_reserved(event):
    order_id = event['order_id']
    try:
        charge_payment(event['order_id'])
        event_bus.publish('payment.charged', {'order_id': order_id})
    except PaymentError:
        event_bus.publish('payment.failed', {'order_id': order_id})

# Order Service - слушает payment.failed и запускает компенсацию
def on_payment_failed(event):
    order_id = event['order_id']
    Order.update_status(order_id, 'cancelled')
    # Inventory Service услышит это событие и освободит резерв
    event_bus.publish('order.cancelled', {'order_id': order_id})

Каждый сервис знает только о своём шаге и о событиях, на которые реагирует. Это хорошо сочетается с принципом слабой связанности. Но есть обратная сторона: понять общую картину бизнес-процесса из кода становится сложно. Логика размазана по нескольким сервисам, и проследить полный сценарий без схемы почти невозможно.

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

Здесь появляется отдельный компонент - оркестратор (иногда называют Saga Orchestrator или Process Manager). Он знает весь сценарий целиком, сам вызывает каждый шаг и обрабатывает результат.

Пример оркестратора на Python:

class OrderSagaOrchestrator:
    def __init__(self, order_id):
        self.order_id = order_id
        self.state = 'started'
        self.compensations = []

    def run(self):
        try:
            self._step_reserve_inventory()
            self._step_charge_payment()
            self._step_confirm_order()
        except SagaStepFailed as e:
            self._compensate()
            raise OrderFailed(self.order_id) from e

    def _step_reserve_inventory(self):
        result = inventory_service.reserve(self.order_id)
        if not result.success:
            raise SagaStepFailed('inventory_reservation')
        # Регистрируем компенсацию сразу после успешного шага
        self.compensations.append(
            lambda: inventory_service.release(self.order_id)
        )
        self.state = 'inventory_reserved'

    def _step_charge_payment(self):
        result = payment_service.charge(self.order_id)
        if not result.success:
            raise SagaStepFailed('payment')
        self.compensations.append(
            lambda: payment_service.refund(self.order_id)
        )
        self.state = 'payment_charged'

    def _step_confirm_order(self):
        order_service.confirm(self.order_id)
        self.state = 'completed'

    def _compensate(self):
        # Компенсации в обратном порядке
        for compensation in reversed(self.compensations):
            try:
                compensation()
            except Exception as e:
                # Логируем, но продолжаем компенсации
                logger.error(f'Compensation failed: {e}')
                # В production - добавить в dead letter queue

Весь сценарий читается в одном месте. Если нужно добавить шаг или изменить порядок - правишь один файл, а не ищешь обработчики в трёх сервисах. Оркестратор также проще мониторить: его состояние можно персистировать в базу и отслеживать, на каком шаге завис конкретный заказ.

Сравнение двух подходов

КритерийХореографияОркестрация
Связанность сервисовСлабая - через событияОркестратор знает всех участников
Читаемость сценарияРазмазана по сервисамСконцентрирована в одном месте
НаблюдаемостьНужен distributed tracingСостояние явно хранится
Сложность стартаНижеНужен отдельный компонент
Масштабируемость логикиРастёт сложность при 5+ шагахХорошо управляется при росте
Единая точка отказаНетОркестратор должен быть надёжным

Компенсирующие транзакции: подводные камни

На словах всё выглядит логично: шаг упал - запускаем обратную операцию. На практике здесь скрывается большинство реальных проблем.

Компенсация тоже может упасть

Если сервис склада упал при резервировании - окей, компенсировать нечего. Но что если он успешно зарезервировал товар, а потом упал при ответе? Оркестратор думает, что шаг не выполнен, и не регистрирует компенсацию. Резерв висит навечно.

Решение: делать все шаги идемпотентными. Каждый вызов должен принимать идентификатор операции (order_id или специальный idempotency_key) и безопасно обрабатывать повторный запрос. Если резерв уже существует - вернуть успех. Если компенсация уже выполнена - тоже вернуть успех.

Идемпотентный обработчик компенсации:

def release_inventory(order_id):
    reservation = Reservation.get(order_id=order_id)
    if reservation is None:
        # Уже отменено или никогда не создавалось
        return {'status': 'ok', 'note': 'nothing_to_release'}
    if reservation.status == 'released':
        # Уже выполнено ранее
        return {'status': 'ok', 'note': 'already_released'}
    reservation.status = 'released'
    reservation.save()
    return {'status': 'ok'}

Некоторые операции некомпенсируемы

Отправили email с подтверждением заказа на шаге 2, а на шаге 4 всё упало. Email уже ушёл - его не «отзовёшь». Такие шаги называют pivot transaction: они делятся на действия, которые можно отменить (до pivot), и те, которые нельзя (после pivot).

Практическое правило: некомпенсируемые действия (email, SMS, внешние API без отката) нужно откладывать на самый последний шаг саги, когда все предыдущие уже зафиксированы. Либо явно проектировать «компенсацию» в виде дополнительного уведомления: «Ваш заказ был отменён».

Параллельные саги на одних данных

Два заказа одновременно пытаются зарезервировать последний товар. Оба проходят проверку наличия, оба резервируют - и итоговый остаток уходит в минус. Это типичный write skew, который в однобазовом приложении решается SELECT FOR UPDATE.

В распределённой системе нужны явные механизмы: оптимистическая блокировка с версионированием записи, либо сериализация резервирования через очередь на стороне сервиса склада. Saga сама по себе изоляцию между параллельными экземплярами не обеспечивает.

Персистентность состояния саги

Оркестратор должен переживать рестарты. Если он хранит состояние только в памяти и падает после шага 2 - при перезапуске непонятно, с какого места продолжать.

Стандартное решение - хранить состояние саги в базе данных. Перед каждым шагом и после него состояние персистируется: какой шаг завершён, какие компенсации уже выполнены, какой текущий статус.

Схема таблицы состояний:

CREATE TABLE saga_state (
    saga_id UUID PRIMARY KEY,
    order_id UUID NOT NULL,
    current_step VARCHAR(50),
    status VARCHAR(20), -- running, compensating, completed, failed
    completed_steps JSONB, -- список выполненных шагов
    compensations JSONB, -- список зарегистрированных компенсаций
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

При рестарте сервис читает незавершённые саги и продолжает их выполнение с последнего зафиксированного шага. Для этого нужно, чтобы повторный запуск шага был идемпотентным - иначе при восстановлении можно дважды списать деньги.

Когда использовать этот подход, а когда - нет

Saga хорошо подходит, когда:

  • Бизнес-операция затрагивает несколько независимых сервисов с разными базами данных.
  • Допустима eventual consistency - небольшой промежуток времени, когда данные в разных сервисах временно расходятся.
  • Операции длинные или асинхронные по природе (оформление заказа, бронирование, онбординг пользователя).

Не стоит тянуться к этому решению, если:

  • Операция затрагивает только одну базу данных - тут достаточно обычной транзакции.
  • Бизнес-требование - строгая изоляция (ACID), и промежуточные состояния недопустимы даже на миллисекунды. Пример: биржевые торги с одновременными заявками.
  • Команда только начинает делать микросервисы, и сервисов пока 2-3. Накладные расходы на саги не оправданы.

Инструменты

Реализовывать сагу с нуля можно, но есть готовые решения:

ИнструментТипКогда подходит
TemporalОркестрацияКогда нужна надёжная персистентность состояния и retry из коробки; хорошо для сложных workflow
Apache Kafka + собственный оркестраторХореография / оркестрацияКогда уже используется Kafka и нужен контроль над деталями
Axon Framework (Java)Оба режимаЭкосистема Java/Spring, встроенная поддержка Saga
MassTransit (.NET)Оба режима.NET-стек, поддерживает state machine саги
Eventuate TramОба режимаJava, специализирован именно под Saga в микросервисах

Temporal заслуживает отдельного внимания: он автоматически персистирует историю выполнения, умеет возобновлять workflow после сбоя и предоставляет UI для наблюдения за состоянием каждого экземпляра. Для систем с высокими требованиями к надёжности это сильно снижает объём boilerplate-кода.

Типичные ошибки при реализации

  • Не делать компенсации идемпотентными. При повторном вызове компенсации (из-за retry или восстановления после сбоя) операция выполняется дважды - возврат средств делается два раза, резерв снимается дважды.
  • Выполнять некомпенсируемые действия в середине цепочки. Если email уходит на шаге 3 из 6 - при откате отправить «отменяющее» уведомление всё равно придётся, но пользователь уже получил два письма.
  • Не хранить состояние оркестратора. После рестарта сервис не знает, какие саги незавершены, и не продолжает их.
  • Игнорировать параллельные экземпляры. Два одновременных заказа на один и тот же товар создают гонку данных, которую сага не решает автоматически.
  • Смешивать хореографию с оркестрацией без явной границы. Часть логики в событиях, часть в оркестраторе - и итоговый сценарий не читается нигде.

Чеклист перед внедрением

  1. Все шаги саги идемпотентны - повторный вызов с тем же ID не создаёт дубликат.
  2. Для каждого шага определена компенсирующая операция.
  3. Некомпенсируемые действия перенесены на последние шаги.
  4. Состояние саги персистируется перед каждым шагом и после него.
  5. Реализован механизм восстановления незавершённых саг при рестарте.
  6. Параллельные записи защищены оптимистической блокировкой или очередью.
  7. Настроен мониторинг: видно, сколько саг в статусе «compensating» или «failed».
  8. Dead letter queue для компенсаций, которые падают повторно.
  9. Выбран один стиль координации и он последователен по всей системе.

Часто задаваемые вопросы

2PC блокирует ресурсы всех участников до завершения и требует синхронной координации. Saga фиксирует каждый шаг независимо и при сбое запускает компенсирующую бизнес-логику. Это означает eventual consistency вместо строгой атомарности, но без блокировок и без единой точки отказа.

Это операция, которая семантически отменяет предыдущий шаг. Если шаг создал резерв - компенсация его снимает. Если шаг списал деньги - компенсация оформляет возврат. Это не SQL ROLLBACK, а полноценная бизнес-операция, которая создаёт новую запись или меняет статус.

Для простых цепочек из 2-3 шагов хореография проще в старте. Когда сценариев несколько, шагов больше 4-5, или важна наблюдаемость - оркестрация удобнее: вся логика в одном месте, состояние явно персистируется. Смешивать оба подхода без чёткой границы не стоит.

Нужна комбинация из трёх вещей: персистентное состояние саги, retry-механизм с экспоненциальным backoff и dead letter queue для тех случаев, когда компенсация падает несколько раз подряд. Temporal и Axon предоставляют всё это из коробки.

Технически - да, но практической пользы мало. В монолите с одной базой данных проще использовать обычные транзакции. Саги нужны именно там, где несколько независимых источников данных и нет возможности завернуть всё в один COMMIT.

Данные в разных сервисах могут временно расходиться - между фиксацией шага 1 и фиксацией шага 3 есть небольшое окно. Насколько это критично, зависит от бизнеса. Для большинства операций (заказы, бронирования, подписки) это допустимо. Для финансовых транзакций с требованием строгой изоляции нужно проектировать дополнительные защиты или пересматривать архитектуру.

Это сигнал к ручному вмешательству. Dead letter queue аккумулирует такие случаи, по ним должен быть алерт и процедура разбора. Иногда компенсацию нельзя автоматизировать полностью - тогда оператор разбирает кейс вручную. Это нормальная часть операционной жизни распределённой системы.

Итог

Saga - это не серебряная пуля и не обязательный элемент любой микросервисной архитектуры. Это инструмент для конкретной задачи: когда бизнес-операция пересекает несколько независимых баз данных, и при этом нужно поддерживать согласованность при частичных сбоях. Понять принцип несложно, но дьявол - в деталях: идемпотентность, персистентность состояния, параллельные экземпляры и некомпенсируемые действия требуют явного проектирования.

Выбор между хореографией и оркестрацией зависит от сложности сценария и приоритета наблюдаемости. Для новых систем стоит начать с хореографии на простых случаях, но как только логика начинает разрастаться через пять и более сервисов - оркестратор с явным состоянием даст существенно больше контроля. Инструменты вроде Temporal снимают большую часть инфраструктурной работы и позволяют сосредоточиться на бизнес-логике компенсаций.