Saga на практике: компенсации, изоляция, идемпотентность, tooling

Концепция саги проста, а прод — нет: компенсация это не rollback, а обратное семантическое действие, и её порядок, идемпотентность и «а если компенсация упала» решают, работает система или разваливается. Плюс изоляция: сага даёт ACD, не ACID — грязные чтения и потерянные обновления реальны, и с ними борются семантическими локами и контрмерами. Разбираем классификацию шагов (compensatable/pivot/retriable), персистентность саги, tooling (Temporal/Camunda/Axon) и тестирование с инъекцией отказов

Концептуально сага — распределённая транзакция без двухфазного коммита: серия локальных транзакций, каждая с компенсацией на случай отката. Звучит стройно, пока не доходит до прода, где всплывают вопросы, которые концепция обходит: компенсация — это не «откатить», а выполнить обратное действие (деньги уже списаны — их надо вернуть, а не «отменить списание»); что делать, если сама компенсация упала; в каком порядке компенсировать; как не выполнить шаг дважды при ретрае; и что с изоляцией, которой у саги, в отличие от ACID-транзакции, попросту нет.

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

Цепочка вагонов-шагов саги (оплата, резерв, отгрузка, уведомление) сходит с рельсов у стрелки-pivot; ниже оранжевые вагоны-компенсации разматываются в обратном порядке, справа замок semantic lock на карточке-записи

В статье

Компенсация — не откат

Главное непонимание про сагу — что компенсация «откатывает» шаг. Ничего она не откатывает: локальная транзакция уже закоммичена, деньги уже списаны. Компенсация — это новое, обратное по смыслу действие: не «отменить списание», а вернуть деньги. И это семантически другое: возврат может занять время, оставить след в истории, не восстановить состояние 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-движок, чем переписывать эти механизмы самому.

Тестирование и наблюдаемость

Сага ценна ровно настолько, насколько протестированы её пути отказа. Ключевой приём — инъекция отказа на каждом шаге и проверка, что компенсации отработали в правильном порядке и до конца (мостик к тестированию распределённых систем). Отдельно — тест на идемпотентность: повторно доставленная команда не двоит эффект.

Наблюдаемость саги в проде:

  • застрявшие саги — начатые, но не завершённые дольше таймаута (главный алерт);
  • доля компенсаций — рост отката сигналит о проблеме в шагах;
  • время завершения — деградация = сервис-участник тормозит.

Эксплуатационные ошибки

  1. Компенсация как «rollback». Ожидание, что состояние вернётся 1:1; на деле — обратное действие со своими эффектами и задержкой.
  2. Неидемпотентные шаги/компенсации. Ретрай координатора двоит списание или возврат.
  3. Игнор изоляции. Промежуточное состояние читают другие → нет semantic lock → грязные чтения и потерянные обновления.
  4. Pivot слишком рано. Необратимый шаг в начале саги лишает возможности чистого отката.
  5. Нет восстановления координатора. Упал оркестратор — саги «висят» недокомпенсированными.
  6. Компенсации не тестируют. Прямой путь зелёный, а откат впервые запускается в проде во время инцидента.

Checklist: сага в проде

  1. Шаги классифицированы (compensatable/pivot/retriable), pivot стоит как можно позже?
  2. Для каждого compensatable-шага написана и протестирована компенсация?
  3. Шаги И компенсации идемпотентны?
  4. Есть контрмера изоляции там, где промежуточное состояние видно (обычно semantic lock)?
  5. Состояние саги персистентно, есть таймауты и восстановление после падения координатора?
  6. Пути отказа покрыты тестами с инъекцией сбоя на каждом шаге?
  7. Есть алерт на застрявшие саги и метрика доли компенсаций?

Если по большинству «да» — сага переживёт частичные сбои, ради которых её и берут вместо распределённой транзакции.

Демо и версии

  • Demo: digital-cookbook/architecture/saga — сага «оформить заказ» (оплата → резерв → отгрузка → уведомление) через оркестрацию на Temporal (Go); инъекция отказа на шаге резерва → компенсация оплаты; демонстрация идемпотентности при ретрае и semantic lock на записи. Хореографический вариант на событиях разбирает концептуальная статья.
  • Версии движка (Temporal) / брокера пиновать при написании.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии