Геораспределение: partitioning, zone configs и survival goals

Многорегиональный CockroachDB на девяти узлах. Цели выживания различаются не числом реплик, а числом ГОЛОСУЮЩИХ: три против пяти, и от этого зависит, покидает ли кворум записи свой регион. Плюс расхождение, которое стенд показал вживую: в семи прогонах одна и та же объявленная конфигурация дала три разных исхода, а однажды сошлась по числу голосующих, не достигнув при этом цели выживания.

Геораспределение решает две независимые задачи, которые легко перепутать. Первая — близость: данные пользователя лежат рядом с пользователем, и запрос не летит через полмира. Вторая — выживаемость: кластер переживает потерю зоны доступности или целого региона.

Задачи независимы, а механизмы у них общие, и настраиваются они одними и теми же ручками. Отсюда путаница: «сделали мультирегион» может означать и то, и другое, и ни одного из двух.

Эта статья разбирает механику размещения на живом кластере из девяти узлов, разложенных по трём «регионам». И она же — единственная в серии, где половина заявленного не измерена, о чём сказано сразу, а не в примечании мелким шрифтом.

Карта из трёх островов-регионов «ru-west», «ru-central», «ru-east», каждый разбит на зоны a, b, c. Верхняя половина, заголовок «пережить зону»: на центральном острове три закрашенных кружка с подписью «голосующие», на двух других по одному полому — «неголосующие»; пунктирный контур кворума охватывает два закрашенных и целиком помещается внутри центрального острова, подпись «кворум 2 из 3 — не покидает регион». Нижняя половина, заголовок «пережить регион»: закрашенных кружков пять, разложены два-два-один по трём островам, полых нет; контур кворума охватывает три закрашенных на разных островах и растянут через проливы, подпись «кворум 3 из 5 — пересекает границу на каждой записи». Слева легенда «реплик 5 · 5», «голосующих 3 → 5» и расшифровка закрашенного и полого кружка. Внизу лента в две строки: «RTT между регионами стенда 0.088–0.101 мс — цена расстояния не измерена» и «при цели „зона“ объявленная раскладка на стенде не реализовалась»

Чего здесь нет

Все девять узлов стоят на одной машине. Расстояния между ними нет:

внутри-ru-central       RTT ср. 0.098 мс
ru-central -> ru-west   RTT ср. 0.101 мс
ru-central -> ru-east   RTT ср. 0.088 мс

Эмулировать межрегиональные задержки не вышло. Штатный способ — tc netem — упёрся в ядро:

tc qdisc replace dev eth0 root netem delay 30ms
  -> Error: Specified qdisc kind is unknown.
tc qdisc replace dev eth0 root handle 1: prio bands 4
  -> Error: Specified qdisc kind is unknown.
ls /lib/modules/$(uname -r)/kernel/net/sched/
  -> каталога нет

Ядро собрано без планировщиков трафика вообще — ни netem, ни prio, и подгрузить их неоткуда.

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

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

В статье

  • регионы и зоны как единицы размещения и отказа;
  • REGIONAL BY ROW: строка получает регион своего шлюза — с замером;
  • голосующие и неголосующие реплики: почему считать надо не все;
  • арифметика кворума: 2 из 3 внутри региона против 3 из 5 через границу;
  • конфигурация зоны как намерение, а не факт — с измеренным расхождением;
  • follower reads в многорегиональной топологии — и почему их выгоду тут тоже не измерить;
  • матрица компромиссов, которую придётся выбирать осознанно.

Топология

Девять узлов: три «региона» по три зоны в каждом. Регион и зона — это метки, которые узел получает при старте:

--locality=region=ru-central,zone=a
--locality=region=ru-west,zone=b
--locality=region=ru-east,zone=c

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

Кластер видит регионы сам:

region      zones
ru-east     {a,b,c}
ru-west     {a,b,c}
ru-central  {a,b,c}

Строка получает регион своего шлюза

Многорегиональная база объявляет главный регион и добавляет остальные. Таблица с локальностью REGIONAL BY ROW получает скрытую колонку:

crdb_region public.crdb_internal_region NOT VISIBLE NOT NULL
    DEFAULT default_to_database_primary_region(gateway_region())

Значение по умолчанию — регион узла-шлюза, через который пришёл запрос. Не регион пользователя, не значение из приложения, а точка входа в кластер.

Проверяем: пишем через три разных шлюза по двадцать секунд каждый.

Шлюз Успешных p50, мс p95, мс
ru-central 3384 41.6 87.9
ru-west 4453 31.6 66.6
ru-east 4528 31.2 62.1

И куда легли строки:

crdb_region   rows
ru-central    3391
ru-east       4536
ru-west       4454

