Consistency levels и LWT в ScyllaDB: что реально стоит кворум и Paxos

Разница между ONE/QUORUM/ALL на живых числах, во сколько раз LWT дороже обычной записи и что происходит с CAS-операциями под конкуренцией

ScyllaDB не навязывает одну консистентность на все запросы — каждый запрос сам объявляет, скольких реплик из RF ему достаточно дождаться, и это архитектурное решение называется tunable consistency. Но у настройки consistency level есть слепая зона: разница между «подождать одну реплику» и «подождать все» на бумаге выглядит как выбор между быстро-и-рискованно и медленно-и-надёжно, а на живом кластере эта разница может оказаться куда скромнее интуиции. И есть второй, куда более дорогой инструмент линеаризуемости — LWT на Paxos, — у которого цена не в единицах процентов, а в разах. Ниже — оба измерены на одном и том же живом трёхузловом кластере, и там, где числа не подтверждают интуицию, это зафиксировано честно, а не сглажено.

Это четвёртая статья серии «ScyllaDB: глубокое погружение», продолжение статьи о моделировании данных, статьи об архитектуре shard-per-core и статьи о compaction, tombstones и repair. Дальше в серии — от token ring к tablets и multi-DC, shard-aware драйверы на Go и Java и эксплуатация и итоговый выбор.

Числа ниже — с того же живого трёхузлового кластера scylladb/scylla:2026.2.0 (RF=3, один датацентр), что и в предыдущих статьях. Стенд — в публичном репозитории примеров (digital-cookbook, каталог scylladb/consistency-lwt).

Consistency levels и LWT/Paxos в ScyllaDB: LWT в 3.29 раза дороже обычной записи, доля неуспешных CAS под конкуренцией — структурный артефакт

В статье

Tunable consistency: ONE/LOCAL_QUORUM/QUORUM/ALL на живом кластере

В ScyllaDB и Cassandra репликация решена иначе, чем в реляционных СУБД с одним праймари: каждая партиция реплицируется на RF узлов (в этом кластере RF=3), а сколько из них должны подтвердить операцию — решает не таблица, а сам запрос, через параметр consistency level (CL). ONE — ответила одна реплика, и координатор уже возвращает результат клиенту. QUORUM — больше половины реплик из RF (при RF=3 это 2 из 3). LOCAL_QUORUM — то же самое, но только реплики локального датацентра, не считая удалённые. ALL — все реплики без исключения. Это и есть tunable consistency: один и тот же кластер, одна и та же таблица могут обслуживать запросы с разной ценой в зависимости от того, что объявил клиент.

Два механизма держат реплики согласованными между собой, не дожидаясь явного repair. Read-repair срабатывает прямо во время чтения: если CL требует опросить несколько реплик и их ответы разошлись, координатор замечает расхождение и на лету дописывает более свежую версию отставшей реплике — маленький, постоянный ремонт по ходу обычного трафика, без выделенной фоновой задачи. Hinted handoff решает другую задачу — временную недоступность реплики: если один из узлов, которым адресована запись, не отвечает, координатор сохраняет для него «подсказку» (hint) локально и доставляет её, когда узел возвращается в строй, вместо того чтобы сразу требовать полноценный repair. Оба механизма уже встречались в статье про compaction и repair — там hinted handoff успевал закрыть расхождение раньше, чем скрипт стенда успевал его поймать вручную.

Сценарий -scenario cl-latency прогнал по 20 000 записей и 20 000 чтений на каждый из четырёх CL — свежий ключ на каждую операцию, чтение сразу того же ключа, который только что записан, без гонки с чужими записями — и снял клиентские p50/p99 отдельно на запись и на чтение:

CL write p50 write p99 read p50 read p99 combined p50 combined p99
ONE 1418 1508 1345 1506 1390 1507
LOCAL_QUORUM 1490 1718 1497 1726 1493 1722
QUORUM 1503 2011 1510 2084 1507 2047
ALL 1497 1641 1502 1641 1500 1641

(микросекунды)

1390µsONE1493µsLOCAL_QUORUM1507µsQUORUM1500µsALL

