open atlas
↑ К треку
Разборы System Design SDC · 01 · 01

Спроектируйте распределённое key-value хранилище

Dynamo-подобное KV-хранилище: кольцо хеширования для партиционирования, нестрогие кворумы (R+W>N) для настраиваемой согласованности, векторные часы против LWW для конфликтов, gossip для членства. Доступность куплена согласованностью.

SDC Senior ◷ 32 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Сервис корзины покупок жил на одном реляционном primary с горячим резервом. Трафик в Чёрную пятницу удваивался час за часом, и на пике переключение на резерв заняло девяносто секунд — в течение которых каждое «добавить в корзину» возвращало ошибку, и люди просто уходили. Разбор был жёстким: бизнесу лучше показать слегка устаревшую корзину, чем никакой, а дизайн с единственным мастером записи такого пообещать не может. Перестройка выбросила реляционную модель целиком. Нужно было хранилище без мастера, где любой узел принимает запись, где потеря машины не видна снаружи, и где жертвуется лишь тем, что две реплики могут на миг разойтись. Это форма Dynamo, и она собрана из трёх идей — кольцо, кворум и способ свести конфликты — уложенных друг на друга.

Требования

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

Функциональные

  • get(key) возвращает значение (или значения, если реплики расходятся) для ключа.
  • put(key, value, context) пишет значение; context несёт метаданные версии, полученные клиентом при предыдущем чтении.
  • Значения — непрозрачные блобы до небольшого предела (Dynamo брал < 1 MB); хранилище их никогда не интерпретирует.
  • Никаких запросов, джойнов, вторичных индексов, транзакций между ключами. Один ключ за раз.

Нефункциональные — это и есть дизайн, а не украшение:

  • Всегда доступно для записи. put обязан проходить даже при сбоях и сетевых разделениях. Это требование диктует всё остальное.
  • Инкрементальный масштаб. Добавлять по одному узлу; кластер ребалансируется без окна обслуживания.
  • Симметрия, децентрализация. Ни мастера, ни особого узла, потеря которого останавливает систему. Все узлы крутят один код.
  • Настраиваемость. Разные вызывающие хотят разные точки на кривой задержка/надёжность/согласованность из одного хранилища.

Решающий компромисс — доступность над согласованностью (A и C в CAP). Поскольку хранилище обязано принимать записи во время разделения, оно не может одновременно гарантировать, что каждый читатель видит последнюю запись — поэтому принимает итоговую согласованность и переносит разрешение конфликтов на момент чтения.

Оценка

Числа определяют форму. Пусть это хранилище сессий/корзин:

  • 100M активных пользователей в день, каждый трогает корзину ~20 раз/день → 2 × 10^9 операций/день. Делим на ~10^5 с/день → ~20 000 операций/с в среднем, при пике 5× — ~100 000 операций/с.
  • Микс read-heavy, но записи реальны: пусть отношение чтений к записям 4:1 → ~80K чтений/с, ~20K записей/с на пике.
  • Размер значения ~5 КБ в среднем. Горячий рабочий набор: 100M активных корзин × 5 КБ = ~500 ГБ живых данных — влезает в суммарную RAM скромного кластера, так что большинство чтений идут из памяти.
  • При N = 3 сырые байты утраиваются до ~1,5 ТБ до накладных; на диске мелочь, смысл числа в том, что ни один узел не держит всё.
  • При 100K операций/с один узел на ~10K операций/с — это примерно 10–20 узлов на пике с запасом — мало, чтобы рассуждать, и много, чтобы узел падал каждую неделю.

Последнее число и есть драйвер: при таком числе узлов падение узла — это вторник, а не инцидент. Дизайн обязан считать отказ узла нормой.

Высокоуровневый дизайн

Клиент (или умная клиентская библиотека) хеширует ключ на кольцо consistent hashing, попадает на узел-координатор, который реплицирует на следующие N − 1 узлов по часовой стрелке и применяет кворум. Отдельного слоя маршрутизации нет — любой узел может координировать любой запрос, а членство узнаётся через gossip.

Три строительных блока, по порядку:

  1. Партиционирование — consistent hashing раскладывает ключи по узлам и дёшево ребалансирует, когда узел входит или выходит.
  2. Репликация + кворум — каждый ключ живёт на N узлах; R и W настраивают, скольким нужно ответить.
  3. Версионирование — поскольку записи во время разделения могут попасть на разные реплики, хранилище держит несколько версий и сводит их.

Глубокое погружение

Партиционирование кольцом хеширования

Каждый ключ хешируется на кольцо; его N реплик — следующие N различных физических узлов по часовой стрелке — это preference list ключа. Виртуальные узлы (каждый физический размещён в ~150 точках кольца) держат дуги сбалансированными и раскидывают нагрузку мёртвого узла по многим преемникам, а не давят одного соседа. Выигрыш операционный: добавление узла двигает лишь ~1/N ключей, так что мощность растёт по одной машине без глобальной перетряски. (Пререквизит про consistent hashing разбирает механику кольца; здесь это подложка, на которой стоит всё остальное.)

