2026 г.

Курс «Распределённые системы». Глава 9. Распределённые транзакции

Цели главы. Атомарность «всё или ничего» поверх нескольких узлов — исторически самое больное место распределённых систем. Разберём двухфазную фиксацию с её врождённой блокирующей природой, посмотрим, как современные распределённые СУБД вылечили её консенсусом и работой со временем (Percolator, Spanner, HLC-семейство), и спустимся к прагматике микросервисного мира: саги с компенсациями и transactional outbox. Сквозная линия главы — за каждым решением стоит теорема из главы 3, и честный инженер знает, какую цену и за что он платит.

9.1. Постановка и словарь

Транзакция затрагивает данные на нескольких узлах (шардирование — глава 8, разные сервисы со своими БД). Требования классические — ACID, но в распределённом контексте их надо расщепить, чтобы не путать (обещали в главе 4): атомарность — либо все узлы применили свои части, либо ни один; изоляция — конкурентные транзакции не видят промежуточных состояний друг друга (уровни: read committed, snapshot isolation, serializable); согласованность реплик из главы 4 — третья, отдельная ось. Глава в основном об атомарности и изоляции; их переплетение со временем — в 9.4.

9.2. Двухфазная фиксация и её проклятие

Классика (Грей, 1978). Координатор ведёт транзакцию с участниками:

  1. Фаза 1 (prepare): координатор рассылает «готовься»; участник доводит свою часть до состояния, из которого гарантированно может и зафиксировать, и откатить (данные и намерение — в надёжном журнале, блокировки удержаны), и отвечает «готов». Ответ «готов» — безотзывное обещание: с этого момента участник не имеет права передумать сам.
  2. Фаза 2 (commit/abort): собрав «готов» от всех — координатор пишет решение в свой журнал (это и есть точка фиксации всей транзакции) и рассылает «фиксируй»; любой отказ или тайм-аут в фазе 1 — рассылает «откатывай».

Две фазы протокола 2PC между координатором и участниками
Рис. 9.1. После ответа READY участники зависят от доступности устойчиво записанного глобального вердикта.

Протокол корректен — и блокирующий: участник, сказавший «готов» и потерявший координатора, застрял. Откатить нельзя (обещал), зафиксировать нельзя (решения не знает — вдруг сосед ответил «не готов»), остаётся ждать восстановления координатора или иного источника его устойчиво записанного решения — с удержанными блокировками, останавливая всё, что касается тех же данных. Это не мелкая недоработка: атомарная фиксация тесно связана с консенсусом, но не совпадает с ним — у неё другое условие обоснованности (голос «не готов» обязан привести к откату). Трёхфазный 3PC устраняет некоторые блокировки лишь при более сильных временных предположениях и отсутствии сетевых разделений. В обычном 2PC координатор единолично записывает глобальный вердикт; пока этот вердикт недоступен, подготовившийся участник не может безопасно угадать исход.

Практические следствия: legacy-механизмы XA (2PC между разнородными системами — БД + брокер сообщений) в эксплуатации регулярно оставляют «зависшие» транзакции, требующие ручного вердикта администратора; современная практика избегает 2PC между организациями и разнородными системами, оставляя его там, где обе фазы — внутри одной управляемой системы. Что подводит к правильной починке.

9.3. Починка первая: 2PC поверх консенсуса

Диагноз ясен — единственная точка истины; практическое лечение из главы 6 — реплицировать её. В архитектуре Spanner (и родственных распределённых СУБД) каждый шард — не узел, а группа консенсуса (Paxos/Raft): и состояние участника «готов», и решение координатора — записи в реплицируемых журналах, переживающие отказ меньшинства. После отказа лидера роль координатора может принять новый лидер группы вместе с журналом. Это убирает блокировку при одиночном отказе, но не превращает атомарную фиксацию в безусловно неблокирующую: если группа координатора или участника не может собрать большинство, транзакция не продвигается. Цена доступности решения — кворумы, частичная синхронность и работоспособные большинства.

9.4. Изоляция и время: TSO, TrueTime, HLC

