SQL и Qt

Шардинг: горизонтальное масштабирование на уровне СУБД

Введение

Репликация (статья 429) масштабирует чтение, распределяя нагрузку между несколькими полными копиями одних и тех же данных, но не решает проблему масштабирования записи или ограничения по объёму данных, помещающихся на одном сервере — шардинг распределяет сами данные (а не их копии) между несколькими независимыми серверами, каждый из которых отвечает лишь за часть общего набора данных.

Концепция

Шардинг разделяет таблицу (или всю БД) на несколько частей (шардов), физически размещённых на разных серверах, на основе значения некоторого ключа шардирования (shard key) — каждый конкретный запрос обычно должен явно или неявно указывать значение ключа шардирования, чтобы система могла направить запрос на правильный, конкретный шард, содержащий релевантные данные. Шардинг даёт практически неограниченное горизонтальное масштабирование как чтения, так и записи (в отличие от репликации, масштабирующей только чтение), но за значительно большую архитектурную сложность — запросы, требующие данных из нескольких разных шардов одновременно (JOIN между сущностями, оказавшимися на разных шардах; агрегация по всем шардам сразу) становятся значительно сложнее реализовать эффективно по сравнению с обычной, не шардированной БД.

Пример кода: Шардинг

# Концептуальная демонстрация шардинга на уровне логики приложения
# (большинство реальных систем используют специализированные инструменты шардинга,
# но базовый принцип распределения по ключу остаётся тем же)

def get_shard_for_customer(customer_id, num_shards=4):
    return customer_id % num_shards # простейший вариант шардирования по остатку от деления

def get_connection_for_customer(customer_id):
    shard_number = get_shard_for_customer(customer_id)
    shard_connections = {
        0: "postgresql://shard0.internal/mydb",
        1: "postgresql://shard1.internal/mydb",
        2: "postgresql://shard2.internal/mydb",
        3: "postgresql://shard3.internal/mydb",
    }
    return shard_connections[shard_number]
-- Запрос к КОНКРЕТНОМУ шарду — приложение само определило нужный шард на основе customer_id
-- и направило этот запрос именно на соответствующий сервер
SELECT * FROM orders WHERE customer_id = 12345; -- выполняется на shard1 (12345 % 4 == 1)
-- Проблема: запрос, требующий данных СО ВСЕХ шардов одновременно — значительно сложнее
-- "Топ-10 заказов по сумме среди ВСЕХ клиентов" требует обращения к каждому шарду отдельно
-- и объединения/повторной сортировки результатов на уровне приложения, поскольку
-- ни один отдельный шард не содержит полной картины по всем клиентам

Пояснения к коду

get_shard_for_customer() с простым customer_id % num_shards показывает один из наиболее простых, но практически широко используемых способов шардирования — равномерное, основанное на хеш-подобном распределении значения ключа по фиксированному количеству шардов, что обеспечивает достаточно равномерное распределение данных при условии, что значения customer_id распределены относительно равномерно (без сильной концентрации на отдельных диапазонах значений). Прямой комментарий о проблеме «топ-10 заказов среди всех клиентов» явно демонстрирует фундаментальное архитектурное ограничение шардинга — запросы, естественные и простые для обычной, нешардированной БД (просто ORDER BY ... LIMIT 10 по всей таблице), требуют значительно более сложной логики (запрос к каждому шарду отдельно, последующее объединение и повторная сортировка результатов на уровне приложения) при наличии шардинга.

Подводные камни

  • Применение шардинга преждевременно, без реальной, измеренной необходимости — шардинг радикально увеличивает архитектурную сложность системы (включая необходимость переписывания значительной части логики доступа к данным с учётом распределённой природы хранения), и эта сложность должна быть оправдана реальным, объективно измеренным масштабом данных/нагрузки, действительно недостижимым через значительно более простые альтернативы (вертикальное масштабирование одного сервера, более тщательная оптимизация запросов и индексов, репликация для разгрузки чтения) — шардинг — мера, оправданная для систем действительно очень большого масштаба, а не «стандартная» практика, применяемая превентивно для проектов умеренного размера.
  • Выбор неудачного ключа шардирования, приводящего к существенно неравномерному распределению данных между шардами («горячие» шарды, содержащие значительно больше данных или нагрузки, чем остальные) — выбор ключа шардирования должен учитывать реальное, ожидаемое распределение значений и паттернов доступа, и неудачный выбор может фактически свести на нет основное преимущество шардинга (равномерное распределение нагрузки), концентрируя значительную часть реальной нагрузки на ограниченном подмножестве шардов.
  • Недостаточная продуманность обработки запросов, требующих данных с нескольких шардов одновременно (как показано в примере с «топ-10 среди всех клиентов») — такие запросы либо требуют значительно более сложной реализации на уровне приложения (явный сбор и объединение частичных результатов с каждого шарда), либо вовсе оказываются неэффективными/непрактичными при шардированной архитектуре, и перед внедрением шардинга важно явно проанализировать, насколько часто реальные паттерны использования системы требуют именно таких, межшардовых запросов.
  • Сложность изменения схемы шардирования (количества шардов, ключа шардирования) после того, как система уже находится в production с реальными, накопленными данными — в отличие от относительно простого изменения схемы обычной, нешардированной БД (статья 428), изменение фундаментальной схемы шардирования (например, увеличение количества шардов) обычно требует сложного, потенциально длительного процесса перераспределения (resharding) уже существующих данных между новой конфигурацией шардов, что значительно сложнее и рискованнее обычной миграции схемы.