База данных — это сердце системы. И в какой-то момент это сердце начинает давать сбои. Не от объема данных, а от их разнородности. Таблица users разрастается до 200 колонок. Одни нужны для логина каждую секунду, другие — для годового отчета раз в год. В итоге, чтобы прочитать два горячих поля, база тащит с диска целый блок с холодными данными. Это неэффективно.

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

#include <iostream>
#include <string>
#include <vector>

class MonolithicUserTable {
public:
    void getUserProfile(long userId) {
        std::cout << "SELECT * FROM users WHERE id = " << userId << std::endl;
    }

    void updateUserPassword(long userId) {
        std::cout << "UPDATE users SET password_hash = '...' WHERE id = " << userId << std::endl;
    }
private:
    // id, username, email, password_hash, registration_date, last_seen_at,
    // avatar_url, bio_text, user_settings_json, phone_number,
    // login_history_blob, all_user_posts_json, ...
};

int main() {
    MonolithicUserTable db;
    db.getUserProfile(123);
    db.updateUserPassword(123);
}

Проблема №1 – Как и где провести границу?

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

  • Решение А: Разделение по функциональным группам (Table Splitting)

    • Суть: Таблицы, относящиеся к разным бизнес-функциям, разносятся по разным базам данных. Таблицы профиля пользователя — в profile_db. Таблицы, связанные с финансами, — в billing_db.

    • Плюсы:

      • Логичность. Такое разделение интуитивно понятно и часто совпадает с границами микросервисов.

      • Изоляция нагрузки. Высокая нагрузка на профили не повлияет на производительность финансовых транзакций.

    • Минусы:

      • Теряются JOIN-ы. Вы больше не можете одним запросом соединить таблицу пользователей и их платежей.

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

  • Решение Б: Разделение одной таблицы по колонкам (Column Splitting)

    • Суть: Одна жирная таблица (например, users) разрезается на несколько. В users_core остаются только часто используемые данные (id, email, password_hash). В users_profile — редко используемые (bio, avatar_blob).

    • Плюсы:

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

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

    • Минусы:

      • Усложнение кода. Приложению теперь нужно делать JOIN или два отдельных запроса, чтобы собрать полный объект.

      • Поддержание консистентности. Удаление пользователя теперь требует удаления записей из нескольких таблиц.

  • Решение В: Гибридный подход

    • Суть: Комбинация. Сначала таблицы разносятся по функциональным базам, а затем, внутри каждой базы, самые жирные таблицы дополнительно разрезаются по колонкам.

    • Плюсы:

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

    • Минусы:

      • Максимальная сложность. Количество баз данных и таблиц растет, что превращает управление схемой в кошмар.

Проблема №2 – Прощайте, JOIN-ы

Как только вы разнесли таблицы users и orders по разным физическим серверам, команда SELECT ... FROM users JOIN orders ... перестает работать.

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

    • Суть: Приложение сначала делает запрос в users_db, а затем второй запрос в orders_db (WHERE user_id IN (...)).

    • Плюсы:

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

      • Гибкость. Можно соединять данные из любых источников.

    • Минусы:

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

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

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

    • Суть: Признать, что некоторые данные придется дублировать. Например, при создании заказа в orders_db можно сохранить не только user_id, но и user_name.

    • Плюсы:

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

      • Независимость. Сервис заказов может работать, даже если сервис пользователей недоступен.

    • Минусы:

      • Проблемы с консистентностью. Что произойдет, если пользователь изменит свое имя? Нужно реализовать механизм (обычно через события), который обновит user_name во всех заказах.

      • Избыточность. Увеличивает объем хранимых данных.

  • Решение В: Создание материализованных представлений

    • Суть: Создать отдельную, денормализованную базу данных, оптимизированную для чтения. Специальный процесс (ETL) в фоне собирает данные из всех шардов.

    • Плюсы:

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

      • Разделение нагрузки. OLTP- и OLAP-нагрузки полностью изолированы.

    • Минусы:

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

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

Проблема №3 – Транзакционная целостность