Бары на этой картинке специально нарисованы в масштабе реальных чисел — и они почти не отличаются по высоте. Это не ошибка визуализации, это и есть честный результат: разница между CL на этом кластере — единицы процентов, не порядки. combined p50 растёт от ONE (1390µs) к ALL (1500µs) — около 8%. Причина не в архитектуре ScyllaDB, а в топологии стенда: все три реплики физически сидят на одном Docker-хосте за одним мостом, и разница между «дождаться одну реплику» и «дождаться все три» на соединении, которое по сути является loopback, просто не успевает вырасти до заметной величины. На кластере, разнесённом по разным хостам или датацентрам, где RTT между узлами реален и измеряется единицами-десятками миллисекунд, та же разница между CL была бы куда заметнее.

Отдельно стоит зафиксировать LOCAL_QUORUM и QUORUM, которые дали практически идентичные числа — 1493 против 1507µs combined. Это не шум измерения пополам с совпадением, а прямое следствие топологии: keyspace telemetry живёт в одном датацентре (datacenter1), и кворум из RF=3 реплик — это 2 реплики что для LOCAL_QUORUM (только локальный ДЦ), что для QUORUM (весь кластер) — архитектурно один и тот же порог. Разница в 14µs между ними — чистый шум измерения, не сигнал. Разойтись эти два CL могут только там, где датацентров больше одного — этому посвящена следующая статья серии про multi-DC, где QUORUM действительно платит заметную (хоть тоже топологически скромную) надбавку за ожидание удалённого ДЦ, а EACH_QUORUM и вовсе отказывает при полном отказе одного из ДЦ.

Направление эффекта при этом воспроизводится безукоризненно: ассерт lat[ALL] >= lat[ONE] (combined p50) держится честно — 1.500ms >= 1.390ms, и порядок ONE < LOCAL_QUORUM ≈ QUORUM < ALL монотонен на каждом из четырёх уровней. Верно направление, а не абсолютная величина — если взять этот стенд как основание для прогноза «разница между CL на проде тоже будет единицы процентов», прогноз будет неверным для любого кластера, разнесённого географически.

LWT и Paxos: чем платится линеаризуемость

Всё, что описано выше, — про обычные записи и чтения: координатор рассылает мутацию репликам и ждёт нужное число подтверждений, один сетевой раунд. Lightweight transactions (LWT) — INSERT ... IF NOT EXISTS, UPDATE ... IF column = ? — устроены принципиально иначе: под капотом не один раунд, а Paxos-консенсус в три фазы поверх тех же реплик.

sequenceDiagram participant C as Клиент participant Co as Координатор participant R as Реплики (RF=3) rect rgb(201, 228, 197) Note over C,R: Обычная запись — один round-trip C->>Co: INSERT (без IF) Co->>R: запись на реплики R-->>Co: подтверждения (по CL) Co-->>C: ok, ~1.5ms p50 end rect rgb(244, 217, 198) Note over C,R: LWT — INSERT ... IF NOT EXISTS C->>Co: LWT-запрос Co->>R: Prepare(ballot) R-->>Co: Promise Co->>R: Propose(value, ballot) R-->>Co: Accepted Co->>R: Commit R-->>Co: Ack Co-->>C: applied=true/false, ~4.9ms p50 end

sequenceDiagram
  participant C as Клиент
  participant Co as Координатор
  participant R as Реплики (RF=3)

  rect rgb(201, 228, 197)
  Note over C,R: Обычная запись — один round-trip
  C->>Co: INSERT (без IF)
  Co->>R: запись на реплики
  R-->>Co: подтверждения (по CL)
  Co-->>C: ok, ~1.5ms p50
  end

  rect rgb(244, 217, 198)
  Note over C,R: LWT — INSERT ... IF NOT EXISTS
  C->>Co: LWT-запрос
  Co->>R: Prepare(ballot)
  R-->>Co: Promise
  Co->>R: Propose(value, ballot)
  R-->>Co: Accepted
  Co->>R: Commit
  R-->>Co: Ack
  Co-->>C: applied=true/false, ~4.9ms p50
  end
LWT: Paxos prepare → propose → commit против одного round-trip обычной записи