Репликация и ручка кворума

Каждая запись идёт на все N реплик, но координатор возвращает успех после подтверждения лишь W из них; каждое чтение опрашивает N и возвращает после ответа R. Единственное неравенство, управляющее корректностью:

R + W > N   →   множество чтения и множество записи обязаны пересекаться

Примеры для N = 3:
  W=3, R=1  → надёжно, быстрые чтения, медленная/хрупкая запись
  W=1, R=3  → быстрая запись, медленные чтения, запись жива, если жив любой узел
  W=2, R=2  → баланс; обычный дефолт

Когда R + W > N, любой кворум чтения и любой кворум записи делят хотя бы один узел, так что чтение гарантированно увидит хотя бы одну копию последней подтверждённой записи. Опусти сумму ниже N + 1 — и обменяешь эту гарантию на задержку, иногда верный выбор для кеш-подобной нагрузки.

Почему это работает

Почему R + W > N гарантирует, что чтение увидит запись? Чистый подсчёт. Запись трогает W из N узлов; чтение трогает R из них. Два множества, выбранных из N элементов, обязаны пересекаться, когда их размеры в сумме больше N (принцип Дирихле) — нельзя выбрать R узлов, которые все избегут W, держащих новое значение. Значит множество чтения всегда содержит хотя бы один узел с самой свежей подтверждённой записью; версионирование затем подсказывает читателю, какое из вернувшихся значений новее. Опусти сумму до R + W = N — и множества могут не пересекаться, так что чтение может промахнуться мимо только что записанного значения — сюрприз «я записал, но не могу прочитать обратно».

Загвоздка — слово подтверждённой. Во время разделения координатор может не достучаться до «настоящих» N узлов, поэтому пишет на первые N здоровых узлов, до которых может достучаться — нестрогий кворум (sloppy quorum) — и помечает смещённые записи подсказкой (hint), чтобы вернуть их законному узлу при его возвращении (hinted handoff). Так хранилище остаётся доступным для записи сквозь сбои: оно не блокируется в ожидании конкретного узла, а просто пишет куда-то надёжно и сводит позже.

Версионирование: векторные часы против last-write-wins

Нестрогие кворумы и конкурентные записи означают, что две реплики могут законно держать разные значения для одного ключа без глобальных часов для их упорядочивания. Хранилищу нужно знать, происходит ли одна версия от другой (оставить новее) или они действительно конкурентны (конфликт). Две стратегии:

  • Last-write-wins (LWW): прицепить временную метку; побеждает наибольшая. Предельно просто, но молча выбрасывает проигравшего — а при расхождении часов между узлами «наибольшая метка» может быть неверной записью. Годится для кешей; опасно для корзин.
  • Векторные часы: каждое значение несёт вектор пар (узел, счётчик). Сравнение двух векторов говорит, произошла-до ли одна другой (оставить потомка) или они конкурентны (ни один не доминирует) — тогда оба возвращаются клиенту на слияние. Каноничный пример Dynamo: два конкурентных добавления в корзину дают расходящиеся векторы, а слияние — объединение множеств, так что ни один товар не теряется.

Цена векторных часов в том, что приложение должно уметь сливать — хранилище отдаёт «вот два сиблинга, решай сам». Для корзины слияние очевидно (объединить товары); для счётчика — нет, ровно поэтому существуют специальные CRDT. Выбор дизайна семантический: LWW там, где потеря записи приемлема, векторные часы там, где нет.

Anti-entropy: gossip, read-repair, деревья Меркла

У хранилища нет мастера для отслеживания членства, поэтому узлы крутят gossip-протокол: каждый периодически выбирает случайного соседа и обменивается своим взглядом на то, кто жив, какой узел владеет какой дугой, и подозрениями в отказе. За секунды весь кластер сходится к одной карте — без координатора, без единой точки отказа для самого членства.

Реплики всё равно дрейфуют (подсказка не доставлена, узел упал во время записи), поэтому непрерывно работают два механизма починки. Read-repair: когда R ответов чтения расходятся, координатор проталкивает самую свежую версию назад на устаревшие реплики на пути ответа — починка едет на обычном трафике. Anti-entropy по деревьям Меркла: реплики одного диапазона ключей периодически сравнивают хеш-деревья своих данных; обмениваются только ветви с разными хешами, так что две реплики обнаруживают и лечат расхождение, почти ничего не передавая, когда уже согласны.

Граничные случаи

