Консистентное хеширование обычно описывают одной фразой: «ключи и серверы раскладываются по окружности, ключ идёт к следующему серверу по часовой стрелке». Из этого сразу выводят три обещания — равномерность, монотонность и минимизацию перемещений. Но что именно стоит за каждым из них: вероятность, математическое ожидание или коэффициент вариацииотношение стандартного отклонения величины к её среднему значению, показывающее относительный разброс? И почему интуиция «нагрузка делится как 1/k» — это упрощение, а не формула? Разберём механику по шагам: от модели окружности до комбинаторики, которая даёт точную оценку разброса.
Что именно обещают три свойства
хеш-функцияфункция, отображающая произвольные данные в число фиксированной разрядности переводит и объекты, и серверы на единичную окружностьотрезок от 0 до 1, замкнутый в кольцо, где за концом следует начало, после чего каждый объект назначается следующему серверу по часовой стрелке. Это даёт равномерное распределение объектов между серверами. При добавлении или удалении сервера перераспределяется лишь часть объектов: добавление n-го сервера перемещает только 1/n долю.
Математическое описание строится через длину сегментаучастка окружности между соседними точками серверов. Для неё вводятся CDFфункция распределения: вероятность того, что случайная величина не превышает заданного значения как P(L ≤ x) и PDFплотность вероятности: производная функции распределения по её аргументу как производная CDF по x. CDF выражается формулой 1 − (1 − x)^(H − 1), PDF — формулой (H − 1)(1 − x)^(H − 2). Ошибка равномерности для N серверов с k хешами задаётся формулой Err_k = sqrt((N − 1)/(kN + 1)).
Ключевая тонкость: «равномерность» здесь — не одно число, а распределение. И именно из него выводятся и ожидаемая доля нагрузки, и разброс вокруг неё.
Модель: окружность, хеши и точки серверов
Состояние кольца в одной из реализаций хранится в двух структурах: словаре, где ключ — хеш точки на окружности, а значение — имя сервера, и срезе хешей, отсортированном по возрастанию. При добавлении сервера под блокировкой на запись для каждого из replicationFactorчисло копий, которыми сервер представлен на окружности экземпляров вычисляется хеш от имени сервера с числовым суффиксом, результат записывается в словарь и добавляется в срез, после чего срез сортируется. Поиск сервера для ключа идёт под блокировкой на чтение: вычисляется хеш ключа, затем бинарным поиском находится индекс первого элемента среза, который не меньше хеша, с закольцовыванием на 0 при выходе за границу.
Хеш здесь 64-битный: blake2b.Sum512криптографическая хеш-функция BLAKE2b, возвращающая 512-битную (64-байтовую) контрольную сумму входных данных вычисляет полную 512-битную сумму, а binary.LittleEndian.Uint64функция, читающая первые 8 байт среза как 64-битное целое в порядке байтов от младшего к старшему берёт из неё младшие 64 бита — этого достаточно, чтобы получить компактный числовой ключ для позиции на окружности. В другом варианте реализации используются 32-битные хеши, словарь с 32-битными ключами, множество имён серверов и счётчик серверов; функция добавления заполняет словарь, после чего пересобирает отсортированный срез из его ключей. Выбор хеш-функции там переключается между FNVбыстрая некриптографическая хеш-функция с простым умножением на константу, хорошо распределяющая короткие строки и CRC32контрольная сумма на основе циклического избыточного кода, вычисляемая аппаратно и дающая равномерное распределение — переключатель позволяет выбрать между скоростью и качеством разброса в зависимости от нагрузки.
Блокировки разделены по типу операции: изменение состояния (добавление, удаление, обновление нагрузки, инкремент и декремент) берёт блокировку на запись, а чтение (поиск сервера, поиск наименее загруженного, перечисление хостов и участников, выбор двух и N серверов) — блокировку на чтение.
Один хеш на сервер: откуда берётся CDF длины сегмента
Классическая схема — один хеш на сервер. Тогда длина сегмента L — это расстояние от 0 до ближайшего хеша. События L ≤ x и L > x дополняют друг друга, поэтому CDF(L) = 1 − P(L > x). Вероятность P(L > x) — это вероятность того, что все остальные H − 1 хешей больше x. Хеши независимы, значит эта вероятность раскладывается в произведение H − 1 множителей, а для одного хеша вероятность оказаться больше x равна 1 − x. Отсюда:
CDF(L) = 1 − (1 − x)^(H − 1)PDF получается дифференцированием CDF по x: производная константы 1 равна нулю, производная (1 − x)^(H − 1) даёт −(H − 1)(1 − x)^(H − 2), и минус на минус даёт итог (H − 1)(1 − x)^(H − 2).
Здесь стоит остановиться: PDF может принимать значения больше 1. Это не ошибка — плотность вероятности не обязана быть вероятностью. Поэтому вероятность конкретного значения ищут через гистограммы, а вероятность попадания в диапазон вычисляют как интеграл PDF по этому диапазону или, что то же самое, как разность CDF на границах.
Именно эта разность служит масштабированной аппроксимацией плотности в точке и позволяет сравнить расчёт с результатами симуляции.
Почему абсолютное отклонение обманывает
Стандартное отклонение длины сегмента задано в абсолютных единицах. Из формулы видно, что оно падает обратно пропорционально общему числу хешей. Но доля диапазона, покрываемая одним сегментом, тоже падает обратно пропорционально общему числу хешей. В результате абсолютная ошибка может оказаться почти равна ожидаемому размеру сегмента. При H = 100 стандартное отклонение около 0.99%, ожидаемый размер сегмента 1% — то есть разброс в обе стороны от ожидания почти равен самому ожиданию.
Сравнивать конфигурации по такому числу бессмысленно. Поэтому переходят к коэффициенту вариации: он определяется как стандартное отклонение, делённое на ожидаемое значение, то есть ошибка приводится к масштабу ожидаемой величины. Для одного хеша на сервер:
CV(L) = SD(L) / Exp(L) = sqrt((H − 1)/(H + 1))При H = 100 это даёт ошибку 99%. И главное: для почти всех значений H ошибка остаётся постоянной и составляет около 100%. Одно-хешевая схема плоха не в каком-то частном режиме — она плоха всегда.
Несколько хешей: комбинаторика вместо интуиции
Схема с k хешами на сервер (виртуальные узлынесколько точек на окружности, указывающих на один реальный сервер) меняет картину. При создании задаётся 20 реплик на сервер, добавление размещает сервер в кольце для каждого индекса от 0 до NumberOfReplicas − 1, используя хеш от имени с этим индексом. Удаление симметрично проходит те же индексы и убирает соответствующие ключи. Поиск находит ближайший по часовой стрелке узел; выбор двух серверов после первого найденного продолжает идти по кругу, пока не встретит сервер, отличный от первого.
Математически задача формулируется так: найти распределение суммы первых k сегментов числовой прямой. Ключевой приём — сведение суммы первых k сегментов к позиции (k + 1)-го наименьшего хеша. Если расставить все хеши на числовой прямой по возрастанию, то длины сегментов — это расстояния между соседними хешами, поэтому сумма длин первых k сегментов равна расстоянию от начала координат до (k + 1)-й точки. Через CDF это записывается как вероятность того, что сумма не превышает x, а затем переписывается через счётчик хешей меньше x.
Чтобы получить замкнутое выражение, вводится параметризованная функция Beta(C, n, m) = C·∫₀¹ xⁿ(1 − x)ᵐ dx — она нужна, чтобы многократно применить интегрирование по частям. После одного прохода степень x уменьшается на 1, степень (1 − x) увеличивается на 1, а множитель C умножается на n/(m + 1). После i проходов состояние записывается как Cᵢ∫₀¹ x^{nᵢ}(1 − x)^{mᵢ}dx, откуда получаются переобозначения nᵢ = n − i, mᵢ = m + i и Cᵢ = C·n!m!/((n − i)!(m + i)!). При i = n, когда nᵢ = 0, интеграл сводится к C·n!m!/(m + n + 1)!. Это и даёт ожидаемую долю нагрузки Exp_k(L) = k/H.
Промежуточная комбинаторика устроена так: вводится функция Bᵢ(x) = C(H − 1, i)(H − 1 − i)·xⁱ(1 − x)^{H − 2 − i}, где C(H − 1, i) — биномиальный коэффициентчисло способов выбрать i элементов из H − 1 без учёта порядка, и рядом A_{i+1}(x) = −C(H − 1, i + 1)(i + 1)·xⁱ(1 − x)^{H − 2 − i}, причём Bᵢ(x) = −A_{i+1}(x). Сумма PDF_k(x) = Σ_{i=0}^{k−1} Aᵢ(x) + Bᵢ(x) после подстановки сокращается попарно, и остаётся PDF_k(x) = A₀(x) − A_k(x). В итоговой формуле k — число хешей на сервер, H — общее число хешей.
Финальная формула и её частный случай
Финальное выражение для ошибки — коэффициент вариации:
Err_k = CV_k(L) = sqrt((H − k)/(k(H + 1)))Оно получается как отношение стандартного отклонения нагрузки к её ожидаемому значению. Для частного случая, когда на каждый сервер приходится одинаковое число хешей H = Nk, формула упрощается до sqrt((N − 1)/(kN + 1)). Автор прямо оговаривает: это не та формула из краткого изложения, и версия из краткого изложения была базовой — для случая одинакового числа хешей на всех серверах. Коэффициент вариации показывает, насколько нагрузка на одном сервере может отличаться от ожидаемой.
Пограничные случаи: что происходит при изменении состава
При добавлении или удалении сервера подавляющее большинство объектов сохраняют прежнее назначение. Добавление n-го сервера перемещает лишь 1/n долю объектов. При удалении сервера переназначаются только те объекты, которые были отображены на отказавший сервер, — они переходят к следующему серверу по часовой стрелке. При добавлении сервера переназначаются только те объекты, для которых новый сервер стал следующим по часовой стрелке, то есть ранее назначенные существующему серверу.
В реализации для поиска сервера используется бинарное дерево поискаструктура данных, где каждый узел имеет не более двух потомков, а левое поддерево содержит меньшие значения. В нём находят преемникаближайший по часовой стрелке сервер, то есть узел с наименьшим идентификатором, не меньшим хеша объекта. При вставке, если значение больше всех идентификаторов серверов, объект помещается на сервер с наименьшим идентификатором. При удалении объект удаляется с возвращённого сервера, а если преемника нет — с наименьшего идентификатора.
Отдельный вопрос — одновременное добавление нескольких серверов. Рассматривается только распределение суммы k сегментов, где k — число хешей одного сервера. Формула для k хешей описывает распределение нагрузки одного сервера с k хешами, но не совместное добавление нескольких серверов. Это ограничение стоит держать в голове: формулы отвечают на вопрос о разбросе нагрузки одного сервера, а не о том, что произойдёт, если в кольцо войдут несколько узлов сразу.
Расширения, без которых схема не балансирует
Виртуальные узлы — это дублирующие метки, указывающие на один реальный сервер. Их количество для конкретного сервера называется весомчислом виртуальных узлов, закреплённых за одним реальным сервером. Они нужны, чтобы избежать перекоса нескольких узлов внутри радианаУчасток окружности между двумя соседними точками серверов. — то есть участка окружности между двумя соседними точками серверов: из-за неравномерного распределения серверов по окружности такие участки получаются разной длины, и нагрузка ложится на серверы неравномерно. При отказе сервера объекты, назначенные каждой из его реплик, переназначаются разным серверам по часовой стрелке — так перераспределение получается более равномерным.
Если один объект становится «горячим» и обслуживается многими серверами, он может быть назначен нескольким смежным серверам при обходе окружности. Если два «горячих» объекта хешируются рядом, они используют один и тот же набор смежных серверов; это смягчается выбором для каждого объекта своей хеш-функции отображения серверов на окружность.
Bounded loadsограничение нагрузки: сервер не берёт новые запросы, если его нагрузка превышает порог реализованы через поиск наименее загруженного хоста, способного обслужить ключ: хосты перебираются по кольцу с проверкой порога. Максимальная нагрузка одного хоста вычисляется как (total_load / number_of_hosts) * 1.25, а проверка порога использует ту же формулу с (totalLoad + 1).
Как это реализовано: нагрузка, счётчики и пересборка
Добавление сервера под блокировкой на запись для каждой реплики вычисляет ключ и кладёт его в кольцо, помечает сервер в множестве участников, пересчитывает отсортированный срез и увеличивает счётчик серверов. Удаление делает обратное: для каждой реплики удаляет ключ из кольца, убирает сервер из множества, пересчитывает срез и уменьшает счётчик.
Пересборка отсортированного среза берёт его же срез нулевой длины, при необходимости обнуляет его и заново собирает все ключи кольца, сортирует и присваивает обратно. Условие обнуления: если отношение ёмкости среза к произведению числа реплик на 4 превышает длину кольца, срез сбрасывается в nil — чтобы не удерживать лишнюю память. Иначе говоря, порог срабатывает, когда выделенная ёмкость более чем вчетверо превышает текущее число ключей.
Счётчики нагрузки обновляются атомарно: инкремент увеличивает нагрузку хоста и общую нагрузку на 1, декремент уменьшает обе на 1. При удалении хоста его нагрузка не декрементируется — хост просто удаляется из карты нагрузок. Декремент нужен именно при завершении запроса, потому что проверка порога сравнивает текущую нагрузку плюс один с порогом средней нагрузки на узел. Без уменьшения нагрузки при завершении запроса хост навсегда остался бы перегруженным, и поиск наименее загруженного хоста больше его не выбирал бы.
Как сравнивали и что получилось
Расчётное распределение сравнивают с симуляцией: вероятность попадания сегмента в дискретный диапазон вычисляется через разность CDF на границах диапазона, что позволяет сопоставить рассчитанное распределение с результатами симуляции. При больших N распределение приближается к 1/k·√(1/k). В системе с 50 и более серверами разница между фактическим значением ошибки и этой аппроксимацией составляет около 1%.
Оговорка: эти цифры описывают сходимость аппроксимации при большом числе серверов, а не абсолютную точность схемы. При малых N аппроксимация 1/k·√(1/k) работает хуже, и пользоваться ею вместо точной формулы не стоит.
Сравнение с альтернативами
Для N узлов и K ключей консистентное хеширование добавляет и удаляет узел за O(K/N + log N), тогда как классическая хеш-таблица выполняет обе операции за O(K). Поиск, добавление и удаление ключа у консистентного хеширования — O(log N), у классической хеш-таблицы — O(1). Слагаемое O(K/N) — средняя стоимость перераспределения ключей, а O(log N) возникает потому, что для поиска следующего узла на кольце нужен бинарный поиск среди углов узлов.
Rendezvous hashingсхема согласования, при которой каждый клиент независимо выбирает узел по значениям хешей от ключа и узлов разработан в 1996 году и описывается как более простая и более общая техника: он позволяет полностью распределённо согласовать набор из k вариантов из n возможных. Консистентное хеширование — частный случай rendezvous hashing, и из-за простоты и общности rendezvous hashing теперь используется вместо консистентного хеширования во многих приложениях. Если значения ключей всегда монотонно возрастают, альтернативный подход с хеш-таблицей с монотонными ключами может оказаться более подходящим.
Пример реализации на Java и его ограничения
В примере Tom White класс ConsistentHash<T> хранит кольцо как SortedMap<Integer, T>, создаваемый как TreeMap; ключи — целые хеш-значения, значения — узлы. Конструктор принимает хеш-функцию, число реплик и коллекцию узлов, сохраняет их и для каждого узла вызывает добавление. Добавление для i от 0 до numberOfReplicas кладёт в кольцо пару «хеш от имени узла с числовым суффиксом → узел». Удаление для тех же i удаляет записи по тем же хешам.
Поиск при пустом кольце не находит узел. Иначе вычисляется хеш ключа, и если такого ключа в кольце нет, берётся tailMap от хеша и выбирается первый ключ из него, а если tailMap пуст — первый ключ всего кольца (замыкание по кругу). Если хеш ключа точно совпал с точкой сервера, ветка поиска следующего узла не выполняется и берётся значение по этому же ключу.
Ограничения этого подхода по сравнению с реализацией на Go: нет отдельного множества участников, нет счётчика серверов, нет буфера для пересборки, нет выбора между crc32 и fnv, нет блокировок чтения-записи и нет ошибки пустого кольца — при пустом кольце узел не находится, а не сообщается об ошибке.
Что из этого следует на практике
- «Равномерность» — это не одно число, а распределение. Ожидаемая доля нагрузки на сервер равна k/H, но разброс вокруг неё описывается коэффициентом вариации, и именно он определяет, насколько один сервер может оказаться перегружен относительно другого.
- Один хеш на сервер не работает: коэффициент вариации sqrt((H − 1)/(H + 1)) при почти любом H даёт около 100%. Виртуальные узлы — не оптимизация, а необходимое условие работоспособности.
- Абсолютное стандартное отклонение длины сегмента обманчиво: оно падает вместе с размером сегмента, поэтому сравнивать конфигурации нужно по безразмерной мере — коэффициенту вариации.
- Формула sqrt((N − 1)/(kN + 1)) — это частный случай для одинакового числа хешей на всех серверах. Общая формула — sqrt((H − k)/(k(H + 1))). Если серверы имеют разный вес, частный случай неприменим.
- Аппроксимация 1/k·√(1/k) расходится с точным значением примерно на 1% только при 50 и более серверах. При меньшем числе серверов её использовать нельзя.
- При добавлении n-го сервера перемещается 1/n доля объектов; при удалении — только объекты отказавшего сервера. Это и есть минимизация перемещений.
- Bounded loads требуют парного учёта: инкремент при взятии запроса и декремент при завершении. Без декремента хост навсегда останется перегруженным и выпадет из выбора.
- Консистентное хеширование — частный случай rendezvous hashing, и по сложности операций оно проигрывает классической хеш-таблице: поиск ключа O(log N) против O(1). Выбор в пользу консистентного хеширования оправдан именно минимизацией перемещений при изменении состава узлов, а не скоростью отдельных операций.