Saga в распределённых транзакциях: orchestration vs choreography на практике
Распределённая транзакция в микросервисах — миф, который по инерции живёт в голове у людей, перешедших из мира монолита. Двухфазный коммит между четырьмя сервисами на разных БД технически возможен, но в проде ты этого не хочешь: блокировки, длинные ожидания, отсутствие поддержки в большинстве брокеров и кешей. Saga — это компромисс, в котором длинная бизнес-операция распадается на серию локальных транзакций, каждая со своей компенсацией.
За двенадцать лет я строил saga и через orchestration, и через choreography в трёх разных компаниях. И каждый раз спор «что лучше» начинался с того же места — пока не выяснялось, что лучшего нет. Есть контекст и трейдоффы. Разберу оба подхода на одном сценарии, покажу, где какой выигрывает, и какие подводные камни не описывают учебники.
Сценарий: оформление заказа
Берём типичный e-commerce: пользователь оформляет заказ, ему резервируют товар на складе, списывают деньги с платёжной системы и создают задачу на доставку. Четыре сервиса: orders, inventory, payments, shipping. Если любой шаг упал — надо откатить предыдущие.
Откат здесь — это не ROLLBACK, а компенсирующая операция. Списали деньги — компенсация: вернуть. Зарезервировали товар — компенсация: освободить резерв. Создали заказ в orders — компенсация: пометить как cancelled. Бизнесовая семантика отката, не техническая.
Choreography: каждый сам за себя
В choreography нет центральной координации. Сервисы общаются событиями: один публикует, другие подписываются и реагируют.
orders --OrderCreated--> [event bus]
inventory подписался: резервирует, публикует InventoryReserved
payments подписался: списывает, публикует PaymentCharged
shipping подписался: создаёт задачу, публикует ShipmentScheduled
orders подписан на ShipmentScheduled: переводит заказ в ConfirmedОткат тоже идёт через события. Если payments опубликовал PaymentFailed, на него подписан inventory и публикует InventoryReleased. orders ловит и переводит в Cancelled.
Что вы выигрываете:
- Нет single point of failure в виде оркестратора.
- Сервисы слабо связаны: добавить нового подписчика — не трогать существующие.
- Естественный фит для систем, уже построенных на событиях.
Что теряете:
- Невидимость флоу. Чтобы понять, как идёт saga, нужно собрать схему по подпискам в пяти разных репозиториях. Через год команда не знает, что куда идёт.
- Циклические зависимости. Сервис A публикует событие, на которое подписан B, B публикует другое, на которое подписан A. Так возникают петли.
- Сложный отладочный путь. «Заказ застрял в pending» — где? В каком сервисе он не получил своё событие? Вопрос на час расследования.
Orchestration: дирижёр
В orchestration появляется отдельный сервис-оркестратор (или процесс внутри orders), который явно ведёт saga от шага к шагу.
class OrderSaga(
private val inventory: InventoryClient,
private val payments: PaymentsClient,
private val shipping: ShippingClient,
private val store: SagaStateStore
) {
fun execute(orderId: UUID) {
val state = store.load(orderId) ?: SagaState.new(orderId)
when (state.step) {
"INIT" -> {
inventory.reserve(orderId)
store.save(state.next("INVENTORY_RESERVED"))
}
"INVENTORY_RESERVED" -> {
try {
payments.charge(orderId)
store.save(state.next("PAYMENT_CHARGED"))
} catch (e: PaymentFailed) {
inventory.release(orderId)
store.save(state.fail("PAYMENT_FAILED"))
}
}
"PAYMENT_CHARGED" -> {
shipping.schedule(orderId)
store.save(state.next("COMPLETED"))
}
}
}
}Оркестратор знает про все шаги и компенсации. Он же ведёт состояние saga в своей БД: на каком шаге, что уже выполнено, что нужно компенсировать при сбое.
Что вы выигрываете:
- Видимость. Открыл код оркестратора — увидел всю саге целиком.
- Простая отладка: состояние saga в одной таблице, легко найти застрявшие.
- Удобно править логику: новый шаг — изменение в одном месте.
Что теряете:
- Оркестратор становится координатором с большим знанием. Связность с сервисами выше.
- Single point of failure: упал оркестратор — стоят все saga.
- Граница между «бизнес-логика заказа» и «оркестратор саги» размывается. Часто оркестратор тащит на себя бизнес-правила, которые должны жить в доменных сервисах.
Когда что выбирать
Мой эвристический критерий — сложность графа.
Choreography работает, когда саге линейная или почти линейная: A → B → C → D, без ветвлений. Сервисы небольшие, события — естественный язык их интеграции, и команда хорошо чувствует event-driven подход.
Orchestration побеждает, когда:
- Есть условные ветки: если сумма больше N, идём через дополнительную проверку KYC.
- Параллельные шаги, которые надо синхронизировать: «зарезервировать товар И списать деньги одновременно, ждём оба».
- Откат сложный: компенсации идут не в обратном порядке шагов.
- Команда новая в распределёнке. Оркестратор виднее, легче заходит.
В одном проекте я видел гибрид: оркестратор для критичной финансовой части (заказ + платёж), choreography для нотификаций и аналитики. Это не «лучше всего», но работает: главные саге явные, побочные эффекты — асинхронные.
Состояние и идемпотентность
Что бы вы ни выбрали, есть два вопроса, которые надо решить заранее.
Где хранить состояние saga
В choreography состояние распределено: каждый сервис хранит свой кусок. Это работает, пока шагов мало. На шести-семи шагах вопрос «в каком состоянии saga целиком» становится ответом «обойди шесть сервисов и собери».
В orchestration состояние явное — таблица в БД оркестратора. Минимальная схема:
CREATE TABLE saga_state (
saga_id UUID PRIMARY KEY,
saga_type TEXT NOT NULL,
current_step TEXT NOT NULL,
payload JSONB NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ
);
CREATE INDEX saga_active_idx ON saga_state (updated_at)
WHERE completed_at IS NULL AND failed_at IS NULL;Любая операция над saga идёт через UPDATE этой таблицы в той же транзакции, что и outbox-сообщение для следующего шага. Иначе у вас будет saga, которая «выполнилась», но событие не отправилось.
Идемпотентность шагов
Любой шаг saga может выполниться дважды: ретраи, перезапуски, повторные события. Поэтому каждый шаг обязан быть идемпотентным. Без этого вы дважды спишете деньги или дважды зарезервируете товар.
На практике это значит: сервис должен принимать saga_id или operation_id и хранить дедуп. payments.charge(orderId) по второму вызову с тем же orderId возвращает уже сохранённый ответ, не списывает снова.
Компенсации, которые не работают
Главное заблуждение про saga — «компенсация всегда возможна». В реальной жизни — нет.
Если вы отправили email клиенту, отозвать его уже нельзя. Если уже передали заказ во внешний сервис доставки и фура поехала — отменить «бесплатно» не получится, будет плата за отказ. Если отправили деньги на счёт, который сейчас закрыт, — компенсация уйдёт в долгосрочную ручную работу.
Поэтому саге проектируется с учётом «точки невозврата». До неё все шаги обратимы дешёвыми компенсациями. После неё — only-forward: если что-то сломалось, мы не откатываем, мы доводим до конца руками или с retry. Эту точку обычно ставят перед самым «деструктивным» внешним вызовом.
Концептуально:
- Pivot transaction — после которого откат становится дорогим. Платёж в банке — типичный pivot.
- Compensable transactions — до pivot, легко откатываются.
- Retriable transactions — после pivot, мы не откатываем, а ретраим до успеха.
Если в вашей саге pivot где-то посередине, оркестратор должен это явно знать: после pivot он переходит из режима «откатываю при ошибке» в режим «ретраю до победного».
Подводные камни
Несколько вещей, которые я ловил в проде.
Долгие saga. Saga, которые живут часами или днями (например, ждут подтверждения от внешнего партнёра), требуют отдельного подхода. Хранить state в памяти процесса — нельзя, его перезапустят. Использовать обычные таймауты — не получится. Я обычно завожу отдельный scheduler, который проверяет «зависшие» саге и реагирует.
Параллельные саге над одним ресурсом. Две саге одновременно резервируют один товар. Без блокировок одна из них сожрёт остаток, вторая получит компенсацию. Если допустимо — нормально. Если нет — нужны оптимистичные блокировки на стороне inventory.
Версионирование. Деплоите новую версию saga с дополнительным шагом. Старые саге, начатые до деплоя, не знают про этот шаг. Решение — версионировать saga (saga_type = 'order_v2') и держать обработчик старой версии до её естественного завершения.
Мониторинг. Saga без observability — бомба замедленного действия. Минимум: метрика «зависших» (started, не completed/failed дольше N), алерты по росту failed, дашборд с распределением по шагам. Я обычно ставлю SLO «99% saga завершаются за 60 секунд» и watchаю отклонения.
Что запомнить
Saga — это не «реализация распределённых транзакций», а паттерн управления длинной бизнес-операцией с компенсациями. Choreography хорош для линейных и слабосвязанных флоу, orchestration — для сложных и условных.
Главное — компенсации, идемпотентность и явное состояние. Без них saga превращается в источник зомби-операций: списали, не дотащили, забыли. Прежде чем делать выбор между orchestration и choreography, ответьте себе на три вопроса: какова сложность графа, можно ли откатить каждый шаг и где у вас pivot. Ответы покажут, какой подход дешевле для вашего конкретного случая.