А как насчёт ключа, который читают постоянно, но пишут редко — и один узел в его preference list пропустил последнюю запись и не чинится через read-repair, потому что чтения случайно попадают на две другие? Read-repair срабатывает, лишь когда расходящаяся реплика реально в составе R, которые опросили, так что тихо-устаревший узел может стоять неверным бесконечно, если трафик обходит его стороной. Ровно эту дыру закрывает anti-entropy по деревьям Меркла: оно работает независимо от трафика чтения, сравнивая целые диапазоны ключей по таймеру, так что даже реплика, которую не трогает ни одно чтение, в итоге сводится. Механизмы взаимодополняют: read-repair дёшев и мгновенен для горячих ключей; anti-entropy — медленная страховка для холодных.

Викторина

KV-хранилище работает на N=3 с W=1, R=1 ради скорости. Клиент пишет значение, тут же читает тот же ключ и получает СТАРОЕ. В чём причина и какая настройка гарантирует, что чтение увидит запись?

Закончи аналогию

Когда две реплики держат конкурентные значения, записанные во время разделения, _______ (вектор счётчиков по узлам) позволяют хранилищу решить, происходит ли одна версия от другой или они реально конфликтуют — возвращая оба сиблинга приложению на слияние вместо молчаливого отбрасывания одного, как сделал бы last-write-wins.

Выбери лучший вариант

Dynamo-подобное KV-хранилище работает с N=3 и нестрогими кворумами. Два клиента добавляют разные товары во время сетевого разделения, создавая две расходящихся реплики. Какую стратегию разрешения конфликтов следует использовать хранилищу?

Узкие места и компромиссы

  • Горячие ключи. Consistent hashing балансирует пространство ключей, а не нагрузку — один ключ-знаменитость (вирусная корзина, глобальная строка конфига) гонит весь трафик на одни и те же N узлов независимо от vnodes. Митигации: клиентское кеширование горячих ключей, разбиение горячего ключа на шарды или bounded-load хеширование для маршрутизации. Кольцо не спасает от одного бело-горячего ключа.
  • Бремя слияния. Векторные часы перекладывают сведение на клиента; ошибёшься (или по удобству поставишь LWW) — молча потеряешь данные. Векторы могут расти неограниченно при многих координирующих узлах, поэтому реальные системы подрезают старые записи — что изредка может потерять причинную информацию.
  • Усиление чтения при низком R. R = 1 быстр, но максимизирует устаревание и работу read-repair; R = N согласован, но превращает одно логическое чтение в N сетевых вызовов, и одна медленная реплика тянет хвост (tail-at-scale). Ручка R/W пооперационна именно потому, что одна настройка не годится для всех вызовов.
  • Итоговая согласованность — контракт, не баг — но он протекает. «Прочитай свою запись» не гарантирована, если не маршрутизировать чтения клиента через тот же координатор или не использовать R + W > N со строгим (не нестрогим) кворумом. Команды, считающие read-after-write данностью, обжигаются; честный дизайн заявляет окно устаревания и даёт приложению выбрать более сильные настройки там, где это важно.
Вспомните перед уходом
  1. 01
    Какой центральный компромисс делает Dynamo-подобное KV-хранилище и какое требование его диктует?
  2. 02
    Сформулируй неравенство кворума и объясни, почему оно работает.
  3. 03
    Когда использовать векторные часы против last-write-wins и какие механизмы лечат дрейф реплик?
Итог

Распределённое key-value хранилище — это форма Dynamo: оно существует, потому что некоторые нагрузки (корзина из хука) предпочтут отдать слегка устаревшие данные, чем отказать в записи во время сбоя, поэтому выбирает доступность над согласованностью и принимает итоговую согласованность как цену. Оценка — десятки тысяч операций/с на ~10–20 узлах — делает отказ узла нормой, что исключает любого мастера. Складываются три идеи: кольцо consistent hashing партиционирует ключи и ребалансирует по узлу за раз (N реплик каждого ключа — его preference list); кворум с R + W > N гарантирует пересечение чтения с последней записью, с R/W как пооперационной ручкой задержка-против-согласованности, ослабляемой до нестрогого кворума + hinted handoff, чтобы записи не блокировались на конкретном узле; и версионирование (векторные часы всплывают конкурентные сиблинги на слияние, или last-write-wins там, где отбросить проигравшего приемлемо) сводит расхождение, которое создают записи без мастера. Gossip отслеживает членство без координатора, а read-repair плюс anti-entropy по деревьям Меркла непрерывно лечат дрейф. Узкие места реальны — один горячий ключ побеждает кольцо, бремя слияния ложится на приложение, низкий R максимизирует устаревание — поэтому честный дизайн заявляет своё окно устаревания и даёт каждому вызову выкрутить нужную ему согласованность. Теперь, когда встретишь задачу «высокая доступность без единой точки отказа», знаешь первый ход: нет мастера, кольцо хеширования, нестрогий кворум — и цену, которую называешь сразу: итоговая согласованность (eventual consistency).

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.