Атомарность — полдела; конкурентным транзакциям нужна изоляция, а её распределённая реализация упирается в знакомый по главе 2 вопрос: что значит «раньше» между шардами?

  • Снимковая изоляция по глобальным меткам. Схема Percolator (Google, 2010; идейный предок TiDB): выделенный сервис — раздатчик меток времени (TSO) — выдаёт монотонные метки; транзакция читает снимок по start_ts, фиксируется по commit_ts, конфликты записей обнаруживаются по пересечению интервалов. Просто и честно; TSO — центральная точка (реплицируемая консенсусом), предел — его пропускная способность и задержка до него (гео-развёртывание страдает).
  • TrueTime. Spanner заменил логическую центральную точку физикой: атомные часы и GPS дают интервал TT = [earliest, latest], гарантированно содержащий истинное время. Транзакция выбирает метку фиксации не раньше TT.now().latest и перед ответом ждёт, пока TT.after(commit_ts) не станет истинным. После этого метка гарантированно осталась в прошлом для будущих транзакций — отсюда внешняя согласованность. Цена — инфраструктура времени и ожидание, величина которого зависит от текущей неопределённости TrueTime.
  • Гибридные логические часы. CockroachDB сочетает физическую составляющую с логической и ограничивает допустимое расхождение часов; из-за неопределённости возможны дополнительные ожидания и перезапуски. Это не универсальная схема всех распределённых СУБД: например, YDB упорядочивает распределённые транзакции логическим временем, которое назначают координаторы.

Итог раздела для архитектора: «распределённый ACID» в 2026 году — реальность, но порядок транзакций всегда имеет цену. Системы по-разному сочетают логических координаторов и раздатчики меток, аппаратное время, гибридные часы, ожидания и перезапуски; свести все реализации ровно к трём взаимоисключающим вариантам нельзя.

9.5. Починка вторая: саги

Микросервисный мир (у каждого сервиса своя БД, между ними — сеть и чужие команды) от 2PC отказался почти повсеместно — блокировки через границы сервисов недопустимы ни технически, ни организационно. Ответ — сага (Garcia-Molina, Salem, 1987): длинная операция = последовательность локальных транзакций T₁…Tₙ, каждой из которых сопоставлена компенсация C₁…Cₙ; при провале Tₖ исполняются C(k−1)…C₁ в обратном порядке. Бронирование поездки: T₁ — списать деньги, T₂ — билет, T₃ — отель; отель не вышел — вернуть билет, вернуть деньги. Координация — оркестрация (явный управляющий процесс с журналом состояния саги; проще отлаживать) либо хореография (сервисы реагируют на события друг друга; меньше связность, труднее видеть целое).

Цена, о которой обязана знать команда: сага не даёт ACID-атомарности и не изолирована. Между T₁ и компенсацией мир видит промежуточное состояние (деньги списаны, поездки нет), другие транзакции могут на него опереться; компенсация обычно является новой бизнес-операцией, а не буквальным возвратом прошлого состояния. Приёмы смягчения — семантические: резервирование вместо списания (T₁ «заморозить», подтверждение отдельным шагом), статусы «в обработке», запрет действий над объектами незавершённых саг. Каждый шаг и каждая компенсация должны быть идемпотентны, а их выполнение — записано в устойчивом журнале и повторяться после сбоев. Неустранимая ошибка компенсации требует сверки и ручного разрешения; необратимые шаги по возможности ставят последними.

9.6. Атомарность на минималках: transactional outbox

Самая частая микро-версия проблемы главы: сервис должен атомарно изменить свою БД и отправить событие в брокер (глава 10). Наивные порядки ломаются оба: «БД, потом брокер» теряет событие при падении между ними; «брокер, потом БД» рождает событие о несостоявшемся факте. 2PC между БД и брокером (XA) — см. 9.2. Каноническое решение — outbox: событие пишется в ту же БД, в ту же локальную транзакцию, в таблицу-исходящую; отдельный доставщик (поллер или чтение журнала БД — CDC, Debezium) переносит записи в брокер и помечает отправленными. Атомарность куплена локальной транзакцией; доставка — at-least-once, потребитель обязан быть идемпотентным (дежавю? — это ровно связка «at-least-once + дедупликация» из главы 1, ставшая паттерном). Outbox — мост в главу 10, где журналы событий станут главным героем.

Итоги главы

  • 2PC корректен и блокирующий: «готов» — безотзывное обещание, а без доступного глобального вердикта подготовившиеся участники не могут безопасно выбрать исход; 3PC требует более сильной модели сети.
  • Репликация координатора и участников группами Raft/Paxos переносит одиночные отказы, но при потере большинства соответствующей группы транзакция всё равно не продвигается.
  • Распределённая изоляция требует общего порядка: TSO-метки (Percolator/TiDB), TrueTime + commit-wait (Spanner), HLC (CockroachDB) и логические координаторы (YDB) представляют разные инженерные решения.
  • Саги заменяют ACID-атомарность бизнес-компенсациями; изоляции нет, промежуточные состояния видимы, а неустранимые ошибки требуют сверки и ручного разрешения.
  • Outbox: атомарность «БД + событие» локальной транзакцией + at-least-once доставка + идемпотентный потребитель.

