Обзор
Doctrine Shard Manager — Symfony-бандл для horizontal data sharding поверх Doctrine ORM и DBAL. Бандл берёт на себя маршрутизацию Connection и EntityManager на каждый шард, даёт pluggable-стратегии (hash, range, uuid с UUIDv7), опциональный PSR-6 кэш для резолва и CLI для greenfield provisioning и additive migrations.
Бандл решает application-level sharding — routing-логика живёт в PHP, без отдельного прокси-слоя между приложением и СУБД.
Архитектурная позиция этого слоя — почему шардирование это решение на старте, какие инварианты закладываются в первый коммит и как бандл стыкуется с DBAL-инфраструктурой — описана в заметке Doctrine ORM + шардирование: с чего начать строить сервис.
Быстрый старт
Минимальный сценарий: одна сущность, один шард, атрибут #[Sharding]. Этого достаточно, чтобы проверить, что интеграция работает. Сетапы с N шардами, range-таблицами и uuid — в §Конфигурация.
composer require elriseio/doctrine-shard-manager-bundle
use Elrise\Bundle\DoctrineShardManager\Attribute\Sharding;
#[Sharding(
strategy: 'hash',
key: 'userId',
shardCount: 1,
)]
class User
{
// ...
}
# config/packages/doctrine_shard.yaml
doctrine_shard:
sharding_configs:
App\Entity\User:
strategy: hash
key: userId
shardCount: 1
connections:
shard_0: '@doctrine.dbal.default_shard_0_connection'
entity_managers:
shard_0: '@doctrine.orm.default_shard_0_entity_manager'
final class UserService
{
public function __construct(private ShardedRepository $userRepo) {}
public function load(int $userId): ?User
{
return $this->userRepo->findOneById($userId, User::class);
}
}
Полные требования, несколько шардов и все три стратегии — в §Конфигурация.
Что это и чем не является
Бандл делает:
- маршрутизирует
ConnectionиEntityManagerпо шардам по#[Sharding]атрибуту или YAML-конфигурации (sharding_configs.<FQCN>); - даёт три pluggable-стратегии:
hash(SHA-256 modulo),range(binary search поrange_table),uuid(UUIDv7 с embedded shard index); - поддерживает composite-ключи:
key: ['tenantId', 'userId']работает поверх любой стратегии; - кэширует резолв через PSR-6 (graceful degradation — сбой кэша логируется и обходится, не пробрасывается);
- переключает активный
Connection/EntityManagerв scopeShardContext::withShard()/withEntity()callback, восстанавливает предыдущий routing вfinally; - отдаёт
ShardedFinder::find()для кросс-шардовых чтений через\Generator(memory bound = max одного шарда); - регистрирует
bin/console shard:add <id>(greenfield provisioning) иbin/console shard:migrate <id>(additive миграции); - типизирован strict types, рассчитан на PHP 8.3+.
Бандл не делает:
- не разворачивает middleware-прокси (Vitess, ProxySQL, Citus) — это application-level routing, не SQL-aware proxy;
- не поддерживает cross-shard транзакции (XA, 2PC) по дизайну — см. §Известные ограничения;
- не решает re-sharding автоматически: добавить пятый шард к четырём — ручная операция, консистентность ключей не гарантируется;
- не подменяет Doctrine native sharding (
ShardFilterи т. п.) — это альтернативный, более полный контракт с собственными интерфейсами; - не поставляет UI / observability из коробки — метрики шардинга наружу не экспонируются;
- не версионирует
range_tableчерез миграции Doctrine — таблица живёт в БД, обновляется руками (см. RUNBOOK).
Требования
| Компонент | Минимум |
|---|---|
| PHP | 8.3 (strict types) |
| Symfony | 7.2 (7.2.* pin в composer.json) |
| Doctrine DBAL | 4.2+ |
| Doctrine ORM | 3.3+ |
| Doctrine Migrations | 3.7+ (для shard:migrate) |
PSR-6 CacheItemPoolInterface |
опционально, рекомендуется для горячего пути |
PSR-3 LoggerInterface |
опционально, graceful degradation при отсутствии |
PHP-расширения pdo_mysql и/или pdo_pgsql подключаются вручную (см. §Известные проблемы). Docker-стек интеграционных тестов поставляет MySQL 8.4, MariaDB 10.11, PostgreSQL 16.
Архитектура
+--------------------------+
| Symfony Application |
| (consumer code) |
+------------+-------------+
|
использует интерфейсы из src/Contract/
|
+------------v-------------+
| ShardContext / |
| ShardedRepository |
| ShardedFinder (read) |
+------------+-------------+
|
+------------v-------------+
| ShardResolver | <-- выбор стратегии, извлечение ключа
+------------+-------------+
|
+------------v-------------+ +-----------------------+
| ShardStrategy (IF) | <--> | Hash / Range / UUID |
+--------------------------+ +-----------------------+
|
+------------v-------------+
| ShardConnectionManager | <-- DBAL Connection per shard
+------------+-------------+
|
+------------v-------------+
| EntityManagerProxy | <-- ORM EntityManager per shard
+------------+-------------+
|
+------------v-------------+
| Doctrine DBAL / ORM |
+--------------------------+
Слои и правила зависимостей
Contract/*— единственные типы, от которых зависит consumer code. Конкретные реализации internal, не тегать в consumer-конфиге.Strategy/*зависит только отContract/ShardStrategyInterfaceиConfig/Dto/*.Resolver/*зависит только отContract/*,Attribute/*и инстансовStrategy/*через DI.Context/*оркестрируетResolver,ConnectionManager,EntityManagerProxyи сам к Doctrine не обращается.Manager/*— тонкий Doctrine-binding:ShardConnectionManagerоборачивает lookupConnection,EntityManagerProxy—EntityManager.Repository/*— фасад: композицияShardContext+ShardResolver. Бизнес-код получает те же сигнатуры, что и вEntityRepository.
Ключевые интерфейсы
| Интерфейс | Назначение | Селекторы |
|---|---|---|
ShardStrategyInterface |
Pluggable seam: resolveShardId(), getTotalShards(), resolveShardIndex(), getCacheKey() |
hash, range, uuid |
AbstractShardStrategy |
База с тремя protected хелперами (normalizeKey, defaultCacheKey, resolveShardIndexFromId); override публичных методов для кастомных стратегий |
— |
ShardResolverInterface |
Мерж YAML + #[Sharding]; resolveShardId(entity), resolveShardIdFromId(id, fqcn), resolveShardIdFromCriteria(criteria, fqcn), getShardingConfig(fqcn) |
YAML > attribute (см. ADR-0002) |
ShardContextInterface |
State machine: withShard($shardId, $cb), withEntity($entity, $cb), getCurrentShardId(), getAvailableShards() |
restore в finally |
ShardConnectionManagerInterface |
Реестр shardId → Connection: switchToShard(), getCurrentConnection(), getConnectionForShard() |
WeakMap-кэш на lookup |
EntityManagerProxyInterface |
Реестр shardId → EntityManager: forShard(), getAvailableShards() |
— |
ShardedRepositoryInterface |
Фасад: findOneById, findBy, findOneBy, persistToShard, removeFromShard, flushShard |
Бизнес-код не меняется |
ShardedFinder (concrete) |
Cross-shard reads: find(callable $query, list<string> $shardIds): \Generator |
Sequential в v1 (ADR-0004 §Decision) |
ShardResolverListener помечен @deprecated и удалится в 2.0; новый код работает через ShardedRepository или ShardContext напрямую.
Установка
composer require elriseio/doctrine-shard-manager-bundle
Регистрация бандла в config/bundles.php (если Symfony Flex не подключён):
return [
Elrise\Bundle\DoctrineShardManager\ElriseDoctrineShardBundle::class => ['all' => true],
];
Бандл автоматически регистрирует шесть публичных сервисов; дополнительной wiring в services.yaml для дефолтных стратегий не требуется.
Конфигурация
Полный пример для 4-шардового hash-сетап по userId:
# config/packages/doctrine_shard.yaml
doctrine_shard:
cache_ttl: 86400
shard:
hash:
total_shards: 4
shard_index_offset: 0
shard_prefix: 'shard_'
range:
shard_prefix: 'shard_'
shard_index_offset: 0
range_table:
- { min: 0, max: 999_999, shard: 'shard_0' }
- { min: 1_000_000, max: 1_999_999, shard: 'shard_1' }
uuid:
total_shards: 16
shard_index_offset: 0
shard_prefix: 'shard_'
shard_bits: 4
sharding_configs:
App\Entity\User:
strategy: hash
key: userId
shardCount: 4
App\Entity\Order:
strategy: uuid
key: id
shardCount: 16
resolver:
cache:
enabled: true
pool_service_id: cache.app
connections:
shard_0: '@doctrine.dbal.default_shard_0_connection'
shard_1: '@doctrine.dbal.default_shard_1_connection'
shard_2: '@doctrine.dbal.default_shard_2_connection'
shard_3: '@doctrine.dbal.default_shard_3_connection'
entity_managers:
shard_0: '@doctrine.orm.default_shard_0_entity_manager'
shard_1: '@doctrine.orm.default_shard_1_entity_manager'
shard_2: '@doctrine.orm.default_shard_2_entity_manager'
shard_3: '@doctrine.orm.default_shard_3_entity_manager'
doctrine.dbal.default_shard_<N>_connection и doctrine.orm.default_shard_<N>_entity_manager — стандартные Doctrine service ID, которые Symfony-бандл doctrine отдаёт при объявлении в config/packages/doctrine.yaml:
doctrine:
dbal:
connections:
default_shard_0: ~
default_shard_1: ~
default_shard_2: ~
default_shard_3: ~
orm:
entity_managers:
default_shard_0: ~
default_shard_1: ~
default_shard_2: ~
default_shard_3: ~
Альтернатива: #[Sharding] атрибут
use Elrise\Bundle\DoctrineShardManager\Attribute\Sharding;
#[Sharding(
strategy: 'hash',
key: 'userId',
shardCount: 4,
)]
class User
{
// ...
}
Composite-ключи — массив в key:
#[Sharding(
strategy: 'hash',
key: ['tenantId', 'userId'],
shardCount: 4,
)]
class User
{
// ...
}
YAML-sharding_configs.<FQCN> перекрывает атрибут, если оба присутствуют (см. ADR-0002, docs/architecture.md::I-RES1). Поле sharding_configs всегда обязательно, даже если используется только атрибут: пустой [] — канонический declaration.
Использование
Через ShardContext
use Elrise\Bundle\DoctrineShardManager\Context\ShardContext;
final class UserService
{
public function __construct(private ShardContext $shardContext) {}
public function loadFromShard(int $userId): ?User
{
return $this->shardContext->withEntity(
$this->buildStubUser($userId),
function (EntityManagerInterface $em, string $shardId) use ($userId): ?User {
return $em->getRepository(User::class)->find($userId);
},
);
}
}
withShard($shardId, $cb) — явный вариант, когда caller уже знает целевой шард (например, cron-job, итерирующий все шарды).
Через ShardedRepository
use Elrise\Bundle\DoctrineShardManager\Repository\ShardedRepository;
final class UserController
{
public function __construct(private ShardedRepository $userRepo) {}
public function show(int $userId): Response
{
$user = $this->userRepo->findOneById($userId, User::class);
if (null === $user) {
throw new NotFoundHttpException();
}
return new Response(sprintf('Hello, %s!', $user->getDisplayName()));
}
}
Поверхность: findBy, findOneBy, persistToShard, removeFromShard, flushShard — shard ID резолвится внутри, наружу выходит тот же контракт, что у EntityRepository.
Cross-shard чтения через ShardedFinder
use Elrise\Bundle\DoctrineShardManager\Finder\ShardedFinder;
final class ReportingService
{
public function __construct(private ShardedFinder $finder) {}
public function streamAllActiveUsers(): \Generator
{
$shardIds = ['shard_0', 'shard_1', 'shard_2', 'shard_3'];
return $this->finder->find(
static fn (Connection $c) => $c->iterateAssociative(
'SELECT * FROM users WHERE active = 1 ORDER BY id',
),
$shardIds,
);
}
}
ShardedFinder в v1 sequential (ADR-0004 §Decision). Generator держит по одной строке на fan-out, так что memory bound = максимум одного шарда. На первом shard-fail бросает с per-shard context в сообщении.
Кастомная стратегия
# config/services.yaml
services:
App\Infrastructure\Sharding\TenantPrefixedStrategy:
tags:
- { name: app.shard_strategy, alias: tenant }
use Elrise\Bundle\DoctrineShardManager\Strategy\AbstractShardStrategy;
final class TenantPrefixedStrategy extends AbstractShardStrategy
{
#[\Override]
public function resolveShardId(mixed $key, array $options = []): ?string
{
$tenantId = $this->normalizeKey($key);
return 'tenant_'.$tenantId;
}
}
Использование: strategy: tenant в sharding_configs.<FQCN> или в #[Sharding(strategy: 'tenant', ...)]. Полный worked example — docs/adr/0002-shard-strategy-extensibility.md.
Console-команды
Бандл регистрирует две команды в build().
bin/console shard:add <id>
Greenfield provisioning: фильтрует entity-метаданные до #[Sharding]-аннотированных классов и запускает SchemaTool::updateSchema($filteredMetadata, saveMode: true).
bin/console shard:add shard_3
Внимание.
shard:add— это drop-and-recreate, не additive migration. Запускайте только на greenfield шардах — на существующем шарде удалит все строки. Для существующих шардов используйтеbin/console shard:migrate <id>(Wave 2 / ADR-0003). Полная migration-процедура —docs/RUNBOOK.md::Failure Mode 10.
bin/console shard:migrate <id>
Additive: запускает Doctrine Migrations на одном именованном шарде.
bin/console shard:migrate shard_3
bin/console shard:migrate shard_3 --dry-run
bin/console shard:migrate shard_3 --migration-set=latest
Стандартные --dry-run, --migration-set=<alias-or-version>, exit-codes наследуются от Doctrine Migrations.
Совместимость
| Компонент | Минимальная версия |
|---|---|
| PHP | 8.3 (strict types) |
| Symfony | 7.2 (7.2.* pin) |
| Doctrine DBAL | 4.2+ |
| Doctrine ORM | 3.3+ |
| Doctrine Migrations | 3.7+ |
PSR-6 CacheItemPoolInterface |
опционально, рекомендуется |
PSR-3 LoggerInterface |
опционально, graceful degradation |
Известные пробелы в совместимости
doctrine/event-subscriberпуть (замена deprecatedShardResolverListener) — в плане на Wave 1. До тех пор listener помечен@deprecated, но зарегистрирован вservices.yamlдля backward compatibility.- Трёхуровневая testing-инфраструктура (unit + bench + itests, ADR-0001) высажена частично.
bench/иitests/populated, bounded-concurrency CI smoke (DE-011-B) открыт и трекается вIssues/open/developer/. phpstanиphp-cs-fixerобъявлены вrequire-dev, но CI-gated enforcement job (Wave 6 /DE-019) открыт.
Бенчмарки
PHPBench-сьют в bench/ покрывает hot path каждой публичной стратегии и оркестратора (см. ADR-0001). Без БД по умолчанию; subjects, требующие Connection, опциональны через BENCH_DATABASE_URL.
Запуск:
composer bench
# или с фильтром:
php vendor/bin/phpbench run --report=aggregate --filter=ResolveCacheHit
HTML-отчёт и memory-сэмплы при локальном запуске пишутся в bench/.bench/ (gitignored); воспроизводимые цифры — в таблице выше.
Последний прогон (2026-07-21, PHP 8.5.8 NTS, xdebug off, opcache off)
20 subjects, 0 failures, 0 errors. mode — per-subject median, rstdev — relative standard deviation по итерациям.
| Subject | Mode | Rstdev | Что меряет |
|---|---|---|---|
HashShardStrategyBench::benchResolveCacheMiss |
78.063 ms | ±1.39 % | SHA-256 modulo, холодный PSR-6 |
HashShardStrategyBench::benchResolveCacheHit |
188.438 ms | ±19.48 % | тёплый PSR-6; высокая variance из-за pool overhead |
HashShardStrategyBench::benchResolveKeyTypes |
102.106 ms | ±5.85 % | смешанные типы ключа (string / int / UUID) |
RangeShardStrategyBench::benchResolveHot |
74.523 ms | ±3.59 % | binary search по range_table, in-memory |
RangeShardStrategyBench::benchResolveCold |
659.241 ms | ±1.42 % | range_table парсится на каждом вызове (worst case) |
UuidShardStrategyBench::benchResolve |
74.659 ms | ±6.54 % | UUIDv7 parse + shard-bit extraction |
UuidShardStrategyBench::benchGenerateUuid |
181.993 ms | ±6.09 % | UUIDv7 generation с embedded shard index |
ShardResolverBench::benchResolveFromEntity |
34.833 µs | ±36.84 % | reflection + attribute lookup, высокая variance |
ShardResolverBench::benchResolveFromId |
16.997 µs | ±9.80 % | direct key path |
ShardResolverBench::benchResolveFromCriteria |
26.667 µs | ±11.25 % | composite-ключ extraction |
ShardConnectionManagerBench::benchSwitchToShard4 |
8.496 µs | ±21.57 % | 4 шарда |
ShardConnectionManagerBench::benchSwitchToShard16 |
7.833 µs | ±6.38 % | 16 шардов |
ShardConnectionManagerBench::benchSwitchToShard64 |
11.333 µs | ±8.82 % | 64 шарда |
ShardConnectionManagerBench::benchGetConnectionForShard4 |
1.000 µs | ±0.00 % | 4 шарда |
ShardConnectionManagerBench::benchGetConnectionForShard16 |
1.000 µs | ±0.00 % | 16 шардов |
ShardConnectionManagerBench::benchGetConnectionForShard64 |
0.833 µs | ±20.00 % | 64 шарда |
ShardContextBench::benchWithShard |
13.167 µs | ±3.80 % | callback wrap, без Doctrine round-trip |
ShardContextBench::benchWithEntity |
10.331 µs | ±12.90 % | то же для withEntity |
ShardedRepositoryBench::benchFindOneById |
388.528 µs | ±3.65 % | end-to-end: resolve + DBAL fetch |
ShardedFinderBench::benchFanOutOver16Shards1kRowsEach |
2.036 ms | ±13.71 % | 16-шардовый fan-out, 1 000 строк на шард |
benchResolve* — sub-millisecond на вызов. benchSwitchToShard* и benchGetConnectionForShard* — амортизированы до ~1 µs за счёт WeakMap-кэша. benchFindOneById — единственный end-to-end subject, проходящий через реальный EntityManager; значение доминировано Doctrine hydration. benchFanOutOver16Shards1kRowsEach — regression-guard для fan-out state machine из ADR-0004.
Re-run обязателен после изменений в src/Strategy/*, src/Resolver/*, src/Manager/* или src/Context/*; таблица обновляется, если subject регрессировал больше, чем на свой опубликованный rstdev.
Интеграционные тесты
itests/ — третий tier testing-инфраструктуры (ADR-0001): end-to-end сценарии против реальных MySQL 8.4, MariaDB 10.11 и PostgreSQL 16 под контролируемой concurrency, с фиксированным JSON-envelope. Сценарии говорят с DBAL напрямую — Symfony-app в пакете нет.
Подготовка
make itests-up # docker compose up -d --wait (mysql 8.4 + mariadb 10.11 + postgres 16)
bash itests/bin/migrate-up.sh
Дефолтные порты смещены (33061 MySQL, 33062 MariaDB, 54321 PostgreSQL) — стек сосуществует с dbal-manager проектом на одном хосте. Credentials: root:itests для MySQL/MariaDB, itests:itests для PostgreSQL.
Запуск сценария
php itests/scenarios/<name>.php \
--dsn="mysql://root:itests@127.0.0.1:33061/itests" \
--vendor=mysql --rows=200 --chunk=50 --warmup --reset
Каждый сценарий печатает одну JSON-envelope строку в stdout с scenario, db_vendor, rows, chunk, duration_s, ops_per_sec, peak_rss_bytes, errors. Полный envelope (включая DB-счётчики и latency-процентили) — через itests/bin/run-scenario.sh; контракт CI-smoke — itests/README.md.
Последний прогон (2026-07-22, PHP 8.5.8 NTS, rows=200, chunk=50, warmup+reset)
Все 12 сценариев прошли на MySQL, MariaDB и PostgreSQL.
| Scenario | MySQL 8.4 | MariaDB 10.11 | PostgreSQL 16 |
|---|---|---|---|
shard_resolution_hash |
✅ 0.307 s, 650 ops/s, 0 errors | ✅ 0.590 s, 339 ops/s, 0 errors | ✅ 0.695 s, 287 ops/s, 0 errors |
shard_resolution_range |
✅ 1.945 s, 102 ops/s, 0 errors | ✅ 0.578 s, 345 ops/s, 0 errors | ✅ 0.702 s, 284 ops/s, 0 errors |
shard_resolution_uuid |
✅ 1.977 s, 101 ops/s, 0 errors | ✅ 0.584 s, 342 ops/s, 0 errors | ✅ 0.749 s, 267 ops/s, 0 errors |
shard_resolution_custom_strategy |
✅ 1.993 s, 100 ops/s, 0 errors | ✅ 0.584 s, 342 ops/s, 0 errors | ✅ 0.659 s, 303 ops/s, 0 errors |
bulk_write_per_shard (rows=400) |
✅ 1.605 s, 249 ops/s, 0 errors | ✅ 0.139 s, 2881 ops/s, 0 errors | ✅ 0.480 s, 833 ops/s, 0 errors |
cross_shard_lookup |
✅ 200 rows, 0 errors (re-run clean) | ✅ 200 rows, 0 errors | ✅ 200 rows, 0 errors (re-run clean) |
shard_resolution_* — hot path под реальным DB I/O. MySQL быстрее на hash-варианте, PostgreSQL — на range и custom strategy, MariaDB — между ними с низкой variance по всем четырём. bulk_write_per_shard — write-heavy workload с fan-out routing-table: MariaDB лидирует (~2881 ops/s, ~11.5× быстрее MySQL на том же workload), PostgreSQL ~3.3× быстрее MySQL. cross_shard_lookup — проверяет, что shard ID, вычисленный на write-стороне, совпадает с тем, что вычислен на read-стороне для каждого fan-out шарда; контракт — itests/README.md.
Потрогать руками
dummy-market-agent — reference Symfony-сервис с Application Layer, CQRS, API Platform и DBAL-based persistence. Репозиторий запускается через Docker; в нём доступны API-операции, command/query handlers, persistence, shard-routing, container wiring, границы слоёв и тесты.
Известные ограничения и проблемы
Известные проблемы
itests/bin/run-scenario.shнерабочий as-shipped. Wrapper передаёт--dsn "<value>"(через пробел) в underlying PHP CLI, а вrunner_base.phpобъявленgetopt('', ['dsn::', …]), что на PHP 8.5 тихо теряет значение для long options с optional argument. Wrapper печатает"scenario did not emit JSON"(rc=2) и выходит. Workaround: запускать scenario-скрипт напрямую с--dsn=<value>(через=). Fix: либо сменить'dsn::'на'dsn:'вitests/runner/runner_base.php:52, либо переделать wrapper на передачу DSN через env.pdo_mysql/pdo_pgsqlне подключаются автоматически. В runtime-образе.soлежат в/usr/lib/php/modules/, но/etc/php/conf.d/пуст. Workaround:PHP_INI_SCAN_DIR=~/.php-confdс однойextension=<name>.soстрокой на файл, либо установить дистро-пакеты (php-mysql,php-pgsql).composer benchнерабочий as-shipped. Вphpbench.jsonне объявленrunner.path, поэтомуcomposer benchпадает с "You must either specify or configure a path". Workaround: передатьbench/явно или добавить"runner.path": "bench"вphpbench.json.
Известные пробелы в совместимости
См. §Совместимость. ShardResolverListener помечен @deprecated (удалится в 2.0), трёхуровневая testing-инфраструктура высажена частично, CI-gate для phpstan/php-cs-fixer в плане (Wave 6 / DE-019).
Источники
- github.com/elriseio/doctrine-shard-manager-bundle — MIT.
- Packagist: elriseio/doctrine-shard-manager-bundle
- Doctrine ORM Sharding — для контекста
- ADR-0001, ADR-0002, ADR-0003, ADR-0004 — в
docs/adr/репозитория