Консистентное хеширование: сколько ключей переезжает при смене состава
«Хеш по модулю числа узлов» работает ровно до первого изменения состава. Посчитано точно: добавление одного узла к восьми переносит 88,9 % ключей — в восемь раз больше необходимого. Кольцо переносит 11,5 %, а при удалении узла не трогает ни одного чужого ключа. Цена — перекос, который без виртуальных узлов достигает 90 крат.
Полное техническое изложение
TL;DR
Ключи надо разложить по узлам, и самый простой способ — поделить хеш ключа с остатком на число узлов. Раскладывает он ровно и стоит одну операцию. Беда в том, что владелец ключа при этом оказывается свойством не ключа, а текущего числа узлов: меняется делитель — меняется ответ сразу для всех ключей. Консистентное хеширование — это любая схема, где владелец привязан к самому ключу; кольцо — самая известная из таких схем, но не единственная возможная.
Отсюда главное следствие: добавить узел в схему с делением по модулю — значит перевезти почти все данные. Посчитано на 100 000 ключей для перехода с восьми узлов на девять — то есть когда к восьми добавляется один равноправный узел, а ключи раскладываются поровну: переехать обязаны 11,1 % (столько причитается новому узлу), а сменили владельца 88,9 % — в восемь раз больше. Кольцо на том же переходе и тех же ключах перенесло 11,5 %.
Дальше — остальные числа и цена. Удаление узла кольцо переживает ещё чище: посчитано — переехали 11,7 %, ровно доля ушедшего узла, и 0,0 % чужих ключей. Платят за это перекосом: при одной точке на узел самый нагруженный узел оказался тяжелее самого лёгкого в 90,5 раза — 33,3 % ключей против 0,4 %. Перекос лечат виртуальными узлами и платят структурой: 128 точек на узел дают 1,4 крата вместо 90,5, а кольцо вырастает с 8 точек до 1024. Доля переезда от числа точек при этом почти не зависит: 11,5 % при одной точке и 10,7 % при 128.
- когда данных больше, чем помещается на одну машину, их раскладывают по нескольким узлам;
- чтобы прочитать значение по ключу, надо сначала понять, на каком узле оно лежит;
- хеш-функция превращает произвольный ключ в число и на одном и том же ключе всегда даёт одно и то же.
- как устроено кольцо, что такое виртуальный узел, чем SHA-1 отличается от встроенного
hash(); - «перекос» как термин, репликация, устройство конкретных хранилищ.
Что здесь на самом деле спрашивают
Лестница выглядит так:
- «Как разложить ключи по узлам?» — разминка: ответ на неё — деление по модулю.
- «Что произойдёт при добавлении узла?» — здесь начинается содержание, и ответ «часть ключей переедет» уже неверен по величине.
- «Что такое консистентное хеширование?» — вопрос про механизм.
- «Зачем виртуальные узлы?» — вопрос, на котором видно, считал ли собеседник перекос или пересказывает.
- «Чем платят за виртуальные узлы?» — вопрос про размен.
- «Где эта схема не спасает?» — вопрос про границы.
Числа получены прогоном bench/hashring/ring.py и
bench/hashring/practice.py. Это не модель и не симуляция: раскладка ключей по
узлам считается точно, и доля переехавших получается пересчётом. Случайны здесь
только сами ключи, и они порождены фиксированным зерном.
База: как узнают, на каком узле лежит ключ
Когда данных больше, чем помещается на одну машину, их раскладывают по нескольким узлам. И сразу возникает вопрос, который и есть тема урока: чтобы прочитать значение по ключу, надо знать, на каком узле оно лежит.
Хранить это списком — «такой-то ключ живёт там-то» — не выйдет: список получится размером с сами данные, и держать его придётся каждому, кто ходит за ключами. Поэтому владельца ключа не хранят, а вычисляют: берут ключ, считают от него хеш — число, которое у всех и всегда получается одинаковым, — и по этому числу называют узел.
Самый простой способ назвать узел по числу — поделить с остатком. Остаток от деления хеша на число узлов и есть номер узла: восемь узлов — остатки от нуля до семи, у каждого ключа свой узел, никакой таблицы. Считается это одной операцией, и ключи ложатся ровно.
И ровно здесь его беда, которую видно, если посмотреть на способ ещё раз: номер узла зависит не только от ключа, но и от числа узлов. Пока состав неизменен, это незаметно. Но стоит добавить узел — делитель становится другим, а значит, ответ меняется сразу у всех ключей, в том числе у тех, которых новый узел не касается никак. То же самое происходит и при уходе узла.
Отсюда главный вопрос урока: можно ли раскладывать ключи так, чтобы при появлении нового узла переезжали только те ключи, которые ему и достанутся? Ответ — да, и схемы с таким свойством называют консистентным хешированием: владелец ключа в них привязан к самому ключу, а не к текущему числу узлов.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Всё дальнейшее — про то, насколько велика разница на самом деле, как устроена самая известная из таких схем и чем за неё платят.
Механизм 1: модуль переносит почти всё
Теперь то же самое в числах. Вот что происходит с делением по модулю при добавлении узла:
1. MODULO SHARDING: ADDING ONE NODE MOVES ALMOST EVERYTHING
-----------------------------------------------------------
nodes -> nodes keys moved ideal share
8 9 88.9% 11.1%
8 10 80.2% 20.0%
times more than the minimum, +1 node 8.0
Читать надо две колонки рядом, и первую строку — с названными допущениями.
Строка описывает переход с восьми узлов на девять: к восьми узлам
добавляется один такой же, равноправный, а ключи раскладываются поровну. При
этих допущениях ideal share — необходимый минимум: девятому узлу должна
достаться примерно девятая часть ключей, то есть 11,1 % обязаны переехать в
любой схеме, которая раскладывает ключи поровну. А переехало 88,9 % — в
восемь раз больше. Оба числа относятся именно к этому переходу: у другого
состава и другого числа добавляемых узлов будут свои, что и показывает вторая
строка.
Почему так. При делении по модулю узел ключа — не свойство ключа, а свойство текущего числа узлов. Меняется делитель — меняется ответ сразу для всех: ключ с хешем 17 при восьми узлах жил на первом, при девяти живёт на восьмом, и никакого отношения к появлению нового узла это не имеет.
Ради этого следствия раздел и существует в разговоре: добавление узла в такую схему — это перенос почти всех данных. Для кеша это значит, что почти девять ключей из десяти спросят не тот узел, то есть промахнутся; для хранилища — что почти весь набор данных придётся передать по сети. И то и другое случается ровно тогда, когда узел добавляют из-за нехватки ёмкости.
Обратите внимание и на вторую строку: добавление двух узлов перенесло 80,2 % — меньше, чем добавление одного. «Больше добавили — больше переехало»: такого правила здесь нет. Доля определяется арифметикой остатков, а не размером изменения, и заранее её не угадать — только посчитать.
Механизм 2: кольцо переносит близко к минимуму
Идея консистентного хеширования в том, чтобы владелец ключа перестал зависеть от числа узлов. Самая известная конструкция, которая это делает, — кольцо: хеш ключа и хеши узлов кладутся на одну окружность, и ключ идёт к ближайшему узлу по часовой стрелке.
Тогда появление нового узла отбирает только тот участок окружности, который оказался прямо перед ним. Остальные пары «ключ — узел» не меняются вообще: их не с чем сравнивать заново.
2. A RING MOVES ONLY WHAT IT HAS TO
-----------------------------------
vnodes per node keys moved, +1 node ideal share
1 11.5% 11.1%
16 10.9% 11.1%
128 10.7% 11.1%
Те же восемь узлов, тот же девятый, те же ключи. Кольцо, у которого на каждый узел приходится одна точка, перенесло 11,5 % против 88,9 % у деления по модулю — то есть практически необходимый минимум. Нижние строки блока забегают вперёд: там у узла на кольце не одна точка, а несколько — зачем так делают, разбирается дальше, и на долю переезда это почти не влияет.
Разница здесь не в «эффективности алгоритма», а в том, что именно является ответом. У модуля ответ пересчитывается для всех ключей сразу; у кольца владелец ключа — это ближайшая точка справа, и появление новой точки меняет ответ только для тех ключей, что лежат слева от неё.
Отсюда и название: схема консистентна в том смысле, что ответ для конкретного ключа не меняется без причины, касающейся именно этого ключа.
И сразу оговорка, которую стоит держать в голове: кольцо — это способ, а не синоним. Консистентное хеширование — требование к схеме раскладки: владелец ключа не зависит от числа узлов, поэтому изменение состава трогает только ключи рядом с изменением. Окружность с точками — самая известная конструкция, которая этому требованию удовлетворяет, и потому её и рисуют на собеседовании. Но удовлетворяет ему не только она: конструкций с тем же свойством известно несколько, устроены они по-разному, и все числа этого урока относятся именно к кольцу.
Механизм 3: удаление узла не трогает чужие ключи
Добавление узла мы уже видели. Теперь обратное действие — то самое, ради которого схему и придумали. Уберём один узел из восьми:
5. REMOVING A NODE MOVES ITS KEYS AND NOBODY ELSE'S
---------------------------------------------------
share of keys the removed node owned 11.7%
share of keys that moved in total 11.7%
share of OTHER nodes' keys that moved 0.0%
Три строки, и вся суть в третьей. Переехало ровно столько ключей, сколько принадлежало ушедшему узлу, и ни одного чужого.
Это то самое свойство, которого нет у деления по модулю: там уход узла меняет делитель и, значит, ответ для всех. Здесь уход узла стирает его точки с кольца, и ключи, которые на них указывали, переходят к соседям справа. Все остальные ключи по-прежнему указывают на те же точки, и им нет дела до состава.
В этом расчёте у каждого узла на кольце не одна точка, а сто двадцать восемь. На само свойство их число не влияет: важно только то, что точки ушедшего узла исчезают, а чужие остаются на местах. Зачем точек делают много — отдельный разговор.
Отсюда следует и то, ради чего схему держат в проде: когда узел уходит, переспрашивать надо только его долю ключей — остальные семь восьмых состава отвечают на те же вопросы, что и до ухода. Откуда возьмётся его доля — вопрос репликации, и эта тема сюда не входит.
Глубже: перекос и виртуальные узлы
Дальше — цена кольца и настройка, которой её платят. На базовый ответ это уже не влияет: кольцо переносит близко к минимуму и не трогает чужие ключи независимо от того, что написано ниже. Но именно здесь пересказ обычно заканчивается, а разговор на собеседовании — продолжается.
Разложим ключи по восьми узлам, поставив на кольцо по одной точке на узел:
3. WITHOUT VIRTUAL NODES THE RING IS BADLY SKEWED
-------------------------------------------------
vnodes per node smallest share largest share max/min
1 0.4% 33.3% 90.5x
16 8.7% 16.3% 1.9x
128 11.0% 15.1% 1.4x
512 12.1% 13.6% 1.1x
Один узел получил треть всех ключей, другой — четыре десятых процента. Разница в 90,5 раза, при том что узлов восемь и «поровну» означало бы по одной восьмой на каждого.
Почему так. Восемь точек, положенных хешем имён, делят окружность на восемь дуг: хеш разбрасывает их так, как разбросал бы случайный выбор, и такое разбиение неравномерно. Здесь эти восемь дуг легли так, что на одну пришлась треть окружности, а на другую почти ничего. Свойство это не конкретных имён, а любого разбиения окружности несколькими независимыми точками; расчёт показывает один его случай.
Виртуальные узлы — это лечение: вместо одной точки узел получает много, и его доля складывается из множества маленьких дуг. Много маленьких случайностей усредняются, восемь больших — нет. При 128 точках перекос падает до 1,4 крата.
Именно этот пункт отличает читавшего от считавшего. Кольцо без виртуальных узлов решает задачу переезда и тут же создаёт задачу неравномерности — причём такую, что один узел может получить в девяносто раз больше другого.
Чем платят за виртуальные узлы
4. WHAT VIRTUAL NODES COST
--------------------------
vnodes per node points on the ring max/min
1 8 90.5x
16 128 1.9x
128 1024 1.4x
512 4096 1.1x
Точки на кольце — не абстракция: это структура, по которой ищут владельца каждого ключа и которую обязан держать каждый участник, знающий раскладку. Восемь узлов при 512 точках — это кольцо из 4096 записей.
Смотреть надо на форму размена. Переход от 1 к 16 точкам убирает почти весь перекос: 90,5 крата превращаются в 1,9. Переход от 128 к 512 учетверяет структуру ради последних процентов равномерности: 1,4 против 1,1.
Отсюда и ответ на вопрос «сколько ставить виртуальных узлов»: столько, чтобы перекос стал приемлемым, и ни одной точкой больше. Число это зависит от того, сколько у вас узлов и какой перекос вы готовы терпеть, — и его считают, а не берут из статьи.
Как отвечать на собеседовании
Короткий ответ: деление по модулю привязывает владельца ключа к числу узлов, поэтому любое изменение состава пересчитывает всё; кольцо привязывает владельца к позиции ключа, поэтому меняются только ключи рядом с изменением. Посчитано на 100 000 ключей: добавление девятого узла к восьми переносит 88,9 % ключей при модуле и 11,5 % на кольце с одной точкой на узел.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Хороший ответ отличают три вещи. Первая — вы объясняете причину, а не название: у модуля узел ключа зависит от числа узлов, у кольца — от позиции ключа. Вторая — вы сами называете цену: кольцо без виртуальных узлов даёт перекос, и в этом расчёте он составил 90,5 крата. Третья — вы говорите, чем платят за виртуальные узлы: размером структуры, которую держат все участники, и приводите форму размена — от 1 к 16 точкам почти весь выигрыш, от 128 к 512 последние проценты.
Стоит также не приравнивать кольцо к самому свойству: консистентное хеширование — это требование «владелец ключа не зависит от числа узлов», а кольцо — известная конструкция, которая его выполняет, и не единственная.
Чего говорить не стоит: «консистентное хеширование переносит только 1/N ключей». Это верно для добавления одного равноправного узла при ровной раскладке ключей и неверно как общее утверждение — и скрывает перекос, который в расчёте оказался важнее.
Дальше спросят
Почему добавление двух узлов перенесло меньше, чем добавление одного?
Потому что при делении по модулю доля переезда — свойство арифметики остатков, а не размера изменения. Посчитано: 88,9 % при переходе с восьми узлов на девять и 80,2 % при переходе на десять.
Никакой закономерности вроде «чем больше добавляем, тем больше переезд» здесь нет. Это само по себе довод против схемы: объём переноса невозможно спрогнозировать до того, как его посчитаешь.
Кольцо — это и есть консистентное хеширование?
Кольцо — самая известная конструкция, а не синоним. Консистентное хеширование — это требование к схеме раскладки: владелец ключа не должен зависеть от числа узлов, поэтому изменение состава обязано трогать только ключи рядом с самим изменением. Окружность с точками — один из способов это требование выполнить.
Способ не единственный: известны и другие схемы с тем же свойством, устроенные иначе, — они по-другому ищут владельца ключа и по-другому борются с неравномерностью долей. На собеседовании это стоит оговорить одной фразой: назвать кольцо конкретной реализацией свойства, а не самим свойством. Все числа этого урока посчитаны на кольце, и переносить их на другую схему нельзя.
Сколько виртуальных узлов ставить?
Столько, чтобы перекос стал приемлемым для вашего числа узлов, и ни одной точкой больше. В этом расчёте на восьми узлах 16 точек убрали почти весь перекос — 90,5 крата стали 1,9, — а следующее учетверение структуры дало 1,4 против 1,1.
Число зависит от размера кластера, поэтому цифру из чужой статьи брать бессмысленно — её считают на своём составе, благо считается она за секунды.
Что делать с горячими ключами?
Ничего из этой темы: консистентное хеширование равномерно раскладывает ключи, а не нагрузку. Если один ключ читают в тысячу раз чаще остальных, любое число виртуальных узлов оставит его на одном узле — на то он и один ключ.
Лечится это другими средствами: репликацией горячего ключа на несколько узлов, кешем перед хранилищем, разбиением самого ключа. Важно уметь назвать эту границу самому: перекос по ключам и перекос по нагрузке — разные задачи, и вторую эта схема не решает.
Где эта схема не спасает?
Там, где данные нельзя двигать по одному ключу. Если у вас реляционная база и
записи связаны соединениями (JOIN), «переехал ключ» — это не то же самое, что
«переехала строка»: связанные данные должны оказаться рядом, и владельца им
выбирает не хеш, а бизнес-правило.
И там, где состав меняется часто. Каждое изменение — переезд доли данных, и если узлы приходят и уходят каждые несколько минут, кластер будет непрерывно занят переносом. Схема делает переезд минимальным, но не бесплатным.
Частые заблуждения
При делении по модулю добавление узла переносит примерно 1/N ключей
Переносит почти всё. Посчитано: добавление девятого узла к восьми сменило владельца у 88,9 % ключей при минимуме 11,1 % для ровной раскладки — в восемь раз больше. Причина в том, что меняется делитель, то есть ответ пересчитывается сразу для всех ключей.
Консистентное хеширование — это просто другой способ считать хеш
Хеш тот же; меняется то, что считается ответом. У модуля владелец ключа зависит от числа узлов, у кольца — от позиции ключа на окружности. Поэтому появление узла меняет ответ только для ключей рядом с ним: посчитано 11,5 % против 88,9 %.
Кольцо раскладывает ключи равномерно
Само по себе — нет. Посчитано: при одной точке на узел самый нагруженный узел получил 33,3 % ключей, самый лёгкий 0,4 % — разница в 90,5 раза при восьми узлах. Восемь точек, положенных хешем имён, делят окружность на восемь дуг разной длины, и такое разбиение неравномерно. Равномерность даёт не кольцо, а виртуальные узлы.
Виртуальные узлы бесплатны, поэтому их надо ставить побольше
Они стоят размером структуры, которую держит каждый участник: 512 точек на узел при восьми узлах — это кольцо из 4096 записей вместо восьми. И отдача быстро убывает: переход от 1 к 16 точкам убрал перекос с 90,5 до 1,9 крата, а от 128 к 512 — с 1,4 до 1,1.
Схема решает проблему неравномерной нагрузки
Она равномерно раскладывает ключи, а не обращения к ним. Один ключ, который читают в тысячу раз чаще прочих, останется на одном узле при любом числе виртуальных точек. Это другая задача, и решается она репликацией горячего ключа или кешем, а не настройкой кольца.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ печатает сам скрипт расчёта.
Практика · что напечатает
print(f"{modulo:.1%}")
print(f"{ring_moved:.1%}")
print(f"{others:.1%}")Практика · оцените
Проверка знаний
Восемь узлов, деление по модулю. Добавили девятый. Какая доля ключей сменит владельца?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Ключи надо разложить по узлам, и самый простой способ — поделить хеш ключа с остатком на число узлов. Раскладывает он ровно и стоит одну операцию. Беда в том, что владелец ключа при этом оказывается свойством не ключа, а текущего числа узлов: меняется делитель — меняется ответ сразу для всех ключей. Консистентное хеширование — это любая схема, где владелец привязан к самому ключу; кольцо — самая известная из таких схем, но не единственная возможная.
- Отсюда главное следствие: добавить узел в схему с делением по модулю — значит перевезти почти все данные. Посчитано на 100 000 ключей для перехода с восьми узлов на девять — то есть когда к восьми добавляется один равноправный узел, а ключи раскладываются поровну: переехать обязаны 11,1 % (столько причитается новому узлу), а сменили владельца 88,9 % — в восемь раз больше. Кольцо на том же переходе и тех же ключах перенесло 11,5 %.
- Дальше — остальные числа и цена. Удаление узла кольцо переживает ещё чище: посчитано — переехали 11,7 %, ровно доля ушедшего узла, и 0,0 % чужих ключей. Платят за это перекосом: при одной точке на узел самый нагруженный узел оказался тяжелее самого лёгкого в 90,5 раза — 33,3 % ключей против 0,4 %. Перекос лечат виртуальными узлами и платят структурой: 128 точек на узел дают 1,4 крата вместо 90,5, а кольцо вырастает с 8 точек до 1024. Доля переезда от числа точек при этом почти не зависит: 11,5 % при одной точке и 10,7 % при 128.
На самом деле
- Переносит почти всё. Посчитано: добавление девятого узла к восьми сменило владельца у 88,9 % ключей при минимуме 11,1 % для ровной раскладки — в восемь раз больше. Причина в том, что меняется делитель, то есть ответ пересчитывается сразу для всех ключей.
- Хеш тот же; меняется то, что считается ответом. У модуля владелец ключа зависит от числа узлов, у кольца — от позиции ключа на окружности. Поэтому появление узла меняет ответ только для ключей рядом с ним: посчитано 11,5 % против 88,9 %.
- Само по себе — нет. Посчитано: при одной точке на узел самый нагруженный узел получил 33,3 % ключей, самый лёгкий 0,4 % — разница в 90,5 раза при восьми узлах. Восемь точек, положенных хешем имён, делят окружность на восемь дуг разной длины, и такое разбиение неравномерно. Равномерность даёт не кольцо, а виртуальные узлы.
- Они стоят размером структуры, которую держит каждый участник: 512 точек на узел при восьми узлах — это кольцо из 4096 записей вместо восьми. И отдача быстро убывает: переход от 1 к 16 точкам убрал перекос с 90,5 до 1,9 крата, а от 128 к 512 — с 1,4 до 1,1.
- Она равномерно раскладывает ключи, а не обращения к ним. Один ключ, который читают в тысячу раз чаще прочих, останется на одном узле при любом числе виртуальных точек. Это другая задача, и решается она репликацией горячего ключа или кешем, а не настройкой кольца.
Что разобрано
- Что здесь на самом деле спрашивают
- База: как узнают, на каком узле лежит ключ
- Механизм 1: модуль переносит почти всё
- Механизм 2: кольцо переносит близко к минимуму
- Механизм 3: удаление узла не трогает чужие ключи
- Глубже: перекос и виртуальные узлы
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
1 ИСТОЧНИК
- Вычисление этого урока: кольцо и точный пересчёт владельцевИсточник. Раскладка ключей по узлам считается точно, и доля переехавших получается пересчётом, а не оценкой. Случайности нигде нет, кроме самих ключей: они порождаются фиксированным зерном, а хеш взят как первые восемь байт SHA-1 — встроенный hash() у строк рандомизируется при каждом запуске, и прогон не воспроизводился бы. Всё, что печатает прогон, проверяется чтением скрипта./ru/bench/hashring/ring.py