Deep Engineering
Средний·Опубликовано·25 МИН

Консистентное хеширование: сколько ключей переезжает при смене состава

«Хеш по модулю числа узлов» работает ровно до первого изменения состава. Посчитано точно: добавление одного узла к восьми переносит 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();
  • «перекос» как термин, репликация, устройство конкретных хранилищ.

Что здесь на самом деле спрашивают

Лестница выглядит так:

  1. «Как разложить ключи по узлам?» — разминка: ответ на неё — деление по модулю.
  2. «Что произойдёт при добавлении узла?» — здесь начинается содержание, и ответ «часть ключей переедет» уже неверен по величине.
  3. «Что такое консистентное хеширование?» — вопрос про механизм.
  4. «Зачем виртуальные узлы?» — вопрос, на котором видно, считал ли собеседник перекос или пересказывает.
  5. «Чем платят за виртуальные узлы?» — вопрос про размен.
  6. «Где эта схема не спасает?» — вопрос про границы.

Числа получены прогоном 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
наблюдение замераbench/hashring/ring.py. Это не замер работающего кластера и не модель: у каждого из 100 000 ключей владелец вычислен точно — до изменения состава и после, — и доля переехавших получена пересчётом. Колонка ideal share — доля, которая достаётся новым узлам, то есть минимум при ровной раскладке.

Читать надо две колонки рядом, и первую строку — с названными допущениями. Строка описывает переход с восьми узлов на девять: к восьми узлам добавляется один такой же, равноправный, а ключи раскладываются поровну. При этих допущениях 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%
наблюдение замераbench/hashring/ring.py. То же изменение состава и те же ключи, что и в первом блоке. Изменился только способ выбора владельца.

Те же восемь узлов, тот же девятый, те же ключи. Кольцо, у которого на каждый узел приходится одна точка, перенесло 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%
наблюдение замераbench/hashring/ring.py. Кольцо со 128 точками на узел. Третья строка — прямой пересчёт: сколько ключей сменило владельца при том, что их прежний владелец остался в составе.

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

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

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

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

Глубже: перекос и виртуальные узлы

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

Разложим ключи по восьми узлам, поставив на кольцо по одной точке на узел:

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
наблюдение замераbench/hashring/ring.py. Кратность зависит от того, куда легли точки, то есть от имён узлов. С другими именами число будет другим; воспроизводится порядок величины: десятки крат при одной точке и единицы при сотне. Доли в колонках округлены до десятых, а кратность посчитана по неокруглённым: 0,4 % — это округление 0,37 %.

Один узел получил треть всех ключей, другой — четыре десятых процента. Разница в 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
наблюдение замераbench/hashring/ring.py. Число точек — свойство конструкции: узлов восемь, точек на кольце ровно восемь на каждое значение vnodes. Кратность — результат пересчёта владельцев.

Точки на кольце — не абстракция: это структура, по которой ищут владельца каждого ключа и которую обязан держать каждый участник, знающий раскладку. Восемь узлов при 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.

Утверждение

Схема решает проблему неравномерной нагрузки

На самом деле

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

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ печатает сам скрипт расчёта.

Практика · что напечатает

Восемь узлов, сто тысяч ключей. Печатаются три доли от общего числа ключей: сколько ключей сменит владельца при добавлении девятого узла к делению по модулю; сколько сменит при удалении узла из кольца со 128 точками на узел; и сколько ключей сменило владельца, несмотря на то что их прежний узел остался в составе. Что напечатает этот код?
print(f"{modulo:.1%}")
print(f"{ring_moved:.1%}")
print(f"{others:.1%}")

Практика · оцените

Восемь узлов на кольце, по одной точке на узел, сто тысяч ключей. Во сколько раз самый нагруженный узел тяжелее самого лёгкого?
раз

Проверка знаний

Вопрос 1 из 5

Восемь узлов, деление по модулю. Добавили девятый. Какая доля ключей сменит владельца?

Источники и что читать дальше

1 ИСТОЧНИК

  1. Вычисление этого урока: кольцо и точный пересчёт владельцевИсточник. Раскладка ключей по узлам считается точно, и доля переехавших получается пересчётом, а не оценкой. Случайности нигде нет, кроме самих ключей: они порождаются фиксированным зерном, а хеш взят как первые восемь байт SHA-1 — встроенный hash() у строк рандомизируется при каждом запуске, и прогон не воспроизводился бы. Всё, что печатает прогон, проверяется чтением скрипта./ru/bench/hashring/ring.py