Сверяем поштучно: 3384 записи через ru-central против 3391 строки в нём, 4453 через ru-west против 4454, 4528 через ru-east против 4536. Расхождение везде в одну сторону и не больше двух десятых процента.

Точного совпадения тут и не должно быть: клиент считает транзакции, о которых получил ответ, а в базе оказываются и те, чей ответ не доехал. Важно, что расхождение микроскопическое и одностороннее — привязка строки к региону шлюза работает так, как заявлено.

Практический вывод важнее самих чисел. Раз регион берётся из точки входа, то балансировщик перед кластером определяет размещение данных. Если он раскидывает соединения по всем узлам без учёта географии клиента, REGIONAL BY ROW разложит данные случайным образом — формально по регионам, фактически бессмысленно. Либо клиенты идут в свой региональный шлюз, либо регион надо задавать явно при вставке.

Разброс задержек между шлюзами (41.6 против 31.2 мс) на этом стенде географии не отражает: расстояния нет. Это шум одной машины, и трактовать его как «через ru-central писать дороже» нельзя.

Голосующие реплики — вот что меняется

Ходовое представление: «пережить зону» — три реплики, «пережить регион» — пять. Оно неверно дважды, и обе ошибки живут в слове «реплики».

Реплик в обоих случаях пять. Но в кворуме участвуют не все: у диапазона есть голосующие (voting) и неголосующие (non-voting) реплики. Вторые получают данные и умеют обслуживать follower reads, но в голосовании Raft не участвуют и на кворум не влияют. Считать надо именно голосующих — и вот тут разница есть.

Каждая партиция многорегиональной таблицы получает собственную конфигурацию зоны. При цели «пережить зону» она выглядит так:

num_replicas = 5,
num_voters = 3,
constraints       = '{+region=ru-central: 1, +region=ru-east: 1, +region=ru-west: 1}',
voter_constraints = '[+region=ru-east]',
lease_preferences = '[[+region=ru-east]]'

Читается так: пять реплик всего, из них три голосующих, и все три обязаны быть в родном регионе строки; ещё по одной реплике — в каждом из остальных регионов, они неголосующие; аренда тоже предпочитается родному региону.

При переключении на региональную выживаемость меняются ровно две строки:

num_replicas = 5,
num_voters = 5,
constraints       = '{+region=ru-central: 2, +region=ru-east: 2, +region=ru-west: 1}',
voter_constraints = '{+region=ru-east: 2}',
lease_preferences = '[[+region=ru-east]]'

Голосующих становится пять вместо трёх, требование «все в родном регионе» сменяется на «не менее двух в родном» — и, что важнее всего, меняется строка constraints: с «по одной реплике в каждом регионе» на 2 + 2 + 1. Это та самая раскладка, которую документация называет целевой для пяти реплик по трём регионам.

Здесь стоит задержаться, потому что на этой строке я и ошибся. voter_constraints касается только голосующих, а constraints — всех реплик, и читать надо обе. Ограничения при этом задают минимум, а не максимум: {+region=ru-east: 2} — это «хотя бы два». Но минимумы, расставленные по всем трём регионам сразу, в сумме дают жёсткую раскладку: 2 + 2 + 1 = 5, и свободы уже не остаётся.

Арифметика кворума

Отсюда и вырастает компромисс, ради которого цель выживания вообще выбирают.

Пережить зону. Кворум — 2 голоса из 3, и все три голосующих сидят в одном регионе. Значит, запись подтверждается не покидая его: платим только внутрирегиональными задержками. Потеря зоны убирает один голос из трёх — кворум цел. Но потеря всего региона убирает сразу все три — диапазон недоступен, потому что голосовать больше некому.

Пережить регион. Кворум — 3 голоса из 5, и голосующие раскиданы по всем трём регионам. Ни в одном нет трёх, значит собрать большинство внутри одного региона невозможно: каждая запись платит межрегиональным round trip. Зато потеря любого региона убирает максимум два голоса из пяти — остаётся три, кворум жив.

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

Что из этого удалось увидеть на стенде, а что нет

Здесь придётся исправить самого себя. В первой редакции этой статьи стояло: «при цели „пережить регион“ кластер сошёлся к заявленному, конфигурация и реальность совпали». Это было верно для того прогона — и неверно как утверждение. Прогонов стало семь, и они не согласуются между собой.

Прогон Диапазонов с пятью голосующими Что с раскладкой
1 7 из 7 2/2/1 или 2/1/2 — цель достигнута
2 2 из 7 у сошедшегося 2/2/1
3 0 из 7 —
4 7 из 7 у диапазона 83 3/2/0 — конфигурация нарушена
5 0 из 7 (максимум 4) —
6 1 из 7 2/1/2, но дома один голосующий вместо двух
7 1 из 7 у сошедшегося 2/2/1 — ровно по конфигурации