Упражнения

  1. Проследите 2PC при отказе координатора сразу после записи решения «фиксировать» в свой журнал, но до рассылки. Что делают участники до его восстановления? Почему участник не может «спросить соседей» и решить без координатора — постройте сценарий, где двое «готовых» участников, опросив друг друга, приняли бы неверное решение (подсказка: был третий).
  2. Докажите редукцию «атомарная фиксация → консенсус»: покажите, как решатель атомарной фиксации решает консенсус узлов о значении из {0,1}, и выведите из FLP следствие для детерминированной неблокирующей фиксации в асинхронной сети.
  3. В схеме Percolator транзакция A (start_ts=100) читает x, транзакция B (start_ts=90, commit_ts=105) записала x. Видит ли A запись B? А при commit_ts=98? Сформулируйте правило видимости снимка и покажите, какую аномалию оно исключает.
  4. Объясните commit-wait: почему Spanner ждёт до выполнения TT.after(commit_ts), как неопределённость TrueTime влияет на длительность ожидания и что сломается при часах, вышедших за заявленные границы? Как тот же риск учитывают HLC-системы?
  5. Спроектируйте сагу «оформление заказа»: резерв товара, списание оплаты, создание доставки. Для каждого шага — компенсация; укажите некомпенсируемый шаг и обоснуйте его место; опишите, что видит конкурентный запрос «остаток товара» в середине саги и каким семантическим приёмом вы спрячете аномалию.
  6. Реализуйте outbox на уровне схемы: DDL таблицы, псевдокод бизнес-транзакции, псевдокод доставщика с меткой отправки. Покажите, где возникает дубликат события при падении доставщика, и что обязан сделать потребитель.

Ответы и указания. 1: в данном исполнении участники ждут, пока станет доступна запись COMMIT. Их локальные состояния неотличимы от другого исполнения, где те же двое готовы, третий проголосовал против и координатор записал ABORT; поэтому решение по опросу только готовых соседей небезопасно. 2: предложивший 0 голосует «не готов», предложивший 1 — «готов»; COMMIT отображается в 1, ABORT — в 0. При всех единицах получается 1, а при любом нуле решение 0 предложено хотя бы одним узлом. Неблокирующий решатель атомарной фиксации тем самым решил бы бинарный консенсус, что противоречит FLP в указанной модели. 3: A видит версии с commit_ts ≤ start_ts: при 105 запись невидима, при 98 видима. Снимок не смешивает состояния одной зафиксированной транзакции. 4: Spanner выбирает метку фиксации не раньше TT.now().latest и ждёт до TT.after(commit_ts); фактическое ожидание определяется текущей неопределённостью, а не механически всегда равно одной константе. Если истинное время выйдет из заявленного интервала, внешний порядок может нарушиться. Системы с HLC вместо TrueTime контролируют максимальный сдвиг часов и в зависимости от протокола ждут, продвигают метки или перезапускают конфликтующие операции. 5: резерв товара компенсируется снятием резерва, авторизация оплаты — отменой или возвратом, создание доставки — отменой задания до передачи перевозчику. Физически отправленная посылка необратима и потому идёт последней; остаток в середине показывает «зарезервировано» отдельно от «доступно», а конкурентные операции не расходуют резерв повторно. 6: outbox содержит event_id, aggregate_id, payload, created_at и sent_at; бизнес-транзакция меняет предметную таблицу и вставляет outbox-строку. Доставщик выбирает непомеченные строки с блокировкой, публикует event_id и затем ставит sent_at. Падение после публикации до sent_at создаёт повтор, поэтому потребитель атомарно записывает event_id вместе со своим эффектом и на повторе ничего не делает.

Литература к главе

  1. J. Gray, "Notes on Data Base Operating Systems," 1978 — первоисточник 2PC.
  2. H. Garcia-Molina, K. Salem, "Sagas," SIGMOD, 1987.
  3. D. Peng, F. Dabek, "Large-scale Incremental Processing Using Distributed Transactions and Notifications (Percolator)," OSDI, 2010.
  4. J. Corbett et al., "Spanner: Google's Globally-Distributed Database," OSDI, 2012.
  5. M. Kleppmann, "Designing Data-Intensive Applications," O'Reilly, 2017 — гл. 7, 9 (транзакции; согласованность и консенсус).
  6. C. Richardson, "Microservices Patterns," Manning, 2018 — саги и outbox в микросервисном контексте.

Предыдущая глава || Содержание курса || Следующая глава

404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией