Рано или поздно один сервер перестает справляться. Вы можете купить ему больше памяти, больше CPU, более быстрые диски, но в конце концов вы упретесь в потолок. Самый большой сервер конечен. Горизонтальное шардирование — это признание этого факта.

Это философия разделяй и властвуй, примененная к данным. Вместо одной гигантской таблицы users на одном сервере, вы создаете 10, 100 или 1000 маленьких таблиц users, разбросанных по разным серверам. Это дает почти безграничную масштабируемость на запись и чтение.

Но это пакт с дьяволом. В обмен на масштабируемость вы жертвуете простотой, транзакциями и душевным спокойствием. Шардирование решает одну проблему, но создает десять новых, каждая из которых сложнее предыдущей.

#include <iostream>
#include <vector>
#include <string>
#include <cstdint>
#include <map>

class DatabaseShard {
public:
    explicit DatabaseShard(std::string name) : name_(std::move(name)) {}
    void executeQuery(const std::string& query) {
        std::cout << "Executing on shard '" << name_ << "': " << query << std::endl;
    }
private:
    std::string name_;
};

class Application {
public:
    Application(std::map<uint32_t, DatabaseShard> shards) : shards_(std::move(shards)) {}

    void findUserAndOrders(uint64_t userId) {
        uint32_t userShardIndex = userId % shards_.size();
        uint32_t orderShardIndex = (userId + 1) % shards_.size();
        
        shards_.at(userShardIndex).executeQuery("SELECT * FROM users WHERE id = " + std::to_string(userId));

        shards_.at(orderShardIndex).executeQuery("SELECT * FROM orders WHERE user_id = " + std::to_string(userId));
    }
private:
     std::map<uint32_t, DatabaseShard> shards_;
};

int main() {
    std::map<uint32_t, DatabaseShard> cluster;
    cluster.emplace(0, DatabaseShard("shard-1"));
    cluster.emplace(1, DatabaseShard("shard-2"));
    
    Application app(cluster);
    
    app.findUserAndOrders(101);
}

Проблема №1 – Выбор ключа шардирования

Это самое важное решение. От него зависит все. Ключ шардирования — это колонка, по которой вы решаете, на какой сервер отправить строку. Ошибка в выборе ключа приведет к неравномерной нагрузке и сделает систему неэффективной.

  • Решение А: Шардирование по диапазону

    • Плюсы:

      • Простота. Логика очень проста и понятна.

      • Эффективные запросы по диапазону. Легко получить всех пользователей, зарегистрированных, допустим, в октябре.

    • Минусы:

      • Горячие шарды. Почти гарантированно приводит к неравномерной нагрузке. Все новые регистрации будут долбить в последний шард.

      • Сложность управления. Нужно постоянно следить за диапазонами и делить их по мере роста.

  • Решение Б: Шардирование по хешу

    • Плюсы:

      • Идеальное распределение. Данные размазываются по всем шардам максимально равномерно.

    • Минусы:

      • Убивает запросы по диапазону. Данные, которые были рядом, теперь разбросаны по разным серверам.

      • Кошмарное ребалансирование. При добавлении нового шарда почти все данные должны переехать.

  • Решение В: Консистентное хеширование

    • Плюсы:

      • Решает проблему ребалансировки. При добавлении нового шарда переехать должна только небольшая часть данных.

      • Хорошее распределение.

    • Минусы:

      • Сложность. Логика сложнее, чем у простого хеширования.

      • Возможны небольшие перекосы в распределении.

Проблема №2 – Маршрутизация запросов

Хорошо, мы разделили данные. Теперь приложению приходит запрос: Найди пользователя с user_id=1. Как оно должно понять, на какой из 100 серверов ему идти?

  • Решение А: Логика маршрутизации в приложении

    • Плюсы:

      • Простота. Не требует никаких дополнительных движущихся частей.

    • Минусы:

      • Дублирование и жесткая связь. Логика размазана по всем сервисам.

      • Хрупкость. Легко допустить ошибку и создать рассинхронизацию.

  • Решение Б: Выделенный сервис маршрутизации

    • Плюсы:

      • Централизация. Вся логика маршрутизации находится в одном месте.

    • Минусы:

      • Дополнительная задержка. Каждый запрос к данным порождает еще один сетевой вызов.

      • Единая точка отказа.

  • Решение В: Прокси-сервер баз данных

    • Плюсы:

      • Прозрачность для приложения. Приложение вообще не знает о шардировании.

      • Мощный функционал. Прокси могут обеспечивать балансировку, кэширование.

    • Минусы:

      • Дополнительный компонент. Еще одна сложная система, которую нужно развертывать и мониторить.

      • Сложность. Настройка и эксплуатация таких систем требует глубокой экспертизы.

Проблема №3 – Прощайте, JOIN-ы и транзакции

Как только вы разнесли users и orders по разным серверам, JOIN перестает работать. ACID-транзакция, затрагивающая оба шарда, становится невозможной.

  • Решение А: JOIN-ы на стороне приложения

    • Плюсы:

      • Простота логики. Это самый прямолинейный способ.

    • Минусы:

      • Низкая производительность. Требует нескольких сетевых походов.

      • Нагрузка на приложение.

  • Решение Б: Денормализация и дублирование данных

    • Плюсы:

      • Высокая скорость чтения. Не нужно делать второй запрос, чтобы получить user_name.

      • Независимость.

    • Минусы:

      • Проблемы с консистентностью. Если пользователь изменит имя, нужно обновить его во всех его заказах.

      • Избыточность.

  • Решение В: Паттерн Сага для транзакций

    • Плюсы:

      • Отказоустойчивость. Работает в асинхронном режиме без блокировок.

      • Слабая связанность.

    • Минусы:

      • Сложность. Требует продуманной системы отката.

      • Итоговая консистентность.

Проблема №4 – Уникальные идентификаторы

Вы больше не можете использовать AUTO_INCREMENT, потому что шард 1 и шард 2 сгенерируют один и тот же ID.

  • Решение А: UUID/GUID

    • Плюсы:

      • Простота. Генерируются на стороне приложения без координации.

      • Глобальная уникальность.

    • Минусы:

      • Размер. Они большие (16 байт) и плохо индексируются.

      • Неупорядоченность. Случайные UUID приводят к плохой локальности записи на диске.

  • Решение Б: Выделенный сервис генерации ID (типа Snowflake)

    • Плюсы:

      • Упорядоченность. ID упорядочены по времени, что хорошо для индексов.

      • Компактность. Обычно это 64-битные числа (BIGINT).

    • Минусы:

      • Единая точка отказа.

      • Сетевая задержка.

  • Решение В: Композитные ID

    • Плюсы:

      • Сохранение преимуществ AUTO_INCREMENT внутри каждого шарда.

    • Минусы:

      • Усложнение кода. Приложение должно работать с составными ключами.

      • Непрозрачность.

Проблема №5 – Ребалансировка (добавление новых шардов)

Ваша система растет, и 10 шардов уже не хватает. Вам нужно добавить одиннадцатый.

  • Решение А: Ребалансировка с простоем

    • Плюсы:

      • Простота и надежность.

    • Минусы:

      • Неприемлемо для высокодоступных систем.

  • Решение Б: Постепенная миграция

    • Плюсы:

      • Минимальное влияние. При консистентном хешировании затрагивается только небольшая часть ключей.

    • Минусы:

      • Требует сложной логики. Приложение на время миграции должно уметь работать и со старым, и с новым расположением данных.

  • Решение В: Использование готовых систем (Vitess, CockroachDB)

    • Плюсы:

      • Прозрачность. Вся сложность скрыта под капотом.

    • Минусы:

      • Высокая сложность внедрения. Это не просто база, это целая распределенная платформа.

Проблема №6 – Горячие шарды

Вы шардировали по user_id, но один пользователь (знаменитость, крупный клиент) генерирует 50% всей нагрузки. Его шард горит, в то время как остальные простаивают.

  • Решение А: Улучшение ключа шардирования

    • Плюсы:

      • Решает проблему в корне. Если добавить к user_id еще один параметр (например, category_id), данные этого пользователя могут распределиться по разным шардам.

    • Минусы:

      • Сложность. Чтение всех данных пользователя теперь требует запроса на несколько шардов.

      • Не всегда возможно. Не всегда есть подходящий второй параметр.

  • Решение Б: Изоляция тяжелых пользователей

    • Плюсы:

      • Предсказуемость. Выделение для таких клиентов отдельных, более мощных шардов позволяет изолировать их нагрузку.

      • Гибкость. Можно применять к ним другие, более агрессивные правила кэширования и лимитирования.

    • Минусы:

      • Ручное управление. Требует механизмов для обнаружения таких пользователей и их переселения.

      • Сложность логики. Маршрутизатор должен иметь специальную логику для особых клиентов.

