Распределённые системы
Саги: долгоживущие транзакции между сервисами без 2PC
Процесс бронирования списывает деньги с карты, бронирует рейс, затем пытается забронировать отель — а сервис отелей лежит. В монолите ты бы сделал 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 T1 бронирует рейс, коммитится локально, шлёт событие
- 2 T2 бронирует отель, коммитится локально, шлёт событие
- 3 T3 пытается арендовать машину и падает
- 4 Запускается C2: отменить отель (компенсируем самый недавний завершённый шаг первым)
- 5 Запускается C1: отменить рейс (компенсируем в обратном порядке)
- 01Объясни коллеге, почему компенсирующая транзакция — не то же самое, что rollback базы, и как это меняет порядок шагов.
- 02Что значит, что сага — это «ACID минус I», и что с этим делать в проде?
Two-phase commit корректен, но непригоден между микросервисами: он делает координатор блокирующей единой точкой отказа и держит блокировки между сервисами на время сетевого round trip, так что краш координатора оставляет участников in-doubt. Сага от этого отказывается. Это последовательность локальных транзакций, каждая закоммичена сразу и каждая в паре с компенсирующей транзакцией, которая семантически её отменяет — и поскольку компенсации это новые прямые действия (возврат, а не удаление), а некоторые эффекты вообще нельзя отменить, ты ставишь необратимые шаги последними. Шаги связываешь хореографией (сервисы реагируют на события; нет единой точки отказа, но логика размазана и трудно трассируется) или оркестрацией (центральный, часто durable, оркестратор владеет процессом; одно место для чтения и продолжения ценой нового компонента). Определяющий трейдофф в том, что сага — это ACID минус Isolation: промежуточные коммиты видны, так что грязные чтения, потерянные обновления и неповторяющиеся чтения становятся твоей проблемой, решаемой на уровне приложения семантическими блокировками, коммутативными обновлениями и перечитываниями. Берись за сагу только когда одна транзакция действительно не может охватить работу — и тогда проектируй путь отката до того, как отгрузишь happy path.