Регистрация пользователя может требовать создания записи в auth_db и в profile_db. В монолите это одна ACID-транзакция. В распределенной системе — нет.

  • Решение А: Распределенные транзакции (XA / 2PC)

    • Суть: Использовать протокол двухфазного коммита (Two-Phase Commit), который координирует транзакции между несколькими базами.

    • Плюсы:

      • Гарантия ACID. Обеспечивает строгую консистентность.

    • Минусы:

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

      • Хрупкость. Координатор транзакций становится единой точкой отказа.

  • Решение Б: Паттерн Сага

    • Суть: Заменить одну распределенную транзакцию на последовательность локальных транзакций и событий. Если шаг N не удается, запускаются компенсирующие операции для шагов N-1, N-2 и т.д.

    • Плюсы:

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

      • Слабая связанность. Каждый сервис отвечает только за свою локальную транзакцию.

    • Минусы:

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

      • Итоговая консистентность (Eventual Consistency). Данные не всегда находятся в целостном состоянии.

Проблема №4 – Логика доступа к данным в приложении

Приложение больше не может просто использовать один DataSource. Оно должно знать, в какую базу идти за профилем, а в какую — за балансом.

  • Решение А: Логика в каждом сервисе

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

    • Плюсы:

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

    • Минусы:

      • Дублирование и жесткая связь. Конфигурация подключения размазана по всей системе.

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

  • Решение Б: Единый слой доступа к данным (DAL)

    • Суть: Создать общую библиотеку или сервис-репозиторий, который инкапсулирует всю логику маршрутизации. Приложение просто говорит: Дай мне пользователя с ID 123", а DAL уже сам решает, в какую базу пойти.

#include <iostream>
#include <string>
#include <memory>
#include <map>

class DbConnection { public: void query(const std::string& q) { std::cout << "Executing: " << q << std::endl; } };

struct UserCore { long id; std::string email; };
struct UserProfile { long id; std::string bio; };
struct FullUser { UserCore core; UserProfile profile; };

class UserRepository {
public:
    UserRepository(std::shared_ptr<DbConnection> core_db, std::shared_ptr<DbConnection> profile_db)
        : core_db_(core_db), profile_db_(profile_db) {}

    FullUser findById(long user_id) {
        std::cout << "Fetching user " << user_id << "..." << std::endl;

        core_db_->query("SELECT id, email FROM users_core WHERE id = " + std::to_string(user_id));
        UserCore core = {user_id, "user@example.com"}; 

        profile_db_->query("SELECT bio FROM users_profile WHERE user_id = " + std::to_string(user_id));
        UserProfile profile = {user_id, "Some bio text."};
        
        return {core, profile};
    }
private:
    std::shared_ptr<DbConnection> core_db_;
    std::shared_ptr<DbConnection> profile_db_;
};

int main() {
    auto core_connection = std::make_shared<DbConnection>();
    auto profile_connection = std::make_shared<DbConnection>();

    UserRepository user_repo(core_connection, profile_connection);
    
    FullUser user = user_repo.findById(123);
    std::cout << "Found user: " << user.core.email << " with bio: " << user.profile.bio << std::endl;
}
  • Решение В: Прокси-сервер баз данных

    • Суть: Использовать специализированный прокси (ProxySQL, Vitess), который стоит между приложением и базами. Приложение подключается к прокси, как к обычной базе.

    • Плюсы:

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

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

    • Минусы:

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

      • Ограниченность. Может не справиться с очень сложной логикой маршрутизации.

Проблема №5 – Миграция данных

У вас есть одна огромная таблица products на 1 ТБ. Как безопасно и без простоя перенести из нее колонки с картинками в отдельную базу products_content_db?

  • Решение А: Миграция с простоем (Downtime)

    • Плюсы:

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

    • Минусы:

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

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

    • Суть:

      1. Приложение начинает писать и в старую, и в новую таблицу.

      2. Запускается фоновый процесс, который копирует старые данные в новую таблицу.

      3. Когда процесс догонит текущие данные, приложение переключается на чтение из новой таблицы.

      4. После периода проверки, когда все работает стабильно, запись в старую таблицу отключается.

    • Плюсы:

      • Нулевой простой. Пользователи не замечают миграции.

    • Минусы:

      • Огромная сложность. Требует тщательного планирования, написания временного кода и постоянного контроля.

      • Риск. Много этапов, на которых что-то может пойти не так.

Проблема №6 – Мониторинг и отладка

