Паттерн Saga: распределённые транзакции без 2PC, компенсации и outbox
Как работает паттерн Saga: почему не двухфазный коммит, хореография или оркестрация, компенсирующие действия, outbox и идемпотентность шагов на примере оформления заказа.
Коротко
- Saga — это бизнес-транзакция, разбитая на цепочку локальных транзакций в разных сервисах. Каждая коммитится сразу, а если дальше что-то сломалось, выполненные шаги отменяются компенсирующими действиями.
- Почему не 2PC. Двухфазный коммит держит блокировки, пока все участники не ответят, зависит от координатора и требует, чтобы все базы и брокеры поддерживали XA. В микросервисах этого обычно нет.
- Два стиля. Хореография: сервисы реагируют на события друг друга. Оркестрация: отдельный координатор говорит каждому сервису, что делать.
- Saga даёт ACD без I. Промежуточные состояния видны другим транзакциям. Это нужно проектировать: статусы
PENDING, семантические блокировки, порядок шагов. - Обязательная обвязка: outbox для надёжной отправки сообщений и идемпотентность каждого шага и каждой компенсации, потому что доставка — at-least-once.
Задача: заказ в интернет-магазине
Пользователь оформляет заказ. Нужно:
- Создать заказ в сервисе заказов.
- Зарезервировать товар в сервисе склада.
- Списать деньги в платёжном сервисе.
- Подтвердить заказ.
У каждого сервиса своя база. В монолите это была бы одна транзакция BEGIN ... COMMIT: не хватило денег — откатили всё, резерв товара исчез сам. Здесь общей транзакции нет. Если склад уже уменьшил остаток, а платёж не прошёл, кто-то должен вернуть товар обратно.
Наивные варианты ломаются предсказуемо:
- Последовательные HTTP-вызовы из сервиса заказов. Сервис упал после резерва — резерв висит вечно, никто о нём не помнит.
- Запись в базу, потом событие в брокер. Упали между ними — заказ есть, событие потеряно. Это проблема двойной записи, её решает outbox (ниже).
Почему не двухфазный коммит
Двухфазный коммит (2PC) — классический способ сделать атомарную транзакцию поверх нескольких ресурсов:
- Prepare. Координатор спрашивает всех участников: «Готовы закоммитить?» Каждый пишет изменения в журнал, берёт блокировки и отвечает «да» или «нет».
- Commit/Abort. Если все сказали «да», координатор рассылает commit, иначе abort.
На бумаге это честная атомарность. На практике:
| Проблема | Что происходит |
|---|---|
| Блокировки на время раунда | Строки заблокированы, пока самый медленный участник не ответит. Латентность всей операции = латентность худшего участника |
| Координатор — узкое место | Если координатор упал после prepare, участники застревают в состоянии «in doubt» и держат блокировки, пока он не восстановится |
| Доступность | Транзакция проходит, только если доступны все. Чем больше участников, тем ниже итоговая доступность |
| Поддержка | Нужен XA или аналог у каждой базы и брокера. Kafka, большинство NoSQL и внешние API (платёжный шлюз) в XA не участвуют |
Последний пункт обычно решает дело: внешний платёжный провайдер не станет держать для вас prepare-состояние. Поэтому в микросервисах выбирают модель, где каждый шаг коммитится локально, а согласованность достигается в конечном счёте. Подробнее о компромиссе согласованности и доступности — в статье про теорему CAP.
Паттерн придумали задолго до микросервисов: статья «Sagas» Гектора Гарсиа-Молины и Кеннета Салема вышла в 1987 году и описывала длинные транзакции внутри одной базы, которые не хотелось держать под блокировками часами.
Как устроена сага
Сага — последовательность шагов . Для каждого шага, кроме последних, есть компенсация , которая семантически отменяет . Если упал шаг , выполняется:
Важное слово — семантически. Компенсация не откатывает базу к прошлому состоянию, это новая бизнес-операция. Списание денег компенсируется возвратом, а не удалением строки из таблицы платежей. Письмо «заказ оформлен» нельзя отозвать — его компенсирует письмо «заказ отменён».
Шаги делятся на три типа:
- Компенсируемые — их можно отменить: создать заказ в статусе
PENDING, зарезервировать товар. - Поворотный (pivot) — после него сага обязана дойти до конца. Обычно это необратимое действие, например списание через внешний шлюз или отправка в доставку.
- Повторяемые (retriable) — шаги после поворотного. Они не могут окончательно провалиться: их ретраят, пока не получится.
Отсюда практическое правило: ставьте рискованные и легко отменяемые шаги раньше, необратимые — позже. Проверку остатков и резерв — до списания денег, а не после.
Хореография
В хореографии нет центра. Каждый сервис слушает события и публикует свои:
Order: OrderCreated ─────────────▶ Stock: ◀─ OrderCreated → StockReserved ─▶ (или StockRejected) Payment: ◀─ StockReserved → PaymentCompleted ─▶ (или PaymentFailed) Order: ◀─ PaymentCompleted → заказ APPROVED Откат: Payment: PaymentFailed ─▶ Stock: ◀─ PaymentFailed → снять резерв, StockReleased ─▶ Order: ◀─ StockReleased → заказ CANCELLED
Плюсы:
- Нет отдельного компонента, который нужно писать, деплоить и масштабировать.
- Сервисы слабо связаны: склад не знает о существовании платёжки, он знает только про события.
Минусы проявляются с ростом:
- Логику процесса нигде не видно целиком. Чтобы понять, что происходит при оформлении заказа, нужно прочитать обработчики в пяти репозиториях.
- Циклические зависимости по событиям. Склад слушает платёжку, платёжка слушает склад — менять порядок шагов больно.
- Трудно ответить на вопрос «в каком состоянии заказ 42». Состояние саги размазано по сервисам.
Хореография хорошо работает для 2–4 шагов с простым откатом.
Оркестрация
Оркестратор — отдельный сервис или модуль, который хранит состояние саги и рассылает команды: «зарезервируй», «спиши», «верни». Участники отвечают событиями. Логика процесса в одном месте, её легко читать, тестировать и мониторить.
Минимальный оркестратор на Python:
from dataclasses import dataclass, field from typing import Callable class StepFailed(Exception): pass @dataclass class Step: name: str action: Callable[[dict], None] compensation: Callable[[dict], None] | None = None @dataclass class SagaLog: # В реальной системе — таблица в БД оркестратора done: list[str] = field(default_factory=list) status: str = 'running' def run_saga(steps: list[Step], ctx: dict, log: SagaLog) -> str: for step in steps: try: step.action(ctx) log.done.append(step.name) except StepFailed as e: print(f' шаг {step.name} упал: {e}') log.status = 'compensating' # Откатываем выполненные шаги в обратном порядке for name in reversed(log.done): comp = next(s for s in steps if s.name == name).compensation if comp: comp(ctx) log.status = 'compensated' return log.status log.status = 'completed' return log.status
Шаги заказа. Сервисы здесь заменены словарями, чтобы пример запускался целиком:
stock = {'book': 1} balance = {'alice': 500} orders = {} def create_order(ctx): orders[ctx['order_id']] = 'PENDING' print(' заказ создан: PENDING') def cancel_order(ctx): orders[ctx['order_id']] = 'CANCELLED' print(' заказ отменён') def reserve_stock(ctx): if stock[ctx['item']] < 1: raise StepFailed('нет на складе') stock[ctx['item']] -= 1 print(' товар зарезервирован') def release_stock(ctx): stock[ctx['item']] += 1 print(' резерв снят') def charge(ctx): if balance[ctx['user']] < ctx['price']: raise StepFailed('недостаточно средств') balance[ctx['user']] -= ctx['price'] print(' деньги списаны') def refund(ctx): balance[ctx['user']] += ctx['price'] print(' деньги возвращены') def approve_order(ctx): orders[ctx['order_id']] = 'APPROVED' print(' заказ подтверждён') steps = [ Step('create_order', create_order, cancel_order), Step('reserve_stock', reserve_stock, release_stock), Step('charge', charge, refund), Step('approve_order', approve_order), # после списания только вперёд ] ctx = {'item': 'book', 'user': 'alice', 'price': 300} print(run_saga(steps, {**ctx, 'order_id': 1}, SagaLog())) stock['book'] = 1 # подвезли ещё одну книгу print(run_saga(steps, {**ctx, 'order_id': 2}, SagaLog())) print(orders, stock, balance)
Вывод:
заказ создан: PENDING товар зарезервирован деньги списаны заказ подтверждён completed заказ создан: PENDING товар зарезервирован шаг charge упал: недостаточно средств резерв снят заказ отменён compensated {1: 'APPROVED', 2: 'CANCELLED'} {'book': 1} {'alice': 200}
Второй заказ не прошёл по деньгам, резерв вернулся на склад, заказ отменён. Итоговое состояние согласовано, хотя общей транзакции не было.
Что в этом примере упрощено и что спросят на интервью:
- Лог саги должен быть персистентным. Оркестратор упал посреди саги — после рестарта он читает из базы, какие шаги выполнены, и продолжает вперёд или компенсирует. Состояние в памяти, как здесь, — не вариант.
- Вызовы асинхронные. Оркестратор отправляет команду в очередь и переходит в состояние «жду ответа от склада». Это конечный автомат, а не цикл
for. - Ошибка ≠ таймаут. «Недостаточно средств» — однозначный отказ, можно компенсировать. Таймаут — неизвестность: деньги могли списаться. Такой шаг сначала ретраят или запрашивают его статус, и только потом решают.
- Компенсация тоже может упасть. Её не откатывают, а повторяют до успеха. Если не получается долго — алерт и ручной разбор.
Писать такой автомат руками необязательно. Для этого есть движки долгих процессов: Temporal, Camunda, AWS Step Functions. Они хранят состояние, ретраят шаги и показывают, на каком шаге застрял каждый процесс.
Что выбрать
| Критерий | Хореография | Оркестрация |
|---|---|---|
| Число шагов | 2–4 | Любое, особенно 5+ и ветвления |
| Где видна логика процесса | Размазана по сервисам | В одном месте |
| Связность | Через события, слабая | Участники зависят от команд оркестратора |
| Новая точка отказа | Нет | Оркестратор (нужна персистентность и реплики) |
| Отладка и мониторинг | Сложно, нужен трейсинг | Статус саги — запись в одной таблице |
На собеседовании безопасный ответ: «Для оформления заказа с оплатой и доставкой — оркестрация. Хореография — для простых реакций вроде „заказ оплачен → отправить письмо“».
Изоляция: главный подвох
Пока сага идёт, её промежуточные состояния видны всем. Заказ уже создан, товар зарезервирован, а деньги ещё не списаны. Отсюда аномалии:
- Грязное чтение. Аналитика посчитала заказ в выручку, а через секунду его отменили.
- Потерянное обновление. Пользователь отменил заказ, пока сага списывала деньги, и сага подтвердила уже отменённый заказ.
Контрмеры:
- Семантическая блокировка. Статус
PENDINGозначает «заказ в процессе». Другие операции с ним либо ждут, либо отклоняются. Отмена пользователем вPENDINGне меняет статус напрямую, а ставит флаг, который сага проверит. - Коммутативные операции. Резерв как
stock -= 1и возврат какstock += 1можно применять в любом порядке, в отличие отstock = 5. - Перечитывание перед поворотным шагом. Перед списанием проверить, что заказ всё ещё
PENDING, а не отменён. - Порядок шагов. Видимые пользователю действия, например письмо и пуш, — в самый конец, после поворотной точки.
Outbox: надёжная отправка команд и событий
Каждый шаг саги делает две вещи: меняет свою базу и сообщает об этом остальным. Если сделать это двумя отдельными операциями, рано или поздно одна из них потеряется. Стандартное решение — transactional outbox: событие пишется в таблицу той же базы в той же транзакции, а отдельный релей публикует его в брокер.
BEGIN; UPDATE stock SET reserved = reserved + 1 WHERE item_id = 'book'; INSERT INTO outbox (event_id, saga_id, type, payload) VALUES (gen_random_uuid(), 'saga-2', 'StockReserved', '{"item": "book"}'); COMMIT;
Событие уйдёт тогда и только тогда, когда резерв зафиксирован. Но может уйти больше одного раза: релей упал после публикации и до отметки «отправлено». Как устроены релей, CDC и порядок сообщений — подробно в статье про очереди сообщений.
Идемпотентность шагов и компенсаций
Доставка at-least-once означает, что каждую команду и каждое событие участник может получить дважды. Поэтому:
- Каждый шаг идемпотентен по
saga_id. Повторное «спиши за saga-2» не списывает второй раз. Общие приёмы — ключ идемпотентности, уникальный индекс, проверка статуса — разобраны в статье про идемпотентность. - Каждая компенсация тоже идемпотентна. Двойной возврат денег — такой же инцидент, как двойное списание.
- Компенсация может прийти раньше действия. Оркестратор не дождался ответа от платёжки, решил откатить и отправил
refund. Аchargeзастрял в очереди и приехал позже. Если платёжка просто выполнит его, деньги спишутся у отменённого заказа.
Последний случай ловят на собеседованиях чаще всего. Решение — помнить отмену:
class PaymentService: def __init__(self, balance): self.balance = balance self.payments = {} # saga_id -> 'CHARGED' | 'REFUNDED' def charge(self, saga_id, user, amount): state = self.payments.get(saga_id) if state == 'CHARGED': return 'ok (повтор)' if state == 'REFUNDED': # Компенсация пришла раньше действия — списывать нельзя return 'rejected' self.balance[user] -= amount self.payments[saga_id] = 'CHARGED' return 'ok' def refund(self, saga_id, user, amount): state = self.payments.get(saga_id) if state == 'REFUNDED': return 'ok (повтор)' if state == 'CHARGED': self.balance[user] += amount # Нет записи: запоминаем отмену, чтобы опоздавший charge не прошёл self.payments[saga_id] = 'REFUNDED' return 'ok' p = PaymentService({'alice': 500}) print(p.charge('s1', 'alice', 300), p.charge('s1', 'alice', 300), p.balance) # ok ok (повтор) {'alice': 200} print(p.refund('s1', 'alice', 300), p.refund('s1', 'alice', 300), p.balance) # ok ok (повтор) {'alice': 500} print(p.refund('s2', 'alice', 100), p.charge('s2', 'alice', 100), p.balance) # ok rejected {'alice': 500}
В базе payments — таблица с уникальным ключом по saga_id, а проверка и запись делаются в одной транзакции, иначе два параллельных сообщения пройдут проверку одновременно.
Как рассказывать на собеседовании
Если в задаче на системный дизайн появилась операция через несколько сервисов — оплата, бронирование, перевод, — интервьюер почти наверняка спросит про согласованность. Хорошая структура ответа:
- Назвать проблему. «Общей транзакции нет, 2PC не подходит из-за блокировок и внешнего платёжного API».
- Предложить сагу и стиль. «Оркестрация: шагов четыре, есть внешняя система, нужен статус процесса».
- Расписать шаги и компенсации таблицей. Отдельно отметить поворотный шаг.
- Закрыть надёжность. Персистентный лог саги, outbox, идемпотентность по
saga_id, ретраи с backoff. - Сказать про изоляцию. Статус
PENDING, что видит пользователь во время саги.
Для заказа таблица выглядит так:
| Шаг | Сервис | Компенсация | Тип |
|---|---|---|---|
| Создать заказ | Order | Отменить заказ | Компенсируемый |
| Резерв товара | Stock | Снять резерв | Компенсируемый |
| Списать деньги | Payment | Вернуть деньги | Поворотный |
| Подтвердить заказ | Order | — | Повторяемый |
| Передать в доставку | Delivery | — | Повторяемый |
Списание здесь поворотное условно: возврат технически возможен, но стоит денег и раздражает пользователя, поэтому после успешного списания сага идёт только вперёд.
Вопросы, которые спросят дальше
- Оркестратор упал посреди саги — что будет? Он перезапустится, прочитает лог из своей базы и продолжит. Команды могут уйти повторно — участники идемпотентны.
- Компенсация не проходит. Ретраи с экспоненциальной задержкой, потом dead letter queue и алерт. Некоторые случаи решает только человек.
- Чем сага отличается от 2PC с точки зрения клиента? Клиент видит промежуточное состояние: «заказ обрабатывается». Ответ «успешно» приходит асинхронно, через пуш или поллинг статуса.
- Как не потерять событие между базой и брокером? Outbox или CDC.
- Можно ли обойтись без саги? Иногда да: если две сущности всегда меняются вместе, возможно, это один сервис и одна база. Границы сервисов, которые требуют сагу на каждую операцию, — повод их пересмотреть. Про то, как резать систему на сервисы, — в статье про микросервисную архитектуру.
Частые ошибки
- Компенсация как
DELETE. Удалили запись о платеже вместо возврата — потеряли историю и деньги клиента. - Неидемпотентные шаги. Повтор сообщения списывает деньги второй раз.
- Нет защиты от опоздавшего действия. Компенсация прошла, а потом выполнилось само действие.
- Таймаут считают отказом. Компенсируют шаг, который на самом деле выполнился, или не компенсируют тот, что выполнился наполовину.
- Необратимые шаги в начале. Письмо «заказ оформлен» ушло до проверки оплаты.
- Сага на всё. Каждый запрос через пять сервисов с компенсациями — признак неудачных границ сервисов, а не зрелой архитектуры.