Шардинг и партиционирование
Когда один узел не вмещает данные или не тянет записи — дроби. Range, hash и directory балансируют по-разному, и любой из них может породить горячий шард, болезненный решардинг и запросы, которые больше не помещаются на одном узле.
Соцприложение шардировало базу по user ID — чисто, ровно, на каждом шарде по нескольку миллионов пользователей. Потом знаменитость с 80 миллионами подписчиков сделала пост, и CPU одного шарда встал на 100%, пока остальные девятнадцать дремали почти впустую. У «ровно сбалансированного» кластера был один горячий шард с непропорционально огромной нагрузкой, и быстрой починки не было: нельзя перенести часть пользователя на другой узел, а переразбить весь ключевой простор под живым трафиком — самая страшная операция в здании. Шардинг — то, как ты масштабируешься за пределы одной машины, — и ключ партиционирования, выбранный в первый день, необратимо решает, где будут жить твои горячие точки и самые болезненные миграции.
Репликации мало
Репликация копирует весь датасет на каждый узел, так что масштабирует чтения и переживает отказы — но каждый узел всё ещё держит всё и принимает каждую запись. Как только данные не влезают на один диск или темп записи превышает то, что один лидер может впитать, копирование не помогает; надо дробить. Шардинг (горизонтальное партиционирование) делит датасет на непересекающиеся куски — шарды или партиции — и кладёт каждый на свой узел, так что каждый держит лишь 1/N данных и принимает лишь свою долю записей.
(О словах: «партиционирование» внутри одного движка БД — скажем, декларативные партиции Postgres — и «шардинг» по отдельным серверам БД — это одна идея в разных масштабах. Юнит sharding трека databases уходит вглубь механики одного движка; здесь мы рассуждаем о самом выборе распределения.)
Решение, управляющее всем, — ключ партиционирования: поле, чьё значение решает, на каком шарде живёт строка. Выбери хорошо — нагрузка распределяется ровно, а частые запросы остаются на одном шарде. Выбери плохо — получишь горячий шард из hook и межшардовые запросы, расходящиеся веером на все узлы.
Три способа назначить ключи шардам
Выбор стратегии — решение, с которым ты живёшь годами: стоит разобраться в точном компромиссе каждого варианта прежде, чем написать первый скрипт миграции.
range: [А–Е] → шард 0 [Ж–Н] → шард 1 [О–Я] → шард 2
hash: шард = hash(key) mod N (растаскивает соседей)
directory: таблица поиска: key → шард (явно, гибко, ещё один хоп)- Range-партиционирование держит ключи в сортированном порядке, так что range-сканы («все заказы за март») бьют в один шард и остаются эффективны. Опасность — упорядоченные ключи кучкуются: шардируй по timestamp, и все сегодняшние записи лягут на последний шард — движущаяся горячая точка.
- Hash-партиционирование прогоняет ключ через хеш и назначает по результату, растаскивая соседние ключи по шардам ради ровной нагрузки. Цена — range-сканы теперь бьют в каждый шард, ведь соседние ключи нарочно далеко друг от друга.
- Directory-партиционирование держит явную таблицу, отображающую ключи (или диапазоны) на шарды. Максимум гибкости — горячий ключ можно переселить на собственный шард — ценой лишнего поиска и директории, которая сама по себе критическая инфраструктура.
Вместе эти три стратегии образуют ручку: range даёт эффективность запросов ценой горячих точек на упорядоченных ключах; hash — ровную нагрузку записей ценой range-сканов; directory — гибкость ценой лишнего хопа и новой критической инфраструктуры. Без понимания этой ручки ты потянешься к hash по умолчанию и обнаружишь штраф за range-сканы только в продакшн-инциденте.
Мультитенантный SaaS хранит записи заказов, распределённых по 20 узлам. Основной запрос — «все заказы за март», а вставки идут на автоинкрементных order_id. Какая стратегия партиционирования подходит лучше всего?
Проблема горячего шарда / знаменитости
Даже ровное партиционирование по числу ключей не партиционирует ровно по нагрузке. Знаменитость из hook — канонический случай: один ключ (пользователь, товар, видео) получает на порядки больше трафика, чем средний, так что его шард насыщается, пока остальные дремлют. Это перекос (skew), и он побеждает наивный ответ «просто хешируй ключ», ведь весь трафик одного горячего ключа всё равно ложится на один шард, который им владеет.
Меры все сводятся к размазыванию нагрузки горячего ключа:
- Разбить горячий ключ: добавить случайный суффикс (
celebrity_id:00…:09), чтобы его записи рассыпались по десяти логическим ключам на разных шардах, а чтения свести веером обратно. Меняешь простоту на размазывание. - Закешировать горячий ключ перед шардом, чтобы большинство чтений до него не доходили (уроки кеширования следующего юнита).
- Изолировать его: дать известному горячему ключу собственный выделенный шард (directory-партиционирование делает это изменением в одну строку).
Вместе эти три меры снижают нагрузку на горячий шард с разных сторон — разбивка рассеивает записи, кеш поглощает чтения до того, как они дойдут, изоляция даёт ключу собственный бюджет ёмкости. Без хотя бы одной из них один ключ-знаменитость способен насытить шард, сколько бы узлов ни было в остальном кластере.
Senior-рефлекс: равномерное распределение ключей не означает равномерное распределение нагрузки. Надо рассуждать о паттерне доступа, а не только о просторе ключей.
▸Почему это работает
Почему выбор ключа партиционирования — решение наивысшей ставки и так трудно отменимое? Потому что ключ вшит в то, где физически живёт каждая строка, и менять его — значит двигать данные. Если ты шардируешь заказы по order_id, а самый горячий запрос — «все заказы клиента X», то каждый такой запрос теперь расходится веером на все шарды и собирает результаты — scatter-gather (разослать на все шарды, собрать ответы), который медленен и чья хвостовая задержка растёт с числом шардов (проблема fan-out из урока 01-scalability). Переключить ключ на customer_id позже — значит перечитать и переразместить весь датасет под живым трафиком. Дисциплина: выбирай ключ из доминирующего паттерна доступа и кардинальности, а не из удобства — высокая кардинальность против горячих точек, и согласованный с полем, по которому ты чаще всего фильтруешь или джойнишь, чтобы частые запросы оставались одношардовыми.
Боль решардинга
Рано или поздно ты перерастаёшь число шардов и должен сделать решардинг — добавить шарды и перераспределить данные. С наивной схемой hash(key) mod N это жестоко: меняешь N — и почти каждый ключ отображается на другой шард, так что приходится двигать почти весь датасет разом, обслуживая живые чтения и записи по ключам, которые в полёте. Это ровно та боль «перехешировать всё», ради которой существует consistent hashing (следующий урок) — он даёт добавить шард и сдвинуть лишь ~1/N ключей, а не все.
Даже с consistent hashing решардинг — операционный марафон: копируешь данные, пока они меняются, держишь старый и новый дом записи в синхроне до cutover, делаешь двойную запись или change-data-capture, чтобы не потерять записи во время переезда, и атомарно переключаешь маршрутизацию. Команды, спроектировавшие под решардинг (больше логических шардов, чем физических узлов, так что рост — это «передвинуть шард», а не «переразбить простор ключей»), живут куда легче тех, кто впервые разбивает живой простор во время кризиса ёмкости.
Межшардовые джойны, транзакции и вторичные индексы
Дробление ломает две вещи, что одна база давала бесплатно:
- Межшардовые джойны больше не происходят в движке. Джойн данных на разных шардах надо делать приложению (или слою запросов): собрать куски scatter-gather и сджойнить в памяти. Работает, но медленно и хрупко — поэтому ключи шардинга выбирают так, чтобы джойнимые данные жили вместе (колокация — например, шардировать заказы клиента на тот же шард, что и клиента).
- Межшардовые транзакции теряют одноузловую ACID-атомарность. Атомарно обновить две строки на двух шардах нужна распределённая транзакция (двухфазный коммит) или сага — последовательность локальных транзакций с компенсирующими откатами. Оба куда дороже и отказоопаснее одноузлового
BEGIN…COMMIT; юнит sagas трека distributed разбирает паттерн вглубь. - Вторичные индексы перестают быть глобальными. Если шардируешь по
user_id, но нужно искать поemail, индекс email разбросан по всем шардам. Либо держишь локальный индекс на шард (тогда запрос по email бьёт в каждый шард — scatter-gather), либо глобальный индекс как отдельную шардированную структуру (согласуемую с базовыми данными, часто асинхронно, так что может отставать). Глобальные вторичные индексы DynamoDB, например, eventual-консистентны ровно поэтому.
▸Частая ошибка
Частая и дорогая ошибка — обращаться с шардированным хранилищем как с одной базой и писать запросы, тихо расходящиеся веером на каждый шард. Дашборд с SELECT … WHERE email = ? против кластера, шардированного по user_id, в dev выглядит нормально (один шард) и валится в проде (scatter-gather по 200 шардам, с хвостовой задержкой по самому медленному — снова урок 01-scalability). Дисциплина: знай свой ключ шардинга, делай так, чтобы запросы горячего пути несли его (одношардовые), и относись к любому запросу без ключа шардинга как к scatter-gather, который проектируют намеренно (глобальный индекс, кеш или async-модель чтения), — а не обнаруживают в инциденте.
Ты шардируешь time-series-таблицу по timestamp через range-партиционирование. На тестах записи нормальны, но в проде CPU одного шарда встаёт, пока остальные дремлют. Почему?
Кластер шардирован по user_id через hash(user_id) mod 8. Нужно вырасти до 12 шардов. В чём ядро проблемы и какой дизайн избежал бы его в следующий раз?
Запрос, не несущий ключ шардинга, нельзя маршрутизировать на один шард, поэтому он должен разойтись веером на каждый шард и собрать результаты — этот паттерн зовут _______, и его хвостовая задержка задаётся самым медленным затронутым шардом.
- 01Сравни range, hash и directory партиционирование по тому, в чём каждое хорошо и плохо.
- 02Что такое проблема горячего шарда и как её смягчают?
- 03Почему шардинг ломает джойны, транзакции и вторичные индексы и что заменяет каждое?
Когда репликации мало — данные не влезают или один лидер не впитывает записи — ты шардируешь: дробишь датасет на непересекающиеся партиции по ключу партиционирования, так что каждый узел держит лишь свою долю. Схема решает компромиссы: range даёт эффективные сканы, но горячую точку на монотонно растущих ключах, hash размазывает нагрузку ровно, но убивает range-сканы, directory гибок, но добавляет хоп и критическую таблицу. Определяющая опасность — перекос: ключ-знаменитость, чья нагрузка насыщает один шард, пока остальные дремлют, ведь ровное число ключей не значит ровную нагрузку; горячий ключ разбивают, кешируют или изолируют. Решардинг при hash mod N двигает почти каждый ключ разом, поэтому важны consistent hashing следующего урока (двигать лишь ~1/N) и закладка многих логических шардов заранее. И дробление ломает дары одного узла: межшардовые джойны становятся scatter-gather на стороне приложения (поэтому колокация), межшардовые транзакции требуют 2PC или саг, а вторичные индексы становятся локально-разбросанными или глобально-отстающими. Наивысшая ставка — ключ партиционирования: выбирай его из доминирующего паттерна доступа и кардинальности, ведь он необратимо фиксирует, где будут жить твои горячие точки и труднейшие миграции. Механика одного движка — в юните sharding трека databases; паттерны распределённых транзакций — в юните sagas трека distributed. Теперь, когда увидишь, что запрос расходится веером на все шарды в проде, ты будешь знать — это ошибочный ключ с первого дня или намеренный scatter-gather, правильно спроектированный.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.