awesome-everything EN
↑ Обратно к восхождению

Распределённые системы

Саги: долгоживущие транзакции между сервисами без 2PC

Суть Когда процесс охватывает сервисы рейса, отеля и машины, одну ACID-транзакцию на все не натянуть. Сага — это цепочка локальных коммитов, у каждого своя компенсация-откат: покупаешь доступность ценой изоляции.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Процесс бронирования списывает деньги с карты, бронирует рейс, затем пытается забронировать отель — а сервис отелей лежит. В монолите ты бы сделал ROLLBACK и ушёл чистым. Здесь rollback’а нет: деньги уже списаны в базе платежей, место уже удержано в базе рейсов. Три сервиса, три базы, три коммита, которые уже произошли. Единственный путь назад — это вперёд: вернуть деньги, освободить место — и этот откат ты обязан написать руками, для каждого шага, для каждой точки отказа.

Почему two-phase commit не выживает в микросервисах

Учебный ответ на транзакцию между базами — это two-phase commit (2PC): координатор просит каждого участника подготовиться, и когда все проголосовали «да», велит им закоммитить. Это корректно — и в микросервисной топологии это ловушка. На фазе prepare каждый участник держит свои блокировки открытыми, ожидая вердикта координатора — через сеть, через сервисы, которыми владеют разные команды. Если координатор умирает после prepare, но до commit, участники застревают in-doubt: заблокированы, не могут ни закоммитить, ни откатить, пока координатор не восстановится. Координатор — это блокирующая единая точка отказа, а блокировки, которые он навязывает, держатся на время сетевого round trip до самого медленного сервиса, а не локальной записи на диск.

Для двух таблиц в одном инстансе Postgres это нормально. Для пути запроса через шесть сервисов, один из которых — сторонний платёжный шлюз, который вообще нельзя записать в твою транзакцию, это фатально. Поэтому ты отказываешься от глобальной транзакции и принимаешь другую сделку: каждый сервис коммитит локально, сразу же, а шаги сшиваешь сообщениями вместо блокировок.

Сага: локальные коммиты плюс компенсации

Сага — это последовательность локальных транзакций. Каждый шаг обновляет базу одного сервиса и шлёт сообщение, которое запускает следующий шаг. Глобального коммита нет — каждый T1, T2, T3 становится durable в тот же миг, как отработал. Цена этой мгновенности в том, что автоматического отката нет, поэтому каждый прямой шаг Ti идёт со своей компенсирующей транзакцией Ci, которая семантически его обращает. Если шаг T3 падает, сага выполняет C2, затем C1, в обратном порядке, чтобы вернуть мир в согласованное состояние.

Пример бронирования поездки делает форму наглядной: T1 бронь рейса, T2 бронь отеля, T3 аренда машины. Если шаг с машиной падает, ты компенсируешь в обратном порядке — C2 отмена отеля, C1 отмена рейса. Компенсации выполняются в порядке, обратном прямым шагам, потому что более поздние шаги могут зависеть от более ранних.

Прямой шагКомпенсацияНастоящий откат?
T1 бронь рейсаC1 отмена рейсаНет — возможен штраф
T2 бронь отеляC2 отмена отеляНет — новая отмена, а не удаление
T3 аренда машиныC3 отмена машиныНет — бронь уже состоялась

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

Хореография против оркестрации

Есть два способа связать шаги между собой, и неверный выбор — это сожаление на несколько кварталов.

В хореографии координатора нет. Каждый сервис слушает события и реагирует: платежи слышат FlightBooked, списывают карту, шлют CardCharged; отель слышит это и бронирует номер. Это децентрализованно и не имеет единой точки отказа — но логика саги не существует целиком нигде. Чтобы ответить «что происходит после списания карты?», ты грепаешь четыре кодовые базы. Добавь пятый шаг — и трогаешь три сервиса. Заползают циклические зависимости событий. Трассировка застрявшего бронирования означает реконструкцию распределённой последовательности из логов по сервисам.

В оркестрации центральный оркестратор владеет процессом как явным кодом: он шлёт BookFlight, ждёт ответа, шлёт ChargeCard, и при любом сбое запускает компенсации. Вся сага читается в одном месте и в одной трассе; цена — новый компонент с состоянием, который надо построить, задеплоить и держать доступным. Это та ниша, которую закрывают движки durable-execution вроде Temporal — они персистят состояние оркестратора, чтобы шаг, упавший посреди саги, продолжился ровно с того места, где остановился, а не потерял процесс на лету.

Почему это работает

