lenec ru

← все посты

Saga в распределённых транзакциях: orchestration vs choreography на практике

16K

Распределённая транзакция в микросервисах — миф, который по инерции живёт в голове у людей, перешедших из мира монолита. Двухфазный коммит между четырьмя сервисами на разных БД технически возможен, но в проде ты этого не хочешь: блокировки, длинные ожидания, отсутствие поддержки в большинстве брокеров и кешей. 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. Ответы покажут, какой подход дешевле для вашего конкретного случая.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.