Одна и та же объявленная конфигурация, один и тот же стенд, семь прогонов — и три разных исхода. Это и есть главный результат раздела, более сильный, чем любое отдельное число.

При цели «пережить зону» не сошлось ни разу целиком. Конфигурация требует трёх голосующих в родном регионе партиции; в седьмом прогоне их получил только диапазон основного региона, у двух других осталось по два.

83  ru-central  3  3  внутри   данные
84  ru-east     2  2  внутри   данные
86  ru-west     2  2  внутри   данные
85  ru-east     0  3  граница  пусто
87  ru-west     0  3  граница  пусто

в утверждение вошло диапазонов: 3; вне партиций (не проверялись): 2

Ловушка, в которую я попал сам

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

range_id  voting_replicas  replica_localities
83        {1,2,5,7,3}      ru-central/a, ru-central/b, ru-west/b,
                           ru-central/c, ru-west/a

Три голоса из пяти в ru-central, два в ru-west, в ru-east — ни одного. Потеря ru-central уносит большинство, и кворум не собирается.

Дальше в черновике этого раздела стояло рассуждение, которое пришлось снять целиком. Я написал, что конфигурация при такой раскладке «формально не нарушена»: voter_constraints = '{+region=ru-central: 2}' требует «не менее двух», и трое ему удовлетворяют. Вывод получался эффектный — буква соблюдена, цель не достигнута.

Он неверен, и причина обидная. Я смотрел только на voter_constraints, потому что сам же написал сценарий, выбиравший из вывода SHOW ZONE CONFIGURATION три строки: num_replicas, num_voters, voter_constraints. Строка constraints не попадала в лог ни разу за все прогоны. А в ней — {+region=ru-central: 2, +region=ru-east: 2, +region=ru-west: 1}, и распространяется она на все реплики. Ноль реплик в ru-east нарушает её прямо.

То есть конфигурация нарушена, и никакой коллизии «буква против цели» здесь нет. Сценарий теперь печатает конфиг целиком — и таблицы, и каждой партиции.

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

А как выглядит сошедшийся диапазон, когда он действительно сошёлся, видно в седьмом прогоне:

voting_replicas  {1,5,6,8,3}
localities       ru-central/a, ru-central/c, ru-west/c, ru-west/a, ru-east/b

Два в ru-central, два в ru-west, один в ru-east. Ровно constraints = '{+region=ru-central: 2, +region=ru-east: 1, +region=ru-west: 2}' из конфигурации этой партиции, и ровно два голосующих дома, как требует voter_constraints. Механика работает — вопрос лишь в том, доходит ли до неё аллокатор.

Что именно проверяется, а что нет

Утверждение о раскладке имеет смысл только вместе с ответом на вопрос «по скольким диапазонам оно сделано». Первая версия проверки отбирала диапазоны по range_size > 0 — то есть по наличию строк. Это неверный признак: конфигурация партиции покрывает диапазон ключей, а не строки, и пустой диапазон внутри партиции обязан удовлетворять ей ровно так же.

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

83  ru-central  дома 3  максимум 3  внутри   пусто
88  ru-central  дома 3  максимум 3  внутри   данные
84  ru-east     дома 2  максимум 2  внутри   данные
86  ru-west     дома 2  максимум 2  внутри   данные
85  ru-east     дома 0  максимум 3  граница  пусто
87  ru-west     дома 0  максимум 3  граница  пусто

в утверждение вошло диапазонов: 4; вне партиций (не проверялись): 3

Строка про непроверенные диапазоны здесь не формальность. Без неё «максимум 3, минимум 2» читалось бы как утверждение обо всей таблице — а оно про четыре диапазона из семи.

Почему не сошлось: причина найдена

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

queue_replicate_addreplica_error{store="1"}   190
queue_replicate_process_failure{store="1"}    194
ranges_underreplicated{store="1"}              26
queue_replicate_addreplica_error{store="6"}     7
ranges_underreplicated{store="6"}               2

Аллокатор не бездействовал — он пытался и не смог, сотни раз. А в файловых логах узла лежит и текст отказа:

queue processing resulted in non-benign error: 8 matching stores are currently
throttled: [s2: suspect, s4: suspect, s5: suspect, s9: suspect, s6: suspect,
            s3: suspect, s7: suspect, s8: suspect]

Число подозрительных узлов от прогона к прогону менялось — восемь или девять из девяти, — но сам отказ повторился во всех семи прогонах без исключения.

Почти все узлы помечены suspect. Так CockroachDB помечает узел, чьи сигналы живости приходят с опозданием. Аллокатор считает такие узлы ненадёжной целью и отказывается размещать на них реплики — а поскольку подозрительны все девять, размещать оказывается некуда вовсе.

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

