Когда данных или нагрузки больше, чем тянет один узел, их разрезают на секции (partitions, они же шарды): каждая запись живёт ровно в одной секции, а секции раскладывают по узлам. Практику шардинга мы разбираем на MongoDB — replica set, shard key, chunks, balancer. Эта статья — этажом выше: как выбрать способ разбиения, что происходит со вторичными индексами, как двигать данные при добавлении узлов и как клиент вообще находит нужную секцию. Все эти вопросы одинаковы для MongoDB, Cassandra, Elasticsearch и HBase — меняются только имена.
Цель — равномерность, враг — горячая точка
Смысл секционирования один: распределить данные и нагрузку по узлам поровну. Десять узлов должны тянуть в десять раз больше одного. Если разбиение неравномерно — на часть секций приходится непропорционально много данных или запросов, — оно называется асимметричным (skewed), а перегруженная секция — горячей точкой (hot spot). В пределе вся нагрузка ложится на один узел, а девять простаивают. Два базовых способа назначить запись секции — по диапазону ключа и по хешу ключа — это по сути два разных ответа на вопрос, как избежать горячей точки.
Диапазон против хеша
По диапазону значений ключа (range partitioning): каждой секции — непрерывный отрезок ключей, как тома бумажной энциклопедии (A–C, D–F, …). Границы подбираются под данные, потому что распределение неравномерно (слов на «A» и на «Q» разное количество). Плюс — диапазонные запросы: ключи внутри секции лежат отсортированными, «все события за март» читаются одним сканом. Минус — тот же порядок легко рождает горячую точку: если ключ начинается с метки времени, вся сегодняшняя запись валится в одну секцию, а вчерашние узлы простаивают. Лечится составным ключом, где время — не первый элемент (сначала имя датчика, потом время).
По хешу ключа (hash partitioning): хорошая хеш-функция превращает даже похожие ключи в равномерно разбросанные числа, и секцией владеет диапазон хешей. Равномерность почти дармовая — так делают Cassandra и MongoDB (MD5 на входе). Цена — потеря сортировки: соседние по смыслу ключи разлетаются по всем секциям, и диапазонный запрос вынужден опрашивать все узлы. Cassandra берёт компромисс составным ключом: хешируется только первая часть (определяет секцию), остальные работают как отсортированный индекс внутри секции — «все сообщения одного пользователя за период» читаются эффективно, а пользователи разбросаны равномерно.
Отдельная беда, которую не чинит ни хеш, ни диапазон, — горячий ключ: знаменитость с миллионами подписчиков, весь трафик которой бьёт в одну запись (хеш двух одинаковых ключей одинаков). Тут помогает только приложение: добавить к ключу случайный суффикс из сотни вариантов — запись размажется по ста секциям, но чтение теперь должно собрать и склеить все сто. Приём стоит применять к горстке заведомо горячих ключей, а не ко всем.
Вторичные индексы: локальные против глобальных
Пока доступ идёт по первичному ключу, всё просто — по нему и маршрутизируем. Но приложению нужны вторичные индексы: «найти все машины красного цвета», «все статьи со словом hogwash». Вторичный индекс не совпадает с секционированием, и способов его разрезать два — с диаметрально разной ценой.
По документу (локальный индекс). Каждая секция ведёт вторичный индекс только по своим документам: добавили красную машину в секцию 3 — запись color:red появилась в индексе секции 3. Запись дёшева: всё локально, трогается одна секция. Зато чтение — scatter/gather: красные машины разбросаны по всем секциям, и запрос «найди красные» приходится слать во все узлы и склеивать ответы. Так работают MongoDB, Cassandra, Elasticsearch. Фрагментированное чтение усиливает хвостовое время ожидания (ждём самый медленный узел), но запись остаётся локальной — поэтому это дефолт.
По терму (глобальный индекс). Один индекс на весь кластер, но сам он тоже секционирован — по искомому терму: color:red целиком лежит в одной секции индекса. Чтение быстрое — идём в одну секцию вместо всех. Цена перевешивает на записи: добавление одного документа с несколькими полями задевает несколько секций индекса сразу (термы color и make живут в разных местах), и держать это синхронно — распределённая транзакция. Поэтому глобальные индексы обновляют асинхронно: после записи изменение появляется в индексе с задержкой (так устроен DynamoDB — «обычно доли секунды, при сбоях дольше»).
Выбор ровно как везде: локальный индекс — дешёвая запись, дорогое чтение; глобальный — наоборот. Дефолт большинства систем — локальный.
Ребалансировка: как двигать секции
Со временем узлы добавляют (нагрузка выросла) и убирают (сбой). Перенос секций с узла на узел называется ребалансировкой, и от неё ждут трёх вещей: после — нагрузка снова ровная; во время — база продолжает читать и писать; переносится не больше данных, чем необходимо.
- Как делать нельзя —
hash mod N. Соблазнительно: секция =hash(key) % N. Но при изменении числа узлов N почти все ключи меняют секцию — 10→11 узлов перетасует практически всё. Ребалансировка становится неподъёмной. - Фиксированное число секций. Создать секций сильно больше, чем узлов (1000 секций на 10 узлов), и раздать по многу на узел. Новый узел «занимает» по несколько секций у существующих — двигаются целые секции, их число и привязка ключей не меняются, меняется только раскладка по узлам. Так делают Elasticsearch, Riak, Couchbase. Грабля — угадать число секций заранее: оно потолок роста, а слишком большое даёт накладные расходы.
- Динамическое секционирование. Секция переросла порог — делится надвое, усохла — сливается с соседней (как узел B-дерева). Число секций подстраивается под объём данных. Так работают HBase, MongoDB (chunks), RethinkDB. Грабля пустой базы — одна секция на старте, вся запись в один узел, пока не случится первое разбиение (лечится pre-splitting, если известно распределение).
И отдельный вопрос — автоматика против ручного. Полностью автоматическая ребалансировка удобна, но опасна в паре с автообнаружением сбоев: перегруженный узел отвечает медленно → система решает, что он отказал → затевает ребалансировку → добавляет нагрузки уже перегруженному узлу → каскадный сбой. Человек в цикле («система предлагает — админ подтверждает», как в Couchbase и Riak) медленнее, но спасает от таких сюрпризов.
Маршрутизация запросов: где живёт «foo»?
Секции разъезжаются по узлам и двигаются при ребалансировке. Откуда клиент знает, к какому узлу идти за ключом foo? Это частный случай service discovery, и решений три:
- Клиент стучится в любой узел (round-robin балансировщик). Если ключ там — узел отвечает сам, если нет — проксирует запрос нужному узлу и возвращает ответ. Так делают Cassandra и Riak через gossip-протокол: узлы сами обмениваются картой кластера, внешний координатор не нужен.
- Маршрутизирующее звено принимает все запросы и знает, какой узел за что отвечает (mongos в MongoDB). Само запросов не обрабатывает — только балансировщик, учитывающий секции.
- Клиент знает раскладку и подключается к нужному узлу напрямую, без посредников.
Во всех трёх кто-то должен знать актуальную карту «секция → узел» и узнавать об изменениях. Классическое решение — отдельный сервис координации ZooKeeper: узлы регистрируются в нём, маршрутизаторы подписываются на изменения, и при переезде секции ZooKeeper всех оповещает. Так делают HBase, SolrCloud, Kafka. Альтернатива — gossip (Cassandra, Riak): сложнее узлы, зато нет зависимости от внешнего координатора.
Где это применяется
Развилка «как шардировать» встаёт ровно тогда, когда один узел перестал тянуть, — и почти каждое решение здесь необратимо: shard key меняется тяжело, схему индексов после запуска не переиграешь. Практическая рамка: диапазон — когда нужны сканы по времени/порядку и вы готовы бороться с горячей точкой составным ключом; хеш — когда важнее равномерность; локальные вторичные индексы — дефолт; глобальные — только если чтение по вторичному ключу критично, а асинхронное обновление приемлемо.
Где спотыкаются начинающие:
- Ключ секционирования начинается с timestamp или автоинкремента — вся свежая запись в одну секцию, классическая горячая точка. Разнообразие на первое место ключа.
- Ждут от вторичного индекса скорости первичного — при локальных индексах любой запрос не по shard key — это scatter/gather по всем узлам.
hash mod Nдля назначения секций — первое же изменение числа узлов перетасует почти все данные. Нужно фиксированное или динамическое число секций.- Полностью автоматическая ребалансировка + автообнаружение сбоев — рецепт каскадного отказа. Человек в цикле ребалансировки.
- Забыли про горячий ключ — знаменитость или один популярный товар кладут секцию, хотя формально распределение ровное.
Что почитать дальше: репликация и шардинг MongoDB — shard key, chunks, balancer, mongos на практике; модели репликации — секционирование почти всегда идёт с репликацией; строительные блоки — где шардинг живёт в общей картине. Первоисточник — Мартин Клеппман, «Высоконагруженные приложения», глава 6.