Проблема №7 – Агрегирующие запросы и аналитика

Как посчитать общее количество пользователей (SELECT COUNT(*)) или найти топ-10 самых активных, если они разбросаны по 100 базам?

  • Решение А: Запросы-вееры

    • Плюсы:

      • Данные в реальном времени. Запрос выполняется по актуальным данным.

      • Простота. Логика проста: опроси всех, собери ответы.

    • Минусы:

      • Очень медленно и неэффективно. Создает огромную нагрузку на все шарды одновременно.

      • Ненадежность. Если один шард не ответит, весь результат будет неполным.

  • Решение Б: Выгрузка в хранилище данных

    • Плюсы:

      • Изоляция нагрузки. Аналитические запросы не влияют на производительность основной системы.

      • Оптимизация. Хранилища данных специально спроектированы для быстрых агрегаций.

    • Минусы:

      • Данные неактуальны. Всегда есть задержка (часы или дни) на ETL-процесс.

      • Сложность и стоимость. Требует построения и поддержки отдельного конвейера данных.

Проблема №8 – Управление схемой и миграции

Как накатить миграцию на 100 баз одновременно и что делать, если на 50-й она упадет?

  • Решение А: Инструменты для миграций (Flyway, Liquibase)

    • Плюсы:

      • Автоматизация и надежность. Исключает человеческий фактор. Состояние схемы БД становится частью кода.

      • Воспроизводимость.

    • Минусы:

      • Координация. Если миграция затрагивает несколько сервисов и баз, ее нужно выкатывать скоординированно.

      • Не решают проблему отката. Если миграция упала на полпути, у вас будет 50 баз �� новой схемой и 50 со старой.

  • Решение Б: Двухфазные миграции (без простоя)

    • Плюсы:

      • Безопасность. Позволяет выкатывать изменения, не ломая старые версии приложения.

      • Возможность отката. На каждом этапе можно откатиться назад.

    • Минусы:

      • Сложность. Процесс миграции растягивается во времени и требует больше шагов.

      • Временный код. Требует написания кода, который умеет работать и со старой, и с новой схемой.

Проблема №9 – Резервное копирование и восстановление

Как сделать консистентный бэкап всей системы, если данные постоянно меняются?

  • Решение А: Скоординированные снимки

    • Плюсы:

      • Консистентность. Гарантирует, что все восстановленные данные будут соответствовать одному моменту времени.

    • Минусы:

      • Сложность. Требует поддержки на уровне инфраструктуры.

      • Заморозка I/O. Создание снимка может на короткое время приостановить операции записи в базу.

  • Решение Б: Резервное копирование на уровне логики

    • Плюсы:

      • Гибкость. Не зависит от конкретной инфраструктуры.

    • Минусы:

      • Сложность и риски. Скрипты для исправления данных должны быть идеально написаны и протестированы. Ошибка в них может привести к еще большей потере данных.

Проблема №10 – Сложность локальной разработки

Как разработчику запустить все это на своем ноутбуке?

  • Решение А: Использование Docker Compose

    • Плюсы:

      • Максимальная достоверность. Разработчик работает с полной копией системы.

    • Минусы:

      • Требования к ресурсам. Современные ноутбуки могут не справиться.

      • Медленный старт.

  • Решение Б: Моки и стабы

    • Плюсы:

      • Скорость и легковесность. Запускается быстро и не требует много ресурсов.

    • Минусы:

      • Риск рассинхронизации. Мок может вести себя не так, как реальный сервис.

      • Трудоемкость. Поддержание моков в актуальном состоянии — это отдельная работа.

  • Решение В: Общее облачное dev-окружение

    • Плюсы:

      • Экономия локальных ресурсов.

      • Стабильность. Окружение всегда доступно и настроено.

    • Минусы:

      • Эффект соседа. Один разработчик может сломать окружение для всех остальных.

      • Стоимость.

Проблема №11 – Поиск не по ключу

