Doctrine ORM + шардирование: с чего начать строить сервис

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

Здесь разобран архитектурный сдвиг, который я для себя зафиксировал, и бандл elrise/doctrine-shard-manager-bundle как один из способов закрепить эти инварианты в коде. Реализация — contracts, стратегии, конфигурация, CLI, бенчмарки — описана на странице проекта.

Не сравнение подходов

Я не сравниваю разные способы шардирования. Здесь предложен один вариант: заложить в архитектуру проекта с первого коммита ключ, стратегию, routing и per-shard-операции. Бандл — готовая реализация этого подхода поверх Doctrine ORM и DBAL.

Почему шардирование — это решение на старте

Путь «сначала один EntityManager, потом шардирование» выглядит экономно. Через год таблица users — десятки миллионов строк, ещё через год — сотни миллионов. EXPLAIN показывает, что индекс уже не спасает, INSERT ... ON DUPLICATE KEY UPDATE упирается в один первичный ключ по всем записям, и команда идёт «делать шардирование». К этому моменту бизнес-логика уже насыщена findBy(['user_id' => ...]) в десятках мест, ID — автоинкремент, отчёты собирают данные «со всех мест», а JOIN между шардами уже не работает. Поменять стратегию — это переписать половину приложения, выкатить в прод и надеяться, что ничего не упало.

Другой путь — заложить четыре инварианта с самого начала:

Инвариант | Что это значит
Ключ шардирования | Выбирается при проектировании модели данных и не меняется. UUIDv7 с зашитым шардом или хеш от естественного PK (user-id, order-id).
Стратегия | Зафиксирована в коде и конфиге. Даже если физически шард сейчас один, routing уже работает.
Прозрачный routing | Бизнес-логика не знает, сколько шардов в системе. ShardedRepository или ShardContext::withEntity() сами выбирают нужный Connection и EntityManager.
First-class CLI | shard:add для нового шарда, shard:migrate для обновления схемы. Не shell-скрипты, о которых знает только девопс.

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

Прозрачный routing

Главный архитектурный сдвиг — это не введение шардов как таковых, а прозрачность для бизнес-логики. Разработчик пишет ShardedRepository::findOneById(), а бандл сам смотрит на конфигурацию User-класса, вычисляет шард по ключу (через одну из трёх стратегий), переключает активные Connection и EntityManager на время операции, и возвращает результат. Бизнес-логика не знает, что в проекте N шардов — она знает только, что работает с User.

Большинство самописных обёрток прячут шардинг внутри Repository, но требуют, чтобы вызывающий код знал, какой шард ему нужен. Бандл прячет шардинг так, что вызывающий код вообще не думает о шардах. Это и есть инвариант, который разворачивает всю остальную архитектуру: можно менять стратегию, добавлять шарды, переезжать в другую инфраструктуру, не трогая ни строчки в use-case-сервисах.

Граница с инфраструктурным движком

Шардирование решает задачу «куда и как положить данные». Бандл не делает bulk-операции, потоковое чтение, advisory-locks и тонкий SQL-контроль — это задача соседнего elrise/dbal-bundle. Разделение ролей:

elrise/doctrine-shard-manager-bundle — архитектура данных: какой шард, какая стратегия, какая конфигурация, как добавить новый шард, как накатить миграцию. Это карта данных.

elrise/dbal-bundle — инфраструктурное взаимодействие: bulk-insert/update/upsert, потоковое чтение через генераторы, advisory-locks, SELECT ... FOR UPDATE, caller-trace, PSR-3. Это движок для bulk-операций.

Гибридная архитектура получается естественно: ORM остаётся mapping-системой для описания структуры и CRUD-логики, шардовый routing идёт поверх ORM, DBAL закрывает инфраструктурные задачи внутри шарда. Бизнес-логика при этом не меняется — она по-прежнему работает с сущностями.

Что бандл решает и что нет

Закрывает: routing Connection и EntityManager per shard, три reference-стратегии (hash, range, uuid), PSR-6 кэш с graceful degradation, конфиг через #[Sharding] атрибут или YAML, cross-shard чтение через ShardedFinder::find(), shard:add для greenfield и shard:migrate для additive.

Не делает:

middleware-уровень (Vitess, ProxySQL, Citus) — это application-level routing, а не SQL-aware proxy;

кросс-шардовые транзакции (XA, 2PC) — по дизайну, как anti-pattern;

автоматический live-решардинг — добавление пятого шарда к четырём остаётся ручной операцией;

Doctrine native sharding (ShardFilter и т.п.) — это альтернативный контракт с собственной совместимостью.

Когда брать, а когда не брать

Брать, если:

таблица переросла шарды одного сервера БД, и нужно горизонтальное разделение;

уже используется Doctrine ORM/DBAL и не хочется выбрасывать его ради шардинга;

есть детерминированный ключ шардирования, по которому запись всегда попадает на один шард;

вы готовы управлять шардами как first-class-сущностями: provisioning, per-shard миграции, мониторинг.

Не брать, если:

одна БД и вертикальное масштабирование ещё не исчерпано — шардинг это сложность, и она не нужна раньше времени;

нет естественного ключа для шардирования — никакой бандл не поможет, пока это архитектурное решение не принято;

нужны кросс-шардовые транзакции — они не покрываются бандлом и не должны;

нужна live-решардизация — миграция данных между шардами без даунтайма остаётся отдельным проектом.

Где смотреть реализацию

API, контракты, стратегии hash/range/uuid, конфигурация, CLI-команды, бенчмарки на трёх вендорах и known issues — на странице проекта. В dummy-market-agent можно поднять reference Symfony-сервис через Docker и посмотреть, как routing, ORM и DBAL-уровень собираются в одном приложении.

Итог

Шардирование — это не оптимизация поверх существующего сервиса. Это архитектурное решение, которое ложится в самый первый коммит, иначе через год придётся переписывать половину. Бандл elrise/doctrine-shard-manager-bundle — один из способов формализовать эти инварианты в коде: ключ, стратегия, прозрачный routing, first-class CLI для шардов.

Это не замена Doctrine ORM/DBAL и не ответ на вопрос «что делать, когда таблица уже выросла». Это стартовая база для архитектуры, в которой рост данных не станет катастрофой.

Полезные ссылки:

doctrine-shard-manager-bundle: реализация, контракты, бенчмарки

Doctrine ORM Sharding — для контекста

UUIDv7 (RFC 9562) — формат ключа для uuid-стратегии

Doctrine ORM и Doctrine DBAL — то, поверх чего работает бандл

Doctrine Migrations — для shard:migrate

PSR-6: Caching Interface — мемоизация стратегий

PSR-3: Logger Interface — structured-логи резолвинга

Doctrine Migrations Bundle для Symfony — базовая интеграция