user@elrise.io:~/doctrine-shard-manager-bundle
· [active]

doctrine-shard-manager-bundle — Doctrine sharding для Symfony

→ репозиторий

Обзор

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);
    }
}

Полные требования, несколько шардов и все три стратегии — в §Конфигурация.

Что это и чем не является

Бандл делает:

Бандл не делает:

Требования

Компонент Минимум
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     |
             +--------------------------+

Слои и правила зависимостей

Ключевые интерфейсы

Интерфейс Назначение Селекторы
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

Известные пробелы в совместимости

Бенчмарки

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, границы слоёв и тесты.

Известные ограничения и проблемы

Известные проблемы

Известные пробелы в совместимости

См. §Совместимость. ShardResolverListener помечен @deprecated (удалится в 2.0), трёхуровневая testing-инфраструктура высажена частично, CI-gate для phpstan/php-cs-fixer в плане (Wave 6 / DE-019).

Источники