Консистентное хеширование
Чтобы обеспечить горизонтальное масштабирование, важно эффективно и равномерно распределять запросы и данные между серверами. Консистентное хеширование — широко используемая техника для достижения этой цели. Но сначала давайте подробно рассмотрим саму проблему.
Проблема перехеширования
Если у вас есть n кэш-серверов, распространенный способ балансировки нагрузки — использовать следующий метод хеширования:
serverIndex = hash(key) % N, где N — размер пула серверов.
Рассмотрим пример, чтобы понять, как это работает. Как показано в таблице 1, у нас есть 4 сервера и 8 строковых ключей вместе с их хешами.
| ключ | хеш | хеш % 4 |
|---|---|---|
| key0 | 18358617 | 1 |
| key1 | 26143584 | 0 |
| key2 | 18131146 | 2 |
| key3 | 35863496 | 0 |
| key4 | 34085809 | 1 |
| key5 | 27581703 | 3 |
| key6 | 38164978 | 2 |
| key7 | 22530351 | 3 |
Таблица 1
Чтобы получить сервер, на котором хранится ключ, мы выполняем операцию по модулю f(key) % 4. Например, hash(key0) % 4 = 1 означает, что клиент должен обратиться к серверу 1, чтобы получить кэшированные данные. На рисунке 1 показано распределение ключей на основе таблицы 1.
Рисунок 1
Этот подход хорошо работает, когда размер пула серверов фиксирован, а данные распределены равномерно. Однако проблемы возникают, когда добавляются новые серверы или удаляются существующие. Например, если сервер 1 уходит в офлайн, размер пула серверов становится равным 3. При использовании той же хеш-функции мы получаем для ключа то же значение хеша. Но операция по модулю дает уже другие индексы серверов, потому что количество серверов уменьшилось на 1. Мы получаем результаты, показанные в таблице 2, если вычислим hash % 3:
| ключ | хеш | хеш % 3 |
|---|---|---|
| key0 | 18358617 | 0 |
| key1 | 26143584 | 0 |
| key2 | 18131146 | 1 |
| key3 | 35863496 | 2 |
| key4 | 34085809 | 1 |
| key5 | 27581703 | 0 |
| key6 | 38164978 | 1 |
| key7 | 22530351 | 0 |
Таблица 2
На рисунке 2 показано новое распределение ключей на основе таблицы 2.
Рисунок 2
Как видно из рисунка 2, перераспределяется большинство ключей, а не только те, которые изначально хранились на сервере, ушедшем в офлайн (сервер 1). Это означает, что когда сервер 1 становится недоступным, большинство клиентов кэша будут обращаться не к тем серверам при попытке получить данные. Это вызывает шторм кэш-промахов. Консистентное хеширование — эффективная техника для смягчения этой проблемы.
Консистентное хеширование
Согласно Wikipedia, «консистентное хеширование — это особый вид хеширования, при котором при изменении размера хеш-таблицы и использовании консистентного хеширования в среднем требуется переназначить только k/n ключей, где k — количество ключей, а n — число слотов. В отличие от этого, в большинстве традиционных хеш-таблиц изменение числа слотов массива приводит к переназначению почти всех ключей» [1].
Пространство хешей и хеш-кольцо
Теперь, когда мы понимаем определение консистентного хеширования, давайте разберемся, как оно работает. Предположим, что в качестве хеш-функции f используется SHA-1, а диапазон выходных значений хеш-функции таков: x0, x1, x2, x3, …, xn. В криптографии пространство хешей SHA-1 простирается от 0 до 2^160 - 1. Это означает, что x0 соответствует 0, xn соответствует 2^160 - 1, а все остальные значения хеша посередине лежат между 0 и 2^160 - 1. На рисунке 3 показано пространство хешей.
Рисунок 3
Если соединить оба конца, мы получим хеш-кольцо, как показано на рисунке 4:
Рисунок 4
Хеширование серверов
Используя ту же хеш-функцию f, мы отображаем серверы на кольцо по IP-адресу сервера или его имени. На рисунке 5 показано, как 4 сервера отображаются на хеш-кольцо.
Рисунок 5
Хеширование ключей
Важно отметить, что используемая здесь хеш-функция отличается от той, что применялась в разделе «проблема перехеширования», и здесь нет операции взятия по модулю. Как показано на рисунке 6, 4 ключа кэша (key0, key1, key2 и key3) хешируются на хеш-кольцо.
Рисунок 6
Поиск сервера
Чтобы определить, на каком сервере хранится ключ, мы движемся по кольцу по часовой стрелке от позиции ключа, пока не встретим сервер. Рисунок 7 объясняет этот процесс. Двигаясь по часовой стрелке, key0 хранится на server 0; key1 хранится на server 1; key2 хранится на server 2, а key3 хранится на server 3.
Рисунок 7
Добавление сервера
Используя описанную выше логику, добавление нового сервера потребует перераспределения лишь части ключей.
На рисунке 8 после добавления нового server 4 перераспределить нужно только key0. k1, k2 и k3 остаются на тех же серверах. Давайте внимательнее посмотрим на логику. До добавления server 4 ключ key0 хранился на server 0. Теперь key0 будет храниться на server 4, потому что server 4 — первый сервер, который встречается при движении по часовой стрелке от позиции key0 на кольце. Остальные ключи, согласно алгоритму консистентного хеширования, не перераспределяются.
Рисунок 8
Удаление сервера
Когда сервер удаляется, при консистентном хешировании перераспределяется только небольшая часть ключей. На рисунке 9 при удалении server 1 только key1 должен быть переназначен на server 2. Остальные ключи не затрагиваются.
Рисунок 9
Две проблемы базового подхода
Алгоритм консистентного хеширования был предложен Karger и соавторами из MIT [1]. Базовые шаги таковы:
-
Отобразить серверы и ключи на кольцо при помощи равномерно распределенной хеш-функции.
-
Чтобы выяснить, какому серверу соответствует ключ, двигаться по часовой стрелке от позиции ключа до первого найденного на кольце сервера.
У этого подхода есть две проблемы. Во-первых, невозможно сохранять одинаковый размер разделов на кольце для всех серверов, учитывая, что сервер может быть добавлен или удален. Раздел — это участок хеш-пространства между соседними серверами. Возможна ситуация, когда размер разделов на кольце, назначенных серверам, очень мал или, наоборот, довольно велик. На рисунке 10, если s1 удаляется, раздел s2 (выделен двунаправленными стрелками) становится в два раза больше, чем разделы s0 и s3.
Рисунок 10
Во-вторых, на кольце может возникнуть неравномерное распределение ключей. Например, если серверы отображены в позиции, показанные на рисунке 11, большинство ключей будет храниться на server 2. При этом server 1 и server 3 не будут содержать данных.
Рисунок 11
Для решения этих проблем используется техника, называемая виртуальными узлами или репликами.
Виртуальные узлы
Виртуальный узел ссылается на реальный узел, и каждый сервер представлен на кольце несколькими виртуальными узлами. На рисунке 12 и server 0, и server 1 имеют по 3 виртуальных узла. Число 3 выбрано произвольно; в реальных системах количество виртуальных узлов намного больше. Вместо использования s0 мы применяем s0_0, s0_1 и s0_2 для представления server 0 на кольце. Аналогично, s1_0, s1_1 и s1_2 представляют server 1 на кольце. При использовании виртуальных узлов каждый сервер отвечает за несколько разделов. Разделами (дугами) с меткой s0 управляет server 0. С другой стороны, разделами с меткой s1 управляет server 1.
Рисунок 12
Чтобы определить, на каком сервере хранится ключ, мы движемся по часовой стрелке от позиции ключа и ищем первый виртуальный узел, встреченный на кольце. На рисунке 13, чтобы понять, где хранится k0, мы движемся по часовой стрелке от его позиции и находим виртуальный узел s1_1, который ссылается на server 1.
Рисунок 13
По мере увеличения числа виртуальных узлов распределение ключей становится более сбалансированным. Это связано с тем, что стандартное отклонение уменьшается при росте числа виртуальных узлов, что приводит к более равномерному распределению данных. Стандартное отклонение показывает, насколько сильно данные разбросаны. Результат эксперимента, описанного в материале [2], показывает, что при наличии ста или двухсот виртуальных узлов стандартное отклонение составляет от 5% (200 виртуальных узлов) до 10% (100 виртуальных узлов) от среднего значения. При увеличении количества виртуальных узлов стандартное отклонение становится еще меньше. Однако для хранения данных о виртуальных узлах требуется больше места. Это компромисс, и количество виртуальных узлов можно настраивать в соответствии с требованиями системы.
Поиск затронутых ключей
Когда сервер добавляется или удаляется, часть данных должна быть перераспределена. Как определить затронутый диапазон, по которому нужно перераспределять ключи?
На рисунке 14 server 4 добавляется на кольцо. Затронутый диапазон начинается с s4 (нового добавленного узла) и движется против часовой стрелки по кольцу, пока не встретится сервер (s3). Следовательно, ключи, расположенные между s3 и s4, должны быть перераспределены на s4.
Рисунок 14
Когда сервер (s1) удаляется, как показано на рисунке 15, затронутый диапазон начинается в s1 (удаленном узле) и движется против часовой стрелки по кольцу, пока не встретится сервер (s0). Следовательно, ключи, расположенные между s0 и s1, должны быть перераспределены на s2.
Рисунок 15
Подведем итоги
В этой главе мы подробно обсудили консистентное хеширование, включая то, зачем оно нужно и как оно работает. Преимущества консистентного хеширования включают следующее:
-
При добавлении или удалении серверов перераспределяется минимальное число ключей.
-
Оно упрощает горизонтальное масштабирование, потому что данные распределяются более равномерно.
-
Оно смягчает проблему горячих ключей (hotspot key). Чрезмерное число обращений к конкретному шарду может привести к перегрузке сервера. Представьте, что данные о Katy Perry, Justin Bieber и Lady Gaga попали в один и тот же шард. Консистентное хеширование помогает смягчить эту проблему, распределяя данные более равномерно.
Консистентное хеширование широко используется в реальных системах, включая несколько известных примеров:
-
Компонент разбиения данных в базе данных Amazon Dynamo [3]
-
Разбиение данных по кластеру в Apache Cassandra [4]
-
Чат-приложение Discord [5]
-
Сеть доставки контента Akamai [6]
-
Сетевой балансировщик нагрузки Maglev [7]
Поздравляем, вы добрались до этого места! Теперь можно похлопать себя по плечу. Отличная работа!
Справочные материалы
[1] Consistent hashing:
https://en.wikipedia.org/wiki/Consistent_hashing
[2] Consistent Hashing:
https://tom-e-white.com/2007/11/consistent-hashing.html
[3] Dynamo: Amazon’s Highly Available Key-value Store:
https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf
[4] Cassandra - A Decentralized Structured Storage System:
http://www.cs.cornell.edu/Projects/ladis2009/papers/Lakshman-ladis2009.PDF
[5] How Discord Scaled Elixir to 5,000,000 Concurrent Users:
https://discord.com/blog/how-discord-scaled-elixir-to-5-000-000-concurrent-users
[6] CS168: The Modern Algorithmic Toolbox Lecture #1: Introduction and Consistent Hashing:
http://theory.stanford.edu/~tim/s16/l/l1.pdf
[7] Maglev: A Fast and Reliable Software Network Load Balancer:
https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/44824.pdf