Сценарий -scenario lwt (n=5000) прогнал по 5000 обычных INSERT и 5000 INSERT ... IF NOT EXISTS на разные, не пересекающиеся ключи (без конкуренции — каждая LWT-операция выигрывает сразу, applied=5000/5000):

plain: p50=1.496ms p99=1.695ms
lwt:   p50=4.924ms p99=6.611ms  applied=5000/5000
ratio lwt_p50/plain_p50 = 3.29x

LWT дороже обычной записи в 3.29 раза по p50 (4.924ms против 1.496ms) — даже без единой конкурирующей попытки, только за счёт prepare-propose-commit вместо одного round-trip. Показательно сравнение с предыдущим разделом: разница между CL на этом же кластере измерялась единицами процентов, а разница между plain-записью и LWT — это уже не проценты, это разы. Накладные расходы согласования консенсуса на порядок заметнее, чем выбор consistency level поверх обычной записи — и это единственная часть данной статьи, где «на порядок» — не оговорка, а прямое сравнение чисел.

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

Contention: цена конкуренции за один ключ

Тот же сценарий -scenario lwt (-contenders 16) запускает 16 горутин, которые синхронно, без backoff и без jitter, читают текущее значение одного и того же ключа contended-key и шлют CAS-запрос UPDATE ... IF val = ? — 312 попыток на горутину, 4992 суммарных попытки на один партиционный ключ:

Contention: 16 горутин x 312 попыток = 4992 суммарных CAS-попыток на ключ "contended-key"
попыток=4992 (applied=312, failed=4680, ошибок-транспорта=0)
contention CAS latency: p50=78.85ms p99=91.24ms
contended_failed_fraction = 0.9375 (93.75%)

Два числа здесь бросаются в глаза сами по себе: латентность одной CAS-попытки под конкуренцией выросла до 78.85ms p50 — почти в 16 раз дороже LWT на свободном ключе (4.924ms из предыдущего раздела) — и 93.75% попыток завершились applied=false. Читать этот второй процент нужно осторожно, и вот почему.

93.75% — это не общая вероятность конфликта LWT, а структурный артефакт конкретного цикла без backoff. Цикл контеншена намеренно наивный: 16 горутин без jitter читают одно и то же значение и почти синхронно шлют CAS практически одним лок-степ-раундом. Paxos на занятом ключе сериализует конкурентов и в каждом таком раунде даёт ровно одного победителя — отсюда прямая арифметика: failed_fraction ≈ (contenders − 1) / contenders. При 16 конкурентах это 15/16 = 0.9375 — ровно наблюдаемое число. Формула проверена не на одном значении: при -contenders 8 получилось 175/200 = 7/8 = 0.8750, при -contenders 475/100 = 3/4 = 0.7500. Доля меняется вместе с числом конкурентов по этой формуле, а не остаётся зафиксированной константой 93.75% — при другом числе горутин или при добавлении backoff/jitter в цикл конкретный процент был бы совсем другим.

Важно не унести из этого раздела неверный вывод. LWT не «отказывает в 93.75% случаев» как свойство самого механизма — это была бы неверная генерализация одного синтетического теста с искусственно убранным backoff. Реальный вывод, который переживает эту оговорку: под высокой конкуренцией на один партиционный ключ подавляющее большинство CAS-попыток тратится впустую — это и есть настоящая цена конкуренции за Paxos, и именно поэтому production-код почти всегда оборачивает CAS в retry-цикл со случайным разбросом по времени между попытками, а не бьёт в занятый ключ лоб в лоб. Точный процент — функция числа конкурентов и тайминга конкретного теста, а не константа, которую можно приписать LWT как таковому.

Серверные CAS-метрики: что на самом деле считает cas_prune

Раз клиентская сторона видит и applied, и failed, логично поискать те же цифры на сервере — вдруг Prometheus-эндпоинт :9180/metrics сам считает успешные и неуспешные CAS раздельно. Полный grep -i cas по метрикам находит семь метрик-семейств; из них годятся под дельту «сколько раз применялось» только два counter’а — scylla_storage_proxy_coordinator_cas_prune и scylla_storage_proxy_coordinator_cas_total_operations (остальные пять — gauge для текущего состояния или гистограммы латентности, не счётчики применений).