Что можно сказать твёрдо: обе прежние догадки — «маленькая таблица» и «аллокатору нечего оптимизировать» — неверны. Аллокатор работал и получал отказы.

Что из этого следует

Практический вывод важнее самого расхождения. Конфигурация зоны — это намерение, а не факт. Она говорит, чего система добивается, но не гарантирует, что уже добилась, и между этими состояниями может лежать неисправность инфраструктуры.

Диагностическая цепочка при этом простая и работает в любом кластере: несходящаяся раскладка → счётчики queue_replicate_* и ranges_underreplicated → текст отказа в логах узла. Три шага, и вместо догадок появляется непосредственная причина. Дальше, если нужна корневая, копают уже в неё — в нашем случае это были бы метрики живости узлов и ресурсы хоста.

Прежде чем полагаться на локальность записи, посмотрите на фактическую раскладку голосующих:

SELECT range_id, voting_replicas, non_voting_replicas, replica_localities
  FROM [SHOW RANGES FROM TABLE users WITH DETAILS];

И держите на дашборде ranges_underreplicated вместе с queue_replicate_addreplica_error: первый показывает, что размещение не достигнуто, второй — что оно не достигается из-за ошибок, а не просто медленно едет.

Про задержки записи здесь

На стенде запись при цели «зона» дала 4676 транзакций с p50 19.5 мс, при цели «регион» — 11 123 с p50 13.5 мс. Разница в «неправильную» сторону, и это ожидаемо: расстояния между узлами нет, платить за пересечение границы нечем. Числа приведены только чтобы показать, что цена географии здесь не измеряется, и использовать их как её оценку нельзя.

Follower reads в многорегиональной топологии

Чтение с ближайшей реплики вместо арендатора — главный инструмент локальности чтения. Он отдаёт слегка устаревшие данные: отставание составляет ровно 4.2 секунды и выводится из трёх настроек.

Замер через шлюз в ru-east, по пять повторов:

обычное:  13, 21, 12, 15, 13
follower: 27, 12, 15, 14, 10

Разницы нет — и снова по той же причине: follower read существует ради близости реплики, а на одной машине все реплики одинаково близки. В статье про время тот же замер повторён с проверкой по счётчику: обслуживание репликой подтверждается, а вот порядок по latency от прогона к прогону меняется — то есть на одном хосте эти режимы по скорости неразличимы.

Вывод, который можно унести без замера, складывается из двух независимых вопросов. Первый — о данных: допускает ли приложение, что прочитанный снимок старше на 4,2 секунды. Эти секунды — возраст данных, а не ожидание: запрос не ждёт их, он просто читает более раннюю версию. Второй — о задержке: ближе ли к клиенту реплика, чем арендатор. Если на первый вопрос ответ «да», а ближайшая реплика стоит в том же регионе, что и клиент, а арендатор — в другом, follower read сокращает задержку на межрегиональный путь туда и обратно. Между континентами это обычно заметный выигрыш, между соседними городами — считать.

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

Матрица компромиссов

Три свойства, из которых обычно приходится выбирать два.

Локальность чтения. Достигается размещением реплик рядом с читателем и follower reads. Стоит устареванием данных на закрытую метку.

Локальность записи. Достигается тем, что кворум собирается внутри одного региона. Требует концентрации реплик — то есть цели «пережить зону», а не «пережить регион».

Выживаемость региона. Требует, чтобы кворум переживал потерю целого региона, — то есть запрещает концентрацию. Автоматически означает, что запись платит межрегиональной задержкой.

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

Именно поэтому «включить мультирегион» — не операция, а решение. Спрашивать себя надо не «хотим ли мы отказоустойчивости» (хотим всегда), а «готовы ли мы платить межрегиональной задержкой на каждой записи».

Куда дальше

Мы разобрали, как данные размещаются и что определяет выживаемость. Следующая статья — про эксплуатацию: что реально переносится с PostgreSQL, во что превращается SERIAL, сколько занимает обновление кластера и что происходит с лицензией.

Демо и версии

Стенд: digital-cookbook/databases/cockroachdb, сценарий scripts/05-geo.sh. Профиль поднимает девять узлов с метками локальности, создаёт многорегиональную базу, пишет через три разных шлюза, переключает цель выживания и показывает раскладку реплик до и после. Базовый кластер на время прогона останавливается. Прогон — около пятнадцати минут.

Скрипт сам проверяет и печатает фактический RTT между «регионами» перед замерами — чтобы никто не принял его вывод за измерение географии.

Версии: CockroachDB v26.2.5, девять узлов в docker на одной машине под Windows 11 + WSL2.

Документация

Обсуждение в Telegram

Присоединиться →