Концептуально сага — распределённая транзакция без двухфазного коммита: серия локальных транзакций, каждая с компенсацией на случай отката. Звучит стройно, пока не доходит до прода, где всплывают вопросы, которые концепция обходит: компенсация — это не «откатить», а выполнить обратное действие (деньги уже списаны — их надо вернуть, а не «отменить списание»); что делать, если сама компенсация упала; в каком порядке компенсировать; как не выполнить шаг дважды при ретрае; и что с изоляцией, которой у саги, в отличие от ACID-транзакции, попросту нет.
Практический сиквел к концептуальной статье про Saga; выбор оркестрации vs хореографии разобран в отдельной статье. Эта статья — про инженерную сторону саги: то, на чём она ломается в реальности.
В статье
- Компенсация — не откат
- Классификация шагов: compensatable, pivot, retriable
- Идемпотентность шагов и компенсаций
- Изоляция: сага даёт ACD, не ACID
- Персистентность саги и таймауты
- Лунная база: аварийный протокол как сага
- Tooling
- Тестирование и наблюдаемость
- Эксплуатационные ошибки
- Checklist: сага в проде
Компенсация — не откат
Главное непонимание про сагу — что компенсация «откатывает» шаг. Ничего она не откатывает: локальная транзакция уже закоммичена, деньги уже списаны. Компенсация — это новое, обратное по смыслу действие: не «отменить списание», а вернуть деньги. И это семантически другое: возврат может занять время, оставить след в истории, не восстановить состояние 1:1 (комиссия за перевод уже не вернётся).
Отсюда три следствия, которые надо заложить в дизайн:
- Порядок компенсаций — обратный. Если сага прошла шаги 1→2→3 и упала на 3, компенсируем 2, затем 1. Как разматывание стека.
- Компенсация может сама упасть. Возврат денег — тоже вызов внешнего сервиса, который может не ответить. План: ретрай с backoff, а если не помогло — эскалация и ручной разбор. Компенсации проектируют максимально надёжными и простыми.
- Не всё компенсируемо. Отправленное письмо не вернёшь, отгруженный товар — уже в пути. Это подводит к классификации шагов.
Классификация шагов: compensatable, pivot, retriable
Richardson делит шаги саги на три типа — и правильный порядок шагов вытекает прямо из этой классификации:
| Тип шага | Свойство | Пример (заказ) |
|---|---|---|
| compensatable | можно компенсировать обратным действием | списать оплату (→ вернуть) |
| pivot | точка невозврата: после него отката нет, только вперёд | отгрузить товар |
| retriable | идёт после pivot, гарантированно завершается ретраем | уведомить клиента |
Логика: все compensatable-шаги идут первыми (пока можно откатиться), затем ровно один pivot — точка, после которой сага обязана завершиться успехом, — затем retriable-шаги, которые не могут провалиться навсегда (их ретраят до победы). Дизайн саги = расположить шаги так, чтобы необратимое (pivot) стояло как можно позже, а до него всё оставалось откатываемым.
compensatable compensatable PIVOT retriable
оплата → резерв → отгрузка → уведомление
(возврат) (снять резерв) (назад нельзя) (ретраить до успеха)
Идемпотентность шагов и компенсаций
Сага живёт в мире at-least-once: координатор упал и переигрывает шаг, повторно шлёт команду. Значит идемпотентными должны быть и шаги, и компенсации — иначе ретрай спишет деньги дважды или вернёт их дважды. Механизм тот же, что для любого консьюмера: стабильный ключ идемпотентности + дедуп, или естественная идемпотентность операции. Полностью разобрано в гарантиях доставки и идемпотентности; здесь важно помнить, что компенсация — такой же ретраемый шаг и требует того же. Мостики: Outbox/Inbox как dedup на входе, exactly-once в Kafka для транспорта.
Изоляция: сага даёт ACD, не ACID
Ключевое, что теряется без двухфазного коммита, — буква I (isolation). Пока сага идёт по шагам, её промежуточное состояние видно другим транзакциям: резерв уже сделан, но заказ ещё не подтверждён. Сага даёт ACD (Atomicity через компенсации, Consistency, Durability), но не Isolation. Отсюда реальные аномалии — те же, что в слабых уровнях изоляции:
- грязное чтение — другая транзакция читает данные саги, которые ещё откатятся;
- потерянное обновление — две саги меняют одно и перетирают друг друга;
- нечёткое чтение — значение меняется между шагами одной саги.
Richardson предлагает набор контрмер (это не «включить SERIALIZABLE», такого рычага нет — это прикладные приёмы):
| Контрмера | Что делает |
|---|---|
| semantic lock | флаг «в процессе» на записи (PENDING); другие видят, что запись занята сагой |
| commutative updates | операции коммутативны (add/subtract вместо set) → порядок не важен, потерянных обновлений нет |
| pessimistic view | переставить шаги так, чтобы грязное чтение было наименее вредным |
| reread value | перечитать значение перед записью, проверить, что не изменилось (оптимистично) |
| version file | записывать операции над записью и переупорядочивать их при конфликте |
| by value | маршрутизация по бизнес-риску: мелкие суммы — сага, крупные — жёсткая блокировка/2PC |
Самая частая на практике — semantic lock: статус PENDING/CONFIRMED, и все читатели обязаны его учитывать.
Персистентность саги и таймауты
Сага — это состояние, которое живёт между шагами, и его надо где-то надёжно хранить: на каком шаге мы, что уже сделано, что компенсировать при сбое. Варианты — таблица состояний у самодельного оркестратора или история движка. Плюс таймауты на шаги: шаг не ответил за N — считаем сбоем и запускаем компенсацию. И восстановление после падения координатора: если оркестратор упал в середине саги, после рестарта он обязан продолжить с нужной точки, а не бросить сагу «висеть». Именно эту проблему durable-движки решают из коробки (прямой мостик к Temporal) — самодельный оркестратор всё это реализует сам.
Лунная база: аварийный протокол как сага
Разгерметизация запускает сагу: зарезервировать аварийный кислород → переключить энергоснабжение → выпустить ремонтного робота → уведомить оператора.
- резерв кислорода — compensatable (компенсация: вернуть резерв в общий пул);
- переключение энергии — compensatable (вернуть прежнюю схему);
- выпуск робота — pivot: робот вышел в повреждённый модуль, «отозвать» его мгновенно нельзя, дальше только вперёд;
- уведомление оператора — retriable (ретраить до доставки).
Если резерв энергии провалился до pivot — компенсируем кислород (вернули в пул) и сага откатилась чисто. Если сбой случился после выпуска робота — откатываться уже некуда, сага дожимает retriable-шаги. А semantic lock тут буквально физический: резервуар в статусе RESERVED_EMERGENCY не должен параллельно расходоваться другой подсистемой — иначе аварийного запаса не хватит.
Tooling
| Инструмент | Модель | Когда |
|---|---|---|
| Temporal | сага как durable-код, компенсации в try/catch | сложные саги, длинные процессы, человек в цикле |
| Camunda (Zeebe/BPMN) | сага как BPMN-модель процесса | нужна визуальная модель, бизнес-владельцы процесса |
| Axon Saga | saga-компоненты на событиях (JVM) | JVM + CQRS/ES-стек |
| Eventuate Tram Sagas | orchestration-based саги для микросервисов | готовый фреймворк саг поверх БД+брокера |
| Самодельный | оркестратор + outbox + таблица состояний | простые саги, нежелание тащить фреймворк |
Самодельный оправдан для 2–3 шагов и простых компенсаций; как только появляются таймауты, восстановление координатора и версионирование процесса — дешевле взять durable-движок, чем переписывать эти механизмы самому.
Тестирование и наблюдаемость
Сага ценна ровно настолько, насколько протестированы её пути отказа. Ключевой приём — инъекция отказа на каждом шаге и проверка, что компенсации отработали в правильном порядке и до конца (мостик к тестированию распределённых систем). Отдельно — тест на идемпотентность: повторно доставленная команда не двоит эффект.
Наблюдаемость саги в проде:
- застрявшие саги — начатые, но не завершённые дольше таймаута (главный алерт);
- доля компенсаций — рост отката сигналит о проблеме в шагах;
- время завершения — деградация = сервис-участник тормозит.
Эксплуатационные ошибки
- Компенсация как «rollback». Ожидание, что состояние вернётся 1:1; на деле — обратное действие со своими эффектами и задержкой.
- Неидемпотентные шаги/компенсации. Ретрай координатора двоит списание или возврат.
- Игнор изоляции. Промежуточное состояние читают другие → нет semantic lock → грязные чтения и потерянные обновления.
- Pivot слишком рано. Необратимый шаг в начале саги лишает возможности чистого отката.
- Нет восстановления координатора. Упал оркестратор — саги «висят» недокомпенсированными.
- Компенсации не тестируют. Прямой путь зелёный, а откат впервые запускается в проде во время инцидента.
Checklist: сага в проде
- Шаги классифицированы (compensatable/pivot/retriable), pivot стоит как можно позже?
- Для каждого compensatable-шага написана и протестирована компенсация?
- Шаги И компенсации идемпотентны?
- Есть контрмера изоляции там, где промежуточное состояние видно (обычно semantic lock)?
- Состояние саги персистентно, есть таймауты и восстановление после падения координатора?
- Пути отказа покрыты тестами с инъекцией сбоя на каждом шаге?
- Есть алерт на застрявшие саги и метрика доли компенсаций?
Если по большинству «да» — сага переживёт частичные сбои, ради которых её и берут вместо распределённой транзакции.
Демо и версии
- Demo:
digital-cookbook/architecture/saga— сага «оформить заказ» (оплата → резерв → отгрузка → уведомление) через оркестрацию на Temporal (Go); инъекция отказа на шаге резерва → компенсация оплаты; демонстрация идемпотентности при ретрае и semantic lock на записи. Хореографический вариант на событиях разбирает концептуальная статья. - Версии движка (Temporal) / брокера пиновать при написании.
Документация и первоисточники
- Канон: microservices.io — Saga (Chris Richardson; классификация шагов и контрмеры изоляции), Garcia-Molina & Salem — «Sagas» (PDF).
- Продукт/движки: Temporal, Camunda, Axon — Saga, Eventuate Tram Sagas.
- Смежное на сайте: Saga — концепция, Хореография vs оркестрация, Temporal, гарантии доставки и идемпотентность, Outbox/Inbox, аномалии и изоляция, матрица решений.
Комментарии