Грубая эвристика: хореография для короткого, стабильного, линейного процесса (2–3 шага, которые редко меняются); оркестрация, как только в процессе появляются ветвления, ретраи, таймауты или больше ~4 шагов — точка, где у вопроса «где логика?» перестаёт быть ответ в одном файле. Многие команды начинают с хореографии ради простоты и мигрируют на оркестратор, когда паутина событий становится нетрассируемой.

Жёсткая часть: у саг нет изоляции

Это та строка, которую пропускают в туториалах и обнаруживают в проде. Сага — это ACID минус I: она даёт Atomicity (через компенсацию), Consistency и Durability, но никакой Isolation. Поскольку каждая локальная транзакция коммитится сразу, её недоделанное состояние видно всем остальным до того, как сага в целом решила, успешна она или провалена. Гарсия-Молина и Салем назвали этот паттерн в 1987 году именно как ослабление изоляции для долгоживущих транзакций.

Отсюда три конкретные аномалии. Грязное чтение (dirty read): сага B читает заказ, который сага A закоммитила, но позже компенсирует, и действует на данных, которые вот-вот исчезнут. Потерянное обновление (lost update): две саги читают один и тот же баланс, обе пишут, одна затирает другую. Неповторяющееся чтение (non-repeatable read): сага читает значение на шаге 1 и другое значение на шаге 3, потому что другая сага изменила его между ними. Ничего из этого не может произойти внутри одной ACID-транзакции; всё это может произойти на протяжении жизни саги, которая длится секунды, или — для процесса, ждущего одобрения человека, — дни.

Контрмеры — на уровне приложения, а не базы. Семантическая блокировка (semantic lock) помечает запись pending/PENDING_PAYMENT, чтобы другие саги знали не трогать её, пока сага её не освободит. Коммутативные обновления (используй balance += delta, никогда balance = newValue) делают конкурентные записи независимыми от порядка. Перечитывание / проверка версии (reread) убеждается, что значение не изменилось, прежде чем перезаписать (оптимистичный контроль конкуренции). Ты переизобретаешь кусок того, что база давала бесплатно, — и именно поэтому за сагу берутся только тогда, когда одна транзакция действительно не может охватить работу.

Выбери лучший вариант

Процесс заказа из 6 шагов охватывает 5 сервисов, имеет ретраи, таймауты и шаг ручного одобрения, который может ждать дни. Выбери подход к координации.

Викторина

Шаг саги T2 списывает деньги с карты; шаг T3 падает. Что на самом деле делает C2 (компенсация T2)?

Викторина

Сага B читает заказ, который сага A закоммитила, но позже компенсирует, и действует на нём. Какая это аномалия и почему она возможна?

Расставь шаги по порядку

Сага поездки бронирует рейс (T1), отель (T2), машину (T3). Шаг с машиной падает. Расставь, что происходит:

  1. 1 T1 бронирует рейс, коммитится локально, шлёт событие
  2. 2 T2 бронирует отель, коммитится локально, шлёт событие
  3. 3 T3 пытается арендовать машину и падает
  4. 4 Запускается C2: отменить отель (компенсируем самый недавний завершённый шаг первым)
  5. 5 Запускается C1: отменить рейс (компенсируем в обратном порядке)
Вспомните перед уходом
  1. 01
    Объясни коллеге, почему компенсирующая транзакция — не то же самое, что rollback базы, и как это меняет порядок шагов.
  2. 02
    Что значит, что сага — это «ACID минус I», и что с этим делать в проде?
Итог

Two-phase commit корректен, но непригоден между микросервисами: он делает координатор блокирующей единой точкой отказа и держит блокировки между сервисами на время сетевого round trip, так что краш координатора оставляет участников in-doubt. Сага от этого отказывается. Это последовательность локальных транзакций, каждая закоммичена сразу и каждая в паре с компенсирующей транзакцией, которая семантически её отменяет — и поскольку компенсации это новые прямые действия (возврат, а не удаление), а некоторые эффекты вообще нельзя отменить, ты ставишь необратимые шаги последними. Шаги связываешь хореографией (сервисы реагируют на события; нет единой точки отказа, но логика размазана и трудно трассируется) или оркестрацией (центральный, часто durable, оркестратор владеет процессом; одно место для чтения и продолжения ценой нового компонента). Определяющий трейдофф в том, что сага — это ACID минус Isolation: промежуточные коммиты видны, так что грязные чтения, потерянные обновления и неповторяющиеся чтения становятся твоей проблемой, решаемой на уровне приложения семантическими блокировками, коммутативными обновлениями и перечитываниями. Берись за сагу только когда одна транзакция действительно не может охватить работу — и тогда проектируй путь отката до того, как отгрузишь happy path.

Продолжить восхождение ↑Саги: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.