Распределённые транзакции и Saga
Как выполнить операцию, которая затрагивает три сервиса, если общей транзакции больше не существует.
Saga — способ провести бизнес-операцию через несколько сервисов в виде цепочки локальных транзакций: каждый шаг фиксируется в своей базе, а если один из следующих шагов упал, ранее выполненные шаги отменяются специальными компенсирующими действиями.
Вернёмся к оформлению заказа. Нужно: создать заказ, списать деньги, зарезервировать товар на складе, поставить в очередь на доставку. Четыре сервиса, четыре базы. В монолите это был бы один BEGIN ... COMMIT: либо всё, либо ничего. Здесь такого рычага нет. И если деньги списались, а товара на складе не оказалось — у вас недовольный клиент и деньги, которые надо возвращать.
Почему двухфазный коммит не применяют
Формально решение существует: 2PC (two-phase commit, двухфазный коммит). Координатор сначала спрашивает всех участников «готовы зафиксировать?» (фаза prepare) и, если все ответили «да», командует «фиксируем» (фаза commit). Атомарность обеспечена. Почему же в микросервисах его почти никто не использует?
- Блокировки живут всё время транзакции. Между prepare и commit каждый участник держит строки заблокированными. Если операция идёт 300 мс, вы на 300 мс заблокировали склад поперёк всех остальных заказов. Пропускная способность падает в разы.
- Координатор — единая точка отказа. Он упал между фазами — участники остались в состоянии in-doubt: зафиксировать нельзя, откатить нельзя, транзакция висит и держит блокировки, пока координатор не воскреснет.
- Половина стека вообще не умеет 2PC. Kafka, большинство NoSQL-хранилищ, любой внешний платёжный шлюз, да и вообще любой HTTP API в двухфазный коммит не играют. Как вы сделаете prepare у платёжной системы?
- Доступность приносится в жертву. Операция завершится, только если все участники живы и отвечают. Один тормозящий сервис останавливает всех.
| Свойство | 2PC | Saga |
| Атомарность | настоящая (всё или ничего) | достигается компенсациями |
| Изоляция | есть | нет — промежуточные состояния видны |
| Блокировки | на всё время операции | только внутри одного локального шага |
| Доступность | падает с ростом числа участников | высокая, шаги независимы |
| Работает с брокером и внешним API | нет | да |
Ключевую строчку выделим отдельно: Saga даёт ACD без I — атомарность (через компенсации), согласованность и долговечность есть, а изоляции нет. Пока сага идёт, посторонний наблюдатель видит недоделанную картину: заказ уже создан, но ещё не оплачен. С этим придётся жить, и это придётся проектировать явно.
Saga: цепочка локальных транзакций с компенсациями
Разложим наш заказ по шагам. У каждого шага своя локальная транзакция (обычный ACID внутри одной базы) и своё компенсирующее действие.
| Шаг | Сервис | Локальная транзакция | Компенсация |
| 1 | Orders | создать заказ в статусе PENDING | перевести в CANCELLED |
| 2 | Inventory | зарезервировать 2 шт. | снять резерв |
| 3 | Payments | списать 4990 | вернуть 4990 (refund) |
| 4 | Delivery | создать отгрузку | — (pivot: дальше не отменяем) |
| 5 | Orders | перевести заказ в CONFIRMED | — (повторяемый шаг) |
Здесь главная мысль урока: компенсация — это не rollback. Откат в базе стирает следы, будто ничего не было. Компенсация — новая бизнес-операция, которая следы оставляет. Деньги уже ушли в банк: «отката» не существует, существует возврат, и он попадёт в выписку клиента отдельной строкой. Письмо «ваш заказ принят» уже улетело: отменить его нельзя, можно только прислать второе — «к сожалению, товара не оказалось».
Шаги саги принято делить на три типа — деление очень практичное:
- Компенсируемые — их можно отменить (создать заказ, зарезервировать товар, списать деньги).
- Pivot, точка невозврата — шаг, после которого сага уже не отменяется, а только доводится до конца. Курьер забрал посылку — назад не отыграть.
- Повторяемые — идут после pivot и могут только успешно завершиться, пусть и с ретраями: отправить чек, начислить бонусы, поменять статус.
Проектируя сагу, ставьте компенсируемые шаги до pivot, а рискованные проверки — как можно раньше. Если товара может не быть, глупо сначала брать деньги: сначала резерв, потом оплата (именно поэтому в таблице выше Inventory идёт вторым, а не третьим). Половина «сложных саг» упрощается простой перестановкой шагов.
Вот скелет оркестратора, который выполняет шаги и откатывает их в обратном порядке при сбое. Запустите — увидите, как деньги возвращаются на карту:
world = {"balance": 10000, "stock": 1, "order": None}
def create_order():
world["order"] = "PENDING"
print(" заказ создан:", world["order"])
def cancel_order():
world["order"] = "CANCELLED"
print(" заказ отменён:", world["order"])
def charge():
world["balance"] -= 4990
print(" списали 4990, на карте:", world["balance"])
def refund():
world["balance"] += 4990
print(" ВЕРНУЛИ 4990, на карте:", world["balance"])
def reserve():
if world["stock"] < 2:
raise RuntimeError("на складе только %d шт." % world["stock"])
world["stock"] -= 2
saga = [
("create_order", create_order, cancel_order),
("charge_payment", charge, refund),
("reserve_stock", reserve, lambda: None),
]
done = []
for name, do, undo in saga:
try:
print("ШАГ", name)
do()
done.append((name, undo))
except Exception as e:
print("СБОЙ", name, "-", e)
for dname, dundo in reversed(done): # компенсируем в обратном порядке
print("КОМПЕНСАЦИЯ", dname)
dundo()
break
else:
print("Сага прошла целиком")
print("Итог:", world)
Результат:
ШАГ create_order
заказ создан: PENDING
ШАГ charge_payment
списали 4990, на карте: 5010
ШАГ reserve_stock
СБОЙ reserve_stock - на складе только 1 шт.
КОМПЕНСАЦИЯ charge_payment
ВЕРНУЛИ 4990, на карте: 10000
КОМПЕНСАЦИЯ create_order
заказ отменён: CANCELLED
Итог: {'balance': 10000, 'stock': 1, 'order': 'CANCELLED'}
В этом примере списание идёт раньше резерва — как раз чтобы увидеть работу компенсации. В реальной системе шаги стоило бы переставить и не трогать деньги, пока товар не зарезервирован. И ещё: между шагами — сеть, очереди и падения процессов, поэтому состояние саги хранят в базе, а не в списке done в оперативной памяти. Но идея ровно эта.
Хореография против оркестрации
Склеить шаги можно двумя принципиально разными способами.
Хореография: каждый слушает события и решает сам
Центрального управляющего нет. Orders публикует OrderCreated — Payments его слышит и списывает деньги, публикует PaymentCompleted — Inventory слышит и резервирует товар, и так далее. При сбое Inventory публикует ReservationFailed, а Payments, услышав это событие, делает возврат.
Orders --OrderCreated--> Payments
Payments --PaymentCompleted--> Inventory
Inventory --ReservationFailed--> Payments (refund) + Orders (cancel)
Плюс: сервисы слабо связаны, никто ни о ком не знает по имени, добавить нового подписчика можно ничего не переписывая. Минус: бизнес-процесса не существует ни в одном файле. Чтобы понять, что происходит при оформлении заказа, нужно открыть четыре репозитория и мысленно собрать граф событий. На трёх шагах это мило, на восьми превращается в детектив с распределённой трассировкой в руках.
Оркестрация: есть дирижёр
Появляется явный компонент — оркестратор (отдельный сервис Order Saga либо готовый движок вроде Temporal или Camunda). Он хранит состояние саги и рассылает команды: «Payments, спиши», «Inventory, зарезервируй». Участники отвечают, оркестратор решает, что дальше.
state: AWAITING_PAYMENT
on PaymentCompleted -> command ReserveStock, state: AWAITING_STOCK
on PaymentFailed -> command CancelOrder, state: FAILED
state: AWAITING_STOCK
on StockReserved -> command CreateShipment, state: AWAITING_SHIPMENT
on StockUnavailable -> command RefundPayment, state: COMPENSATING
Плюс: процесс виден целиком в одном месте — его легко читать, тестировать, менять и мониторить («сколько саг сейчас висит в состоянии COMPENSATING?»). Минус: риск, что оркестратор превратится во всезнающего бога, в котором осядет вся бизнес-логика, а сервисы выродятся в анемичные CRUD-обёртки.
| Критерий | Хореография | Оркестрация |
| Где живёт процесс | размазан по сервисам | в одном компоненте |
| Связанность | минимальная | средняя (оркестратор знает всех) |
| Отладка и наблюдаемость | тяжело, нужен трейсинг | легко, состояние саги лежит в БД |
| Ветвления и циклы | быстро становятся адом | нормально |
| Когда брать | 2–4 шага, простая линейная логика | 5 и больше шагов, ветвления, таймауты, деньги |
Эмпирическое правило: до трёх-четырёх шагов — хореография, дальше — оркестрация. И никто не запрещает смешивать: критичный денежный поток оркестрируют, а побочные реакции (отправить письмо, обновить рекомендации) оставляют на событиях.
Как это работает
Три механизма, без которых сага в проде развалится в первую же неделю.
Transactional outbox: атомарной записи «в базу и в брокер» не бывает
Классическая ловушка называется dual write: сервис записал заказ в PostgreSQL и следом отправил событие в брокер. Между этими двумя действиями процесс может умереть — и вот заказ есть, а события нет. Сага не начнётся, заказ навсегда останется в PENDING. Лечится так: событие пишут в ту же локальную транзакцию, в таблицу outbox. Отдельный процесс (или CDC-коннектор вроде Debezium) читает эту таблицу и публикует сообщения в брокер. Транзакция одна — значит, факт и событие о нём атомарны.
Идемпотентность: доставка «хотя бы один раз» означает дубли
Брокеры гарантируют at-least-once: одно и то же сообщение может приехать дважды — например, потребитель успел обработать его, но не успел подтвердить. Если обработчик списания не идемпотентен, клиент заплатит два раза. Стандартное лекарство — таблица inbox с уже обработанными message_id: пришло виденное — молча подтверждаем и выходим. Плюс ключ идемпотентности на стороне платёжного шлюза.
Таймауты и зависшие саги
Сервис доставки не ответил ни успехом, ни ошибкой — просто молчит. У саги должен быть дедлайн: не ответили за N минут — считаем шаг неуспешным и запускаем компенсацию. И обязательно дашборд «саги, висящие дольше часа»: это ваш главный индикатор проблем с распределёнными данными.
Частые ошибки
- Компенсация тоже может упасть. Возврат денег не прошёл — что делать? Ретраи с экспоненциальной паузой, а после исчерпания попыток — очередь на ручной разбор. Компенсации обязаны быть идемпотентными и, по возможности, «сколько угодно раз повторяемыми».
- Забыли про отсутствие изоляции. Пока сага идёт, другой процесс видит заказ в PENDING и, скажем, начисляет по нему бонусы. Лечится семантической блокировкой: промежуточный статус явно присутствует в модели, и вся остальная логика обязана его учитывать.
- Нет идемпотентности. Самая дорогая ошибка в списке: двойное списание у живого клиента. Проверьте каждого потребителя.
- Dual write без outbox. Работает на демо, теряет события под нагрузкой и при рестартах.
- Хореография на восемь шагов. Через полгода никто в команде не сможет ответить, что произойдёт при отказе третьего сервиса.
- Порядок шагов не продуман. Сначала берут деньги, потом проверяют наличие товара — и создают себе поток возвратов на ровном месте.
- Сага без сохранённого состояния. Оркестратор перезапустился — и все идущие саги исчезли. Состояние саги — это данные, оно живёт в базе.
Итоги
- 2PC даёт настоящую атомарность, но ценой блокировок, единой точки отказа и несовместимости с брокерами и внешними API — поэтому в микросервисах его почти не используют.
- Saga — цепочка локальных ACID-транзакций; при сбое ранее выполненные шаги отменяются компенсирующими операциями в обратном порядке.
- Компенсация — не откат, а новая бизнес-операция: возврат, отмена, письмо-извинение.
- Изоляции у саги нет: промежуточные состояния видны, их надо явно моделировать (PENDING, RESERVED).
- Шаги бывают компенсируемые, pivot и повторяемые; рискованные проверки ставьте раньше денежных операций.
- Хореография — до 3–4 шагов; оркестрация — когда шагов много, есть ветвления, таймауты и деньги.
- Обязательный минимум для прода: transactional outbox, идемпотентные обработчики, таймауты и мониторинг зависших саг.