Вы шардировали по user_id, а найти нужно по email. Email не содержит информации о том, на каком шарде искать.

  • Решение А: Запрос ко всем шардам

    • Плюсы:

      • Простота. Не требует никаких дополнительных структур данных.

    • Минусы:

      • Ужасно неэффективно. Запрос летит на все 100 шардов, даже если нужные данные есть только на одном. Не масштабируется.

  • Решение Б: Глобальная таблица-индекс

    • Плюсы:

      • Быстрый поиск. Один быстрый запрос к индексу, чтобы узнать user_id, и один целевой запрос к нужному шарду.

      • Эффективность. Не создает лишней нагрузки на шарды.

    • Минусы:

      • Дополнительная сложность. Индекс — это еще одна таблица, которую нужно поддерживать.

      • Консистентность. Нужно гарантировать, что при создании/удалении пользователя индекс будет атомарно обновлен.

  • Решение В: Внешний поисковый движок (Elasticsearch)

    • Плюсы:

      • Мощность. Позволяет искать не только по точному совпадению, но и по частичному, с опечатками и т.д.

      • Изоляция. Поисковая нагрузка полностью отделена от основной нагрузки на БД.

    • Минусы:

      • Сложность и стоимость. Требует развертывания и поддержки отдельного кластера Elasticsearch.

      • Итоговая консистентность. Данные в Elasticsearch всегда будут немного отставать от данных в основной базе.

Проблема №12 – Межшардовая уникальность

Как обеспечить UNIQUE constraint на username по всем шардам? База данных на одном шарде не знает о username на других.

  • Решение А: Проверка на уровне приложения с блокировкой

    • Плюсы:

      • Гарантирует уникальность.

    • Минусы:

      • Медленно. Требует блокировки на глобальной таблице-индексе н�� время всей транзакции.

      • Узкое место. Глобальная таблица становится точкой contention для всех операций регистрации.

  • Решение Б: Асинхронная проверка

    • Плюсы:

      • Быстрая регистрация. Пользователь создается сразу, без блокировок.

      • Отказоустойчивость.

    • Минусы:

      • Возможны коллизии. Два пользователя могут одновременно зарегистрировать один и тот же username. Потребуется процесс для разрешения таких конфликтов.

      • Плохой UX. Пользователю могут сообщить, что его username занят, уже после того, как он думал, что успешно зарегистрировался.

#include <iostream>
#include <vector>
#include <string>
#include <cstdint>
#include <stdexcept>
#include <functional>

class ShardRouter {
public:
    ShardRouter(std::vector<std::string> shard_endpoints)
        : endpoints_(std::move(shard_endpoints)) {
        if (endpoints_.empty()) {
            throw std::invalid_argument("Shard endpoints cannot be empty.");
        }
    }

    const std::string& getShardHostFor(uint64_t entityId) const {
        size_t shard_index = std::hash<uint64_t>{}(entityId) % endpoints_.size();
        return endpoints_.at(shard_index);
    }

private:
    std::vector<std::string> endpoints_;
};

int main() {
    std::vector<std::string> shards = {"db-shard-01.prod", "db-shard-02.prod", "db-shard-03.prod"};
    ShardRouter router(shards);

    uint64_t userId = 123;
    uint64_t otherUserId = 321;
    
    std::cout << "User " << userId << " is on shard: " << router.getShardHostFor(userId) << std::endl;
    std::cout << "User " << otherUserId << " is on shard: " << router.getShardHostFor(otherUserId) << std::endl;
    
    return 0;
}

Выбор правильного инструмента для задачи

  1. B2C-приложение. Шардирование по user_id.

  2. Мультитенантное B2B-приложение. Шардирование по tenant_id.

  3. IoT-платформа, логирование. Шардирование по диапазону времени.

Практические рекомендации

  1. Не начинайте с шардирования. Это решение для проблем, которые у вас еще не наступили.

  2. Тщательно выбирайте ключ шардирования.

  3. Ключ шардирования должен быть в большинстве запросов.

  4. ID должны быть глобально уникальными.

  5. Инвестируйте в observability.

  6. Автоматизируйте все: развертывание, миграции, бэкапы.

  7. Продумайте стратегию ребалансировки.

  8. Денормализация — это не зло, а необходимость.

  9. Будьте готовы к итоговой консистентности.

  10. Проводите нагрузочное тестирование.

  11. Создайте таблицы-индексы для полей, по которым нужен поиск.

  12. Ваша система управления секретами должна быть готова к управлению учетными данными для десятков баз данных.

Горизонтальное шардирование — это не технология. Это архитектурный паттерн, который меняет все. Он дает почти неограниченную масштабируемость, но забирает простоту и строгую консистентность, к которым мы привыкли.

Это шаг, который нельзя делать на всякий случай. Он оправдан только тогда, когда вы уперлись в физические пределы одного сервера, и боль от этого стала невыносимой. Внедрение шардирования — это сложная, дорогая и рискованная операция. Но для систем истинно глобального масштаба другого пути просто нет.

Комментарии (4)