Распределённые системы
Кворумы: инвариант R + W > N и как он тихо ломается
Платёжная команда крутит Cassandra на RF=3 и, гоняясь за latency записи, ставит запись на consistency ONE (W=1) и чтение на ONE (R=1). Месяцами быстро и нормально. Потом одна нода икает во время деплоя. Пользователь меняет реквизиты для выплат; единственный ack пришёл от ноды, которая умерла секундами позже, не успев реплицировать. Следующее чтение попадает на другую реплику и отдаёт старый номер счёта. Запись была подтверждена, durable ни для кого и тихо исчезла. R=1 + W=1 даёт R + W = 2, а 2 не больше 3 — гарантии, что чтение и запись коснулись одной ноды, никогда и не было.
Инвариант пересечения — это вся суть
Реплицируешь один ключ на N нод. Запись успешна, когда её подтвердили W из них; чтение успешно, когда ответили R из них и ты берёшь самую свежую версию. Единственный факт, который делает это полезным: если R + W > N, множество нод, подтвердивших запись, и множество, ответивших на чтение, обязаны иметь хотя бы одну общую ноду по принципу Дирихле — и эта общая нода несёт последнее значение. В этом вся гарантия. Никакой магии, никакого consensus-лога, никакого лидера: просто два подмножества из N, вынужденных пересечься.
Переверни — и провал так же механистичен. Если R + W <= N, два множества могут не пересечься. Чтению тогда разрешено выбрать R нод, которые все пропустили последнюю запись, и оно вернёт устаревшие данные, считая, что преуспело. Ничего не залогировало ошибку, потому что по правилам, которые ты настроил, всё верно — ты просто настройкой убрал гарантию.
Каноничная настройка строгой консистентности при N=3 — это R=2, W=2: 2 + 2 = 4 > 3, поэтому каждое чтение пересекается с каждой записью на одной ноде. Это ровно то, что даёт QUORUM / LOCAL_QUORUM в Cassandra (floor(N/2) + 1 = 2 при RF=3), и то, что DynamoDB называет strongly consistent read.
| Конфиг (N=3) | R + W | Пересечение? | Что реально получаешь |
|---|---|---|---|
W=2, R=2 (QUORUM) | 4 > 3 | Да | Чтение видит последнюю запись; переживает 1 упавшую ноду |
W=3, R=1 (write-ALL) | 4 > 3 | Да | Быстрые чтения, но ЛЮБАЯ упавшая нода блокирует записи |
W=1, R=3 (read-ALL) | 4 > 3 | Да | Быстрые записи, но ЛЮБАЯ упавшая нода блокирует чтения |
W=1, R=1 (ONE/ONE) | 2 <= 3 | Нет | Быстрее и доступнее всего; устаревшие чтения легальны |
Настраиваемая консистентность: ты выбираешь угол треугольника
R и W — это ручки, часто на каждый запрос, а не глобальный режим. В этом и смысл «настраиваемой консистентности» — один и тот же кластер N=3 может в одном процессе обслуживать запись метрик W=1 по принципу fire-and-forget и strongly-consistent чтение баланса W=2,R=2. Ты выбираешь, для каждой операции, где приземлиться на треугольнике консистентность/доступность/latency.
Две крайности показывают, почему их никто не гоняет в проде. W=N (запись во ВСЕ) даёт сильнейшую запись, но убивает доступность записи: при W=3 на RF=3 одна упавшая нода — рядовой деплой, GC-пауза, медленный диск — фейлит каждую запись, потому что подтвердить должны все три. Симметрично R=N блокирует все чтения на одной медленной ноде. Кворум (W=2,R=2) — золотая середина именно потому, что терпит одну упавшую ноду на обоих путях, всё ещё гарантируя пересечение. Эта терпимость к падению одной ноды и есть причина, почему «кворум» стал дефолтной ментальной моделью, а не ALL.
Почему это работает
Пересечение гарантирует, что ты читаешь последнюю запись, но не что чтения монотонны или что конкурентные записи упорядочены. Два клиента, пишущие один ключ при W=2, могут оба преуспеть против разных большинств в одно логическое время; база хранит обе как конфликт (sibling-версии / last-write-wins по таймстампу). Кворум — про видимость завершённой записи, а не про сериализацию гоночных записей — это уже задача LWT/Paxos или consensus-хранилища.
Sloppy-кворум + hinted handoff: размен инварианта на аптайм
У строгого кворума жёсткий край: если меньше W из назначенных реплик (preference list) достижимо, запись фейлится. При сетевом разделении или падении нескольких нод это означает простой. Ответ Dynamo, унаследованный Cassandra и DynamoDB, — sloppy-кворум: когда домашняя реплика недостижима, запись идёт на следующую здоровую ноду в кольце, которая хранит её как hint — посылку с пометкой «это на самом деле принадлежит ноде X». Когда X возвращается, держатель проигрывает hint ей (hinted handoff) и удаляет свою копию.
Это держит записи текущими сквозь сбои, и ради этой доступности всё и затевалось. Но читай мелкий шрифт: запись приняли ноды, которых нет в обычном множестве чтения. Читатель, делающий строгий R по домашним репликам, может полностью пропустить hinted-запись — поэтому во время окна сбоя sloppy-кворум не удовлетворяет R + W > N относительно каноничного N. Гарантия пересечения приостановлена ровно тогда, когда она нужнее всего. Хуже того, если нода, держащая hint, умирает до завершения handoff, а у тебя была низкая durability, эта запись просто потеряна, восстановима лишь медленным anti-entropy repair, если вообще.
Боевые failure modes и repair, который их прячет
Три способа, которыми это кусает в проде, все коварные, потому что ничто не падает с ошибкой:
- Потеря записи при W=1. Единственная подтвердившая нода падает до репликации. Запись была «успешной» и теперь на нуле живых реплик. Это Hook.
- Устаревшее чтение sloppy-кворума. Во время раздела запись осела на держателях hint; строгое чтение по домашним репликам отдаёт старое значение. Чтения выглядят здоровыми по обе стороны раздела.
- Drift при
R + W <= N.R=1,W=2(или любой sub-overlap микс) позволяет чтению выбрать ту одну реплику, что отстала. Прерывистые, невоспроизводимые тикеты про «призрачные устаревшие данные».
Два anti-entropy механизма замазывают щели, чтобы система была eventually consistent. Read repair: когда кворумное чтение замечает расхождение реплик, координатор inline проталкивает свежую версию отставшим — дёшево, но чинит только ключи, которые ты реально прочитал. Anti-entropy repair (nodetool repair): реплики строят деревья Меркла, диффят хеш-деревья и стримят только несовпавшие диапазоны, чтобы согласовать данные, которых никто не читал. Пропусти плановый repair в окне GC тумбстоунов (gc_grace_seconds, дефолт 10 дней) — и удалённые данные могут воскреснуть, отдельный мерзкий класс багов.
Latency: кворумное чтение настолько быстрое, насколько его самая медленная нужная реплика
R — это не только ручка корректности, это ручка хвостовой latency. QUORUM-чтение при RF=3 ждёт 2 из 3 реплик, поэтому его latency — это вторая по скорости из трёх — значит одна медленная реплика (GC-пауза, горячая партиция, throttled-диск) тянет p99 чтения вниз, даже если кластер в целом в порядке. Поднять R до ALL — ждать самую медленную из всех реплик, умножая хвостовой риск. Поэтому strongly-consistent чтения DynamoDB стоят ~2x пропускной способности eventually-consistent и работают измеримо медленнее, и поэтому системы используют request hedging — пускают дубликат запроса после короткой задержки и берут тот, что вернётся первым — чтобы срезать p99, который иначе создаёт кворумное «жди самую медленную нужную реплику».
Кластер N=3. Чтение баланса/реестра не должно показывать устаревшее значение, но и записи должны переживать падение одной ноды на деплой. Выбери R и W.
При N=3 какая пара (R, W) гарантирует, что чтение всегда видит самую свежую успешную запись?
Почему sloppy-кворум может отдать устаревшее чтение, даже если ты настроил W=2, R=2?
Расставь, что происходит, когда строгая кворумная запись не может достичь домашней реплики и откатывается на sloppy-кворум + hinted handoff:
- 1 Назначенная реплика для ключа недостижима (нода упала или в разделе)
- 2 Координатор пишет на следующую здоровую ноду в кольце, чтобы всё же набрать W ack-ов
- 3 Эта нода хранит значение как hint с пометкой «принадлежит ноде X»
- 4 Нода X восстанавливается; держатель hint проигрывает запись ей (hinted handoff)
- 5 Если чтение гонялось с восстановлением, read repair / anti-entropy согласует отставших
- 01Коллега хочет поставить запись на ONE (W=1) при RF=3 «просто ради выигрыша по latency». Объясни точно, какую гарантию он отдаёт и какой конкретный провал за этим следует.
- 02Sloppy-кворум держит записи доступными во время раздела — так почему это не бесплатная победа, и что прячет проблему в обычной работе?
Кворум превращает репликацию в настраиваемое обещание, построенное на одном куске арифметики: если счётчики реплик чтения и записи удовлетворяют R + W > N, два множества вынуждены пересечься, и чтение гарантированно видит последнюю закоммиченную запись. При N=3 рабочая лошадка — W=2, R=2 (QUORUM / strongly-consistent чтение), которая держит пересечение, терпя одну упавшую ноду на обоих путях — причина, почему кворум бьёт ALL, ведь W=N или R=N отдают всю твою доступность самой невезучей ноде. Настраиваемая консистентность значит, что ты задаёшь R и W на операцию, скользя по треугольнику консистентность/доступность/latency, помня, что кворумное чтение ждёт самую медленную нужную реплику, поэтому R — это ещё и ручка p99 (отсюда request hedging и более дорогие strongly-consistent чтения DynamoDB). Sloppy-кворум с hinted handoff держит записи текущими во время сбоев, паркуя их на нодах-заместителях, но это приостанавливает гарантию пересечения именно во время раздела — шов, где прод тихо отдаёт устаревшие чтения или, при W=1, теряет подтверждённую запись начисто. Read repair и anti-entropy по деревьям Меркла сводят данные потом, но урок сеньора — выбирать R и W так, чтобы держать R + W > N для всего, что не должно быть устаревшим, и точно знать, какую гарантию ты отдаёшь в момент, когда настраиваешь ниже.