Раньше у вас была одна база, один набор метрик. Теперь их десять. Запрос тормозит. Где именно? На auth_db, который перегружен запросами на логин, или на analytics_db, где крутится тяжелый отчет?

  • Решение А: Разрозненный мониторинг

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

    • Плюсы:

      • Простота. Используются стандартные инструменты (Zabbix, Prometheus Node Exporter).

    • Минусы:

      • Отсутствие общей картины. Вы видите, что один сервер нагружен, но не понимаете, почему и как это влияет на всю систему.

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

  • Решение Б: Единая платформа наблюдаемости (Observability)

    • Суть: Собирать метрики, логи и трейсы со всех баз данных и приложений в единую систему (Prometheus + Grafana, Datadog, New Relic).

    • Плюсы:

      • Целостное представление. Позволяет строить дашборды, которые показывают здоровье всей системы, а не отдельных ее частей.

      • Быстрая диагностика. С помощью распределенной трассировки можно увидеть, что медленный ответ API на 90% состоит из медленного запроса к inventory_db.

    • Минусы:

      • Требует инвестиций. Внедрение и поддержка такой платформы — это отдельный серьезный проект.

      • Инструментация. Все приложения должны быть инструментированы для отправки метрик и трейсов в едином формате.

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

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

  • Решение А: Независимые бэкапы

    • Суть: Каждая база бэкапится по своему собственному расписанию.

    • Плюсы:

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

    • Минусы:

      • Неконсистентность. Бэкап users_db сделан в 02:00, а orders_db — в 02:05. За эти 5 минут могли быть созданы заказы для пользователей, которых еще нет в бэкапе. Восстановление из таких бэкапов приведет к нарушению целостности.

  • Решение Б: Скоординированные снимки (Coordinated Snapshots)

    • Суть: Вы используете технологии, которые фотографируют состояние дисков всех серверов в один и тот же момент. Это могут быть LVM-снимки на уровне файловой системы или функции вашего облачного провайдера.

    • Плюсы:

      • Целостность данных. Вы получаете гарантию, что все восстановленные данные будут на один и тот же момент времени.

    • Минусы:

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

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

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

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

    • Плюсы:

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

    • Минусы:

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

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

Вам нужно добавить новую колонку в таблицу users_profile. А через неделю — переименовать колонку в billing_info. Управлять изменениями схемы в распределенной системе гораздо сложнее.

  • Решение А: Ручное управление

    • Суть: DBA или разработчик вручную подключается к каждой базе и применяет SQL-скрипты.

    • Плюсы:

      • Полный контроль.

    • Минусы:

      • Человеческий фактор. Огромный риск забыть применить скрипт к одной из баз.

      • Немасштабируемо. Работает для двух баз, но не для двадцати.

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

    • Суть: Вы используете инструменты, которые превращают изменения схемы в код. Они сами отслеживают, какие миграции уже применены к каждой базе, и накатывают только новые при запуске приложения.

    • Плюсы:

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

      • Воспроизводимость. Легко поднять новую базу и накатить на нее все миграции.

    • Минусы:

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

  • Решение В: Расширяемость без миграций (Schema-on-read)

    • Суть: Использовать NoSQL базы данных (MongoDB, Cassandra), которые не требуют строгой схемы.

    • Плюсы:

      • Гибкость. Идеально для быстро меняющихся данных.

    • Минусы:

      • Потеря контроля. Вся ответственность за консистентность данных ложится на код приложения.

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

Проблема №9 – Масштабирование отдельных шардов

Вы идеально разделили систему. Но внезапно одна часть — например, social_graph_db — начинает расти в 10 раз быстрее остальных и становится новым узким местом.

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

    • Суть: Просто докупить для сервера social_graph_db больше памяти, CPU и более быстрые диски.

    • Плюсы:

      • Простота. Не требует никаких изменений в коде или архитектуре.

    • Минусы:

      • Есть предел. Рано или поздно вы упретесь в потолок самого мощного доступного железа.

      • Стоимость. Большое железо стоит очень дорого.

  • Решение Б: Ре-шардирование (горизонтальное)

    • Суть: Признать, что social_graph_db сама стала слишком большой, и начать применять к ней уже горизонтальное шардирование — разрезать ее данные по user_id.

    • Плюсы:

      • Безграничное масштабирование. Можно добавлять новые серверы практически до бесконечности.

    • Минусы:

      • Максимальная сложность. Горизонтальное шардирование — это на порядок более сложная задача, чем вертикальное. Она порождает все те же проблемы, но в еще большем масштабе.

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