Официальный HELP-текст cas_prune звучит однозначно: «how many times paxos prune was done after successful cas operation» — по буквальному чтению это должен быть счётчик только успешных (applied=true) CAS. Живой прогон это чтение опровергает. Дельта cas_prune за весь сценарий -scenario lwt (суммарно по всем узлам и шардам) — 9993, и это число ровно совпадает не с числом успешных операций, а с числом всех завершившихся раундов Paxos: 5000 (LWT INSERT из предыдущего раздела, applied=5000/5000) + 1 (инициализация contended-key) + 4992 (все попытки контеншена, applied=312 и failed=4680 вместе) = 9993.

cas_prune delta = 9993 = 5000 (LWT insert, все applied) + 1 (init) + 4992 (contention: applied+failed)

Вывод: cas_prune инкрементируется на каждый завершённый раунд Paxos — коммит произошёл, неважно, применилось ли в итоге условие IF — а не только на раунды с applied=true. Paxos всегда доводит раунд до коммита, в том числе коммитит «не изменилось» при applied=false, и prune (очистка состояния консенсуса после раунда) происходит в обоих случаях одинаково. HELP-текст, обещающий «после успешного cas», описывает механизм неточно.

Второй counter, cas_total_operations, корроборирует эту находку: его дельта на каждом шарде по отдельности совпадает с дельтой cas_prune один в один — то есть оба инкрементируются в одном и том же месте кода Paxos-координатора, просто у второго HELP-текст («number of total paxos operations executed») буквально точен, а у первого — вводит в заблуждение. Отдельного счётчика «CAS отклонён по условию» на этой сборке нет вообще — ни cas_prune, ни cas_total_operations не различают applied=true от applied=false; единственный источник этого разделения — клиентский ответ (ScanCAS/wasApplied), тот же, что уже использован в разделе про contention выше.

Где проходит граница с транзакциями

LWT в этой серии — не первое появление. Статья про транзакции в KV и документных хранилищах уже показывала LWT на ScyllaDB — тот же INSERT ... IF NOT EXISTS, 16 воркеров бронируют одно место, ровно один получает applied=true. Но угол там был другой: что LWT гарантирует — линеаризуемость в пределах одного раздела против отсутствия взаимного исключения у обычного INSERT (last-write-wins, все 16 «успешны», конфликт никому не виден). Этот стенд идёт глубже — не «что гарантирует», а чем это стоит и как устроено изнутри: реальный трёхузловой RF=3 кластер вместо single-node RF=1, клиентская латентность Paxos против обычной записи разложена по фазам prepare-propose-commit, поведение под конкуренцией за один ключ измерено, а не только продемонстрировано на 16 попытках, и серверные CAS-метрики проверены на то, что они реально считают, а не на то, что обещает HELP-текст.

Обе статьи сходятся в одном практическом выводе: LWT работает только в пределах одного раздела — мульти-раздельных транзакций в ScyllaDB и Cassandra не существует архитектурно, и ни одна из цифр этой статьи не меняет эту границу. То, что LWT линеаризуем внутри раздела, не помогает, если инвариант связывает строки из разных партиций — это по-прежнему задача другого уровня (application-level saga, внешний координатор), не задача, которую решает Paxos ScyllaDB.

Ключи консистентности, разобранные в первой половине статьи, тоже не заканчиваются на одном датацентре. Следующая статья серии переносит ту же арифметику CL на кластер с двумя датацентрами в Scylla-топологии: там LOCAL_QUORUM и QUORUM наконец расходятся не на 14µs шума, а на измеримую величину, EACH_QUORUM показывает, как выглядит честный отказ при недоступности целого ДЦ, а tablets — новая единица распределения данных ScyllaDB — переживают миграцию реплик при выводе узла из кластера.

Версии на стенде: образ scylladb/scylla:2026.2.0, CQL/release_version3.0.8, RF=3, один датацентр (datacenter1), keyspace telemetry не тронут — LWT и CL измерены на отдельных таблицах cl_bench и counters_lwt того же кластера, что и в первой статье серии.

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

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

Комментарии