← назад к разделу

Это финальная статья серии по распределённым данным. В проблемах распределённых систем мы увидели, чему нельзя доверять: сети, часам, собственному процессу. Здесь — про абстракции, которые всё это скрывают, и про консенсус — согласие узлов по одному вопросу. Главный вывод главы неожиданный: половина задач, которые кажутся разными (выбор ведущего, уникальность имени, атомарная фиксация транзакции), — на самом деле одна и та же задача. Разберёмся, как к ней прийти.

Линеаризуемость: иллюзия единственной копии

В базе с конечной согласованностью один и тот же запрос к двум репликам может вернуть разное. Линеаризуемость (она же сильная согласованность) убирает это: система ведёт себя так, будто копия данных одна и каждая операция атомарна. Как только клиент дописал значение, любой следующий читатель видит именно его — это гарантия актуальности, не «когда-нибудь сойдётся».

Классический пример нарушения: Алиса и Боб смотрят финал в одной комнате. Алиса обновила страницу, увидела счёт, крикнула Бобу. Боб обновляет — а его запрос попал на отставшую реплику, и матч «ещё идёт». Отставание реплики стало видимым багом, потому что между ними был второй канал связи (голос Алисы).

Линеаризуемость нужна не везде — она медленная (см. CAP ниже) — но в нескольких местах критична:

  • Выбор ведущего. Ведущий должен быть ровно один (иначе split brain). Узлы должны прийти к единому мнению, кто он.
  • Ограничения уникальности. Два человека одновременно регистрируют одно имя / бронируют одно место / снимают с одного счёта — ровно один должен выиграть.
  • Межканальные зависимости. Пример выше: два пути к данным (кеш и база, очередь и хранилище) рождают гонку, которую линеаризуемость закрывает.

Важно не путать с сериализуемостью: та — про изоляцию транзакций (несколько объектов, порядок транзакций), линеаризуемость — про актуальность чтения-записи одного объекта. Это разные гарантии.

Теорема CAP без мифов

Почему бы не делать всё линеаризуемым? Из-за сети. Представь два ЦОДа с репликацией; связь между ними оборвалась. Клиент на «отрезанной» стороне хочет записать. Выбор жёсткий:

  • Требуем линеаризуемость — реплика на меньшинстве не может подтвердить актуальность, поэтому обязана отказать (стать недоступной), пока связь не восстановится. Это «C» — согласованность ценой доступности.
  • Хотим доступность — принимаем запись локально, но реплики разойдутся (нелинеаризуемо). Это «A».

Это и есть CAP. Но популярная расшифровка «выбери 2 из 3: Consistency, Availability, Partition tolerance» — неверна: сетевой сбой — не то, что можно «не выбрать», он просто случается. Честная формулировка: либо согласованность, либо доступность — когда сеть разорвана. При исправной сети получаешь и то, и другое. CAP к тому же узок — он про один тип сбоя (разрыв связи) и одну модель (линеаризуемость), молчит про задержки и мёртвые узлы. Поэтому на практике теорема имеет скорее исторический интерес.

И отдельный факт: удивительно мало систем линеаризуемы — не ради отказоустойчивости, а ради скорости. Линеаризуемость медленная всегда (время отклика пропорционально сетевой задержке), и большинство систем сознательно её не дают.

Порядок, причинность и рассылка

Линеаризуемость подразумевает полный порядок операций: единая шкала, на которой любые две операции сравнимы. Но есть более слабый и часто достаточный порядок — причинный (causal): он упорядочивает только операции, связанные отношением «происходит до» (happens-before). Вопрос должен предшествовать ответу; строку надо создать раньше, чем обновить. Операции, которые не знают друг о друге, — конкурентны и остаются несравнимыми. Причинный порядок — частичный: как история версий в Git, где ветки сливаются.

Причинность можно сохранять дешевле линеаризуемости. Метки времени стенных часов для порядка не годятся (часы расходятся), а вот метки Лампорта — логические счётчики, растущие при каждой операции и передаваемые в каждом сообщении, — дают полный порядок, согласованный с причинностью. Но и их недостаточно для уникальности имени: порядок узнаётся только постфактум, когда все операции собраны, — а решать «занято ли имя прямо сейчас» так нельзя.

Что действительно нужно — знать, когда последовательность зафиксирована. Это рассылка общей последовательности (total order broadcast): протокол, гарантирующий, что все узлы получают одни и те же сообщения в одном и том же порядке, надёжно, даже при сбоях. Это ровно журнал репликации: доставить сообщение = дописать в журнал, и все реплики, проигрывая его в одном порядке, сходятся.

Консенсус: всё это — одна задача

Вот кульминация. Оказывается, следующие задачи эквивалентны — реши одну, получишь решение остальных:

  • рассылка общей последовательности;
  • линеаризуемый регистр со сравнением-с-присвоением (compare-and-set);
  • выбор ведущего;
  • ограничение уникальности;
  • атомарная фиксация распределённой транзакции.

Все они — консенсус: заставить несколько узлов согласовать одно значение. И у консенсуса есть строгие свойства (единое решение, целостность, действительность, завершённость — алгоритм обязан двигаться вперёд, пока живо большинство).

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

Хороший консенсус — Raft, Paxos, Zab, VSR. Все они опираются на кворум большинства (два большинства не пересекаются → конфликтующих решений нет) и «эпохи»: в каждой эпохе не больше одного ведущего, а решение принимает голосование большинства. Теория пугает результатом FLP (в полностью асинхронной системе гарантированного консенсуса нет), но на практике достаточно частичной синхронности или случайности — и консенсус достижим.

Практический вывод один и жёсткий: не реализуй консенсус сам. Правильно это сделать почти невозможно. Возьми готовый сервис — ZooKeeper или etcd: они дают линеаризуемое хранилище, выбор ведущего, распределённые блокировки и ограждающие маркеры как сервис. Так и работают HBase, Kafka, Kubernetes.

Где это применяется

Каждый раз, когда в системе нужен ровно один ведущий, уникальное имя, «занять ресурс атомарно» или согласованный порядок событий, — вы упёрлись в консенсус, даже если не назвали его так. Практическая рамка: не тяните линеаризуемость и распределённые транзакции без нужды (они медленные и хрупкие — приложения обходят их сагами и outbox); а там, где согласие узлов действительно необходимо, — не изобретайте протокол, делегируйте координацию ZooKeeper/etcd.

Где спотыкаются начинающие:

  • Путают линеаризуемость и сериализуемость — это про актуальность одного объекта и про изоляцию транзакций соответственно, гарантии разные.
  • Расшифровывают CAP как «2 из 3» — сетевой сбой не выбирают; выбор «согласованность или доступность» встаёт только при разрыве связи.
  • Упорядочивают события меткой Лампорта и думают, что решили уникальность — полный порядок известен лишь постфактум; нужна рассылка общей последовательности.
  • Пишут свой распределённый лок/выбор ведущего — это консенсус, который почти невозможно реализовать верно. ZooKeeper/etcd.
  • Тянут 2PC между микросервисами — блокирующий протокол с координатором-SPOF; в микросервисах почти всегда лучше сага.

Что почитать дальше: проблемы распределённых систем — частичные отказы и fencing token, с которых начинается консенсус; модели репликации — кворумы и конечная согласованность; распределённые транзакции — почему приложения обходят 2PC сагами; ACID и изоляция — сериализуемость, которую важно не путать с линеаризуемостью. Первоисточник — Мартин Клеппман, «Высоконагруженные приложения», глава 9.