Чтобы запустить свою фичу, разработчику теперь нужно поднять на своей машине не одно приложение и одну базу, а пять сервисов и три разных базы данных.

  • Решение А: Поднять все (Docker Compose)

    • Суть: Использовать Docker Compose, чтобы одной командой развернуть все необходимое окружение локально.

    • Плюсы:

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

    • Минусы:

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

      • Медленный старт. Поднятие всего окружения может занимать минуты.

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

    • Суть: Разработчик запускает только свой сервис и его базу. Все внешние зависимости заменяются на заглушки.

    • Плюсы:

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

    • Минусы:

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

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

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

    • Суть: Вместо локального запуска использовать общее для команды, постоянно работающее тестовое окружение в облаке.

    • Плюсы:

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

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

    • Минусы:

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

      • Стоимость. Поддержание такого стенда стоит денег.

Проблема №11 – Управление соединениями

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

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

    • Суть: Каждый экземпляр приложения держит свой собственный пул соединений к каждой базе данных, с которой он работает.

    • Плюсы:

      • Простота. Это стандартный подход, поддерживаемый всеми фреймворками.

    • Минусы:

      • Connection storm. Если у вас 100 экземпляров приложения, и каждый должен говорить с 5 базами, вы можете легко исчерпать лимит соединений на стороне БД.

      • Дублирование конфигурации.

  • Решение Б: Внешний пулер соединений (PgBouncer, MaxScale)

    • Суть: Развертывается отдельный сервис-пулер. Приложения подключаются к нему, а он уже управляет ограниченным количеством реальных соединений к базам данных.

    • Плюсы:

      • Эффективность. Значительно снижает количество одновременных соединений к БД.

      • Централизация. Управление соединениями вынесено в один компонент.

    • Минусы:

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

      • Добавляет сетевую задержку.

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

  1. Жирная таблица с разнородной нагрузкой. Классический случай для разделения по колонкам. Выносите тяжелые BLOB-ы и редко используемые JSON-ы в отдельную таблицу.

  2. Разделение монолита на микросервисы. Здесь естественным выбором является разделение по функциональным группам. Границы сервисов должны совпадать с границами баз данных.

  3. Система с очень высокой нагрузкой на запись. Тут стоит подумать о разделении данных на горячие и холодные. Например, текущие транзакции хранить в быстрой OLTP-базе, а всю историю — в системе, оптимизированной для архивов.

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

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

  2. Проводите границу там, где меньше всего JOIN-ов. Анализируйте ваши запросы.

  3. Денормализация — ваш друг. В распределенном мире попытка сохранить полную нормализацию приводит к неработающей системе.

  4. Думайте о данных с точки зрения их жизненного цикла. Какие данные горячие, а какие холодные?

  5. Внедрите механизм асинхронных событий для поддержания консистентности между шардами.

  6. ID должны быть глобально уникальными. Забудьте про автоинкрементные int. Используйте UUID.

  7. Автоматизируйте миграции схемы с самого начала.

  8. Инвестируйте в observability. Без единой системы мониторинга вы будете слепы.

  9. Продумайте стратегию резервного копирования и восстановления до того, как записали первый байт в прод.

  10. Локальная разработка должна быть простой.

  11. Используйте централизованное хранилище секретов. Не храните пароли в коде или конфигах.

  12. Спланируйте управление пулами соединений. Не допустите шторма соединений.

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

Это всегда компромисс. Вы меняете простоту и строгую консистентность монолитной базы на производительность, масштабируемость и изоляцию. Этот шаг оправдан только тогда, когда боль от текущих проблем становится невыносимой, а выгода от разделения — очевидной. В противном случае, вы рискуете создать себе сложного, хрупкого и дорогого в поддержке монстра. И как всегда в архитектуре, главное — не знать решение, а точно понимать проблему.

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


  1. Gapon65
    25.10.2025 03:34

    Вы совершенно забыли о такой вещи как РЕПЛИКАЦИЯ. Эта технология определенно поможет Вам решить часть перечисленных проблем: https://www.mydbops.com/blog/postgresql-replication. Технология (репликации) также поддерживается MySQL/MariaDB.


  1. eigrad
    25.10.2025 03:34

    Спасибо за статью, хорошо собрали все связанные проблемы вместе. Вступление возможно стоит поправить, из него вообще не понятно что речь идёт о разнесении таблиц по разным базам - после прочтения вступления и первой секции возникла мысль написать "автор, ты чо, реально про нормальные формы не слышал?"