user@elrise.io:~/dbal-bundle
· [active]

dbal-bundle — DBAL-менеджер для highload-проектов на Symfony

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

Обзор

Dbal Bundle — Symfony-бандл для high-load систем, где стандартные возможности Doctrine ORM становятся узким местом. Бандл даёт абстракции и интерфейсы для прямой, эффективной и масштабируемой работы с базой на уровне Doctrine DBAL: bulk insert/update/upsert, cursor-стриминг, маппинг в DTO, advisory locks и верифицированную cross-vendor производительность на MySQL 8, MariaDB 10.5+ и PostgreSQL 12+.

Бандл не подменяет Doctrine ORM — он работает рядом: ORM остаётся на своей территории (Entity, миграции, CRUD), бандл закрывает инфраструктурные задачи (bulk, стриминг, read-modify-write с явным concurrency-контролем).

Архитектурное обоснование этого слоя — какие задачи ORM не тянет и где заканчивается его зона ответственности — см. Почему high-load Symfony нуждается в отдельном DBAL-слое рядом с ORM.

Быстрый старт

Минимальный сценарий: установить бандл, зарегистрировать и выполнить bulk insert через фабрику.

composer require elriseio/dbal-bundle
// config/bundles.php
return [
    Elrise\Bundle\DbalBundle\ElriseDbalBundle::class => ['all' => true],
];
class MyService
{
    public function __construct(private DbalManagerFactory $factory) {}

    public function insertUsers(array $emails): void
    {
        $inserter = $this->factory->createBulkInserter();
        $now = date('Y-m-d H:i:s');

        $inserter->insert('users', array_map(
            static fn (string $email) => ['email' => $email, 'created_at' => $now],
            $emails,
        ));
    }
}

Полная конфигурация, все bulk-операции, concurrency-хелперы и верифицированные таблицы производительности — ниже.

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

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

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

Совместимость

Компонент Версия
PHP 8.3+ (strict types)
Symfony 7.2+
Doctrine DBAL 4.2+ (verified on 4.4.x)
MySQL 8.0+ (verified on 8.0, 8.4 LTS)
MariaDB 10.5+ (verified on 10.6, 10.10, 10.11, 11.0.7)
PostgreSQL 12+ (test fixtures target 16; 17 expected compatible)
PSR-3 1.x или 3.x (заявлено psr/log: ^3.0 в composer.json)

CI-гейт: scripts/check_interface_namespace_imports.sh отклоняет legacy Enum\*Interface импорты.

Архитектура

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

В основе select-операций лежат генераторы (yield), что позволяет:

Основные интерфейсы

Finder / Mutator

Bulk-операции

Итераторы

Хелперы

Установка

composer require elriseio/dbal-bundle

Зарегистрируйте бандл в config/bundles.php:

return [
    Elrise\Bundle\DbalBundle\ElriseDbalBundle::class => ['all' => true],
];

Работа с DbalManagerFactory

Класс DbalManagerFactory позволяет удобно создавать компоненты DBAL-инфраструктуры с возможностью переопределения подключения (Connection) и конфигурации (DbalBundleConfig) на уровне каждого сервиса.

Быстрое создание DbalManager

Если хотите использовать все DBAL-компоненты сразу — достаточно createManager():

$dbalManager = $factory->createManager();

С кастомными Connection и DbalBundleConfig:

$dbalManager = $factory->createManager($customConnection, $customConfig);

Создание отдельных компонентов

Если нужен один из компонентов отдельно — используйте соответствующий метод:

$finder         = $factory->createFinder(...);
$mutator        = $factory->createMutator(...);
$cursorIterator = $factory->createCursorIterator(...);
$offsetIterator = $factory->createOffsetIterator(...);
$bulkInserter   = $factory->createBulkInserter(...);
$bulkUpdater    = $factory->createBulkUpdater(...);
$bulkUpserter   = $factory->createBulkUpserter(...);
$bulkDeleter    = $factory->createBulkDeleter(...);

Для каждого метода можно указать собственный Connection и (опционально) DbalBundleConfig. Особенно полезно при работе с несколькими БД или разными стратегиями конфигурации.

Пример в сервисе

class MyService
{
    public function __construct(private DbalManagerFactory $factory) {}

    public function updateBulkData(array $rows): void
    {
        $bulkUpdater = $this->factory->createBulkUpdater();
        $bulkUpdater->update('my_table', $rows);
    }
}

Bulk Insert

Модуль поддерживает массовую вставку с возможностью указать:

Пример

/** @var BulkInserterInterface $inserter */
$inserter->insert('user_table', [
    [
        'id' => IdStrategy::AUTO_INCREMENT, // ID сгенерируется в БД
        'email' => ['user1@example.com', ParameterType::STRING],
        'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
    ],
    [
        'id' => IdStrategy::UUID, // ID сгенерируется в коде
        'email' => ['user2@example.com', ParameterType::STRING],
        'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
    ],
    [
        // ID сгенерируется в коде (UUIDv7 по умолчанию)
        'email' => ['user3@example.com', ParameterType::STRING],
        'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
    ],
]);

Массив ['value', ParameterType::TYPE] задаёт тип значения, совместимый с Doctrine\DBAL\ParameterType. Если тип не указан — он определяется автоматически.

Стратегии генерации ID (IdStrategy)

Стратегия Описание
IdStrategy::AUTO_INCREMENT Значение не задаётся — генерируется на уровне БД.
IdStrategy::UUID Значение генерируется в коде (UUIDv7). Default для нового кода.
IdStrategy::UID Deprecated с 1.0.x из-за коллизий; остаётся в 2.0 (per ADR-0002 § Decision 3). 18-символьный id склонен к коллизиям при конкурентной записи. Для нового кода предпочтительнее IdStrategy::UUID (UUIDv7); существующим пользователям IdStrategy::UID не нужно мигрировать, чтобы обновиться до 2.0.
IdStrategy::INT Случайное целое.
IdStrategy::STRING Строка (например, на основе uniqid()).
IdStrategy::DEFAULT Значение используется для работы с Postgres и генерации DEFAULT ID в Insert/Upsert.
IdStrategy::migrateFromV1() Статический хелпер. Маппит v1-кейсы (INT, STRING) на их v2-замены per ADR-0002 § Decision 3. Доступен в 1.x как DX-утилита для потребителей, планирующих апгрейд на 2.0. Кейсы, переживающие 2.0 (AUTO_INCREMENT, UUID, UID, DEFAULT), проходят без изменений.

DbalBulkUpdater

DbalBulkUpdater обновляет от одной до многих строк в базе.

Пример

$bulkUpdater
    ->updateMany('api_history', [
        ['id' => 1, 'status' => 'success'],
        ['id' => 2, 'status' => 'success'],
    ]);

По умолчанию условие — поле id. Обновление идёт через CASE WHEN ... THEN ... одним запросом, без множественных round-trip'ов. Возвращается количество затронутых строк.

DbalBulkUpserter

DbalBulkUpserter вставляет или обновляет записи по ключевым полям. Если запись с id уже есть — обновляется; иначе — вставляется новая.

Пример

$bulkUpserter
    ->upsertMany('api_history', [
        [
            'id' => 123,
            'status' => 'success',
            'updated_at' => date('Y-m-d H:i:s'),
            'created_at' => date('Y-m-d H:i:s'),
        ],
        [
            'id' => IdStrategy::AUTO_INCREMENT,
            'status' => 'success',
            'updated_at' => date('Y-m-d H:i:s'),
            'created_at' => date('Y-m-d H:i:s'),
        ],
    ], ['status', 'updated_at']);

Обновляемые поля передаются третьим аргументом (replaceFields). id можно сгенерировать автоматически через IdStrategy::AUTO_INCREMENT.

PostgreSQL: RETURNING id за один round-trip

В PostgreSQL upsertManyReturningIds дописывает RETURNING <column> к upsert SQL и возвращает сгенерированные/обновлённые ID в порядке входных строк. MySQL / MariaDB RETURNING не поддерживают и бросят LogicException на этом вызове.

$ids = $bulkUpserter->upsertManyReturningIds(
    'api_history',
    [
        ['id' => IdStrategy::AUTO_INCREMENT, 'status' => 'success', 'updated_at' => date('Y-m-d H:i:s')],
        ['id' => IdStrategy::AUTO_INCREMENT, 'status' => 'pending', 'updated_at' => date('Y-m-d H:i:s')],
    ],
    ['status', 'updated_at'],
);
// $ids — это [123, 124] в порядке входных строк.

DbalFinder

DbalFinder даёт методы для типизированного чтения данных из базы.

Примеры

// Одна строка по SQL (LIMIT 1 добавляется автоматически).
$result = $finder->fetchOneBySql(
    'SELECT * FROM api_history WHERE id = :id',
    ['id' => $id],
    ApiDto::class
);

// Несколько строк с маппингом в DTO.
$results = $finder->fetchAllBySql(
    'SELECT * FROM api_history ORDER BY id LIMIT 10',
    [],
    ApiDto::class
);

// Запись по ID.
$result = $finder->findById($id, 'api_history', ApiDto::class);

// Записи по списку ID.
$result = $finder->findByIdList($idList, 'api_history', ApiDto::class);

Если DTO-класс не указан — вернётся массив.

DbalMutator

DbalMutator предназначен для безопасной вставки и изменения данных в таблицах.

Примеры

// Вставка одной строки.
$mutator->insert('api_history', [
    'type' => ['callback', ParameterType::STRING],
    'merchant_id' => '12345',
    'provider' => 'example-provider',
    'trace_id' => 'trace-001',
    'our_id' => 'our-001',
    'ext_id' => 'ext-001',
    'data' => json_encode(['source' => 'test']),
    'status' => 'success',
    'created_at' => date('Y-m-d H:i:s'),
    'updated_at' => date('Y-m-d H:i:s'),
]);

Поддерживаются поля с типами (например, ['value', ParameterType::STRING]). Если тип не указан — определяется автоматически.

⚠️ Важно

Перед использованием insert(), updateMany(), upsertMany() необходимо обязательно указать актуальные служебные поля через setFieldNames() или общий конфиг в fieldNames:

->setFieldNames([
    BundleConfigurationInterface::ID_NAME => 'id',
    BundleConfigurationInterface::CREATED_AT_NAME => 'created_at',
    BundleConfigurationInterface::UPDATED_AT_NAME => 'updated_at',
])

SQL caller-trace comment (opt-in)

Бандл экспонирует существующий DbalConnection::setAdditionalSqlCommentEnable toggle через конфигурацию doctrine_dbal бандла. Default: off (backward-compatible).

Когда включён, каждый executeQuery, executeStatement и prepare добавляет JSON caller-trace комментарий в начало SQL. Комментарий несёт applicationCaller (первый не-фреймворковый класс в backtrace) и entryPointController (Symfony-контроллер или имя консольной команды, если есть). Оператор видит происхождение каждого запроса в MySQL slow log, PostgreSQL pg_stat_statements или general log.

Включение per environment в config/packages/doctrine_dbal.yaml:

doctrine_dbal:
    sql_comment_enabled: true

Тот же флаг экспонирован на DbalBundleConfig::$sqlCommentEnabled для программного управления в тестах или кастомной wiring.

PSR-3 логирование bulk-операций (opt-in)

AbstractDbalWriteExecutor и его наследники (BulkInserter, BulkUpdater, BulkUpserter, BulkDeleter) принимают опциональный Psr\Log\LoggerInterface. Если логгер не инжектирован, executor использует NullLogger и ведёт себя как раньше. Когда логгер инжектирован (например, Symfony\Bridge\Monolog\Logger), каждая bulk-операция эмитит четыре event-типа:

Event Level Payload
dbal.bulk.{op}.start INFO operation, table, chunk_size, total_rows
dbal.bulk.{op}.end INFO operation, table, rows_affected, duration_ms, peak_memory_mb
dbal.bulk.constraint_violation WARNING оригинальное DBAL-сообщение, attempt count
dbal.bulk.connection_error ERROR оригинальное DBAL-сообщение, attempt count

{op} — один из insert, update, upsert, delete, soft_delete.

Wiring через services.yaml:

services:
    Elrise\Bundle\DbalBundle\Manager\Bulk\BulkInserter:
        parent: Elrise\Bundle\DbalBundle\Manager\Bulk\AbstractDbalWriteExecutor
        arguments:
            $logger: '@monolog.logger.dbal'

    monolog.logger.dbal:
        class: Symfony\Bridge\Monolog\Logger
        arguments: ['dbal']

composer.json уже заявляет psr/log: ^3.0; дополнительных зависимостей не требуется.

Concurrency-хелперы (advisory locks и row-level locks)

TransactionService экспонирует concurrency-хелперы для высокой пропускной способности.

PostgreSQL advisory locks

PostgreSQL advisory locks — application-level мьютексы, не привязанные к таблице. Полезны для leader election или single-writer секций.

// PG advisory lock — leader election или single-writer секция
$txService->transactional(function () use ($txService) {
    $txService->acquireAdvisoryLock(42); // блокирует до получения
    try {
        // ... критическая секция ...
    } finally {
        $txService->releaseAdvisoryLock(42);
    }
});

tryAdvisoryLock($key) — неблокирующий вариант; возвращает false, если другой держатель владеет локом. Все три advisory-метода — PostgreSQL-only и бросают LogicException на MySQL / MariaDB.

Row-level locks

SELECT ... WHERE ... FOR UPDATE лочит конкретные строки перед read-modify-write так, что другие транзакции не могут их менять до commit/rollback текущей.

// Row-level lock перед read-modify-write
$txService->transactional(function () use ($txService, $id) {
    $txService->lockRows('orders', ['id' => $id]); // FOR UPDATE
    $order = $finder->findById($id, 'orders');
    // ... modify and save ...
});

Enum LockMode (FOR_UPDATE, FOR_NO_KEY_UPDATE, FOR_SHARE, FOR_KEY_SHARE) покрывает оба row-lock варианта PostgreSQL и MySQL 8+ семантику. На MySQL legacy FOR_SHARE маппится в LOCK IN SHARE MODE. FOR_NO_KEY_UPDATE и FOR_KEY_SHARE — PostgreSQL-only и бросают LogicException на MySQL / MariaDB.

Зарегистрированные Doctrine Types

Бандл регистрирует семь Doctrine Types в build(), чтобы потребители могли объявлять колонки через #[Column(type: '...')] без ручной регистрации Type:

Верифицированная нагрузка

Измерено itests/ integration load-testing пайплайном (ADR-0004) на реальных MySQL 8.4, PostgreSQL 16 и MariaDB 11 в Docker. Полный отчёт с per-vendor raw envelopes: itests/reports/WAVE_LT_CROSS_VENDOR_REPORT.md.

Single-vendor метрики (один прогон --rows 1000 --chunk 100 на сценарий):

Scenario Vendor duration ops/sec p50 latency p95 / p99 latency errors
bulk_insert MySQL 8.4 0.207s 4,831 16.48 ms 41.45 ms 0
bulk_insert PostgreSQL 16 0.130s 7,692 10.31 ms 20.79 ms 0
bulk_insert MariaDB 11 0.222s 4,505 20.73 ms 37.99 ms 0
bulk_upsert MySQL 8.4 0.027s 37,037 11.29 ms 104.90 ms 0
bulk_upsert PostgreSQL 16 0.108s 9,259 10.17 ms 805.01 ms 0
bulk_upsert MariaDB 11 0.028s 35,714 12.12 ms 47.19 ms 0
bulk_update MySQL 8.4 0.031s 64,516 1.97 ms 19.45 / 31.68 ms 0
bulk_update PostgreSQL 16 0.148s 13,514 9.34 ms 14.45 / 16.23 ms 0
bulk_update MariaDB 11 0.028s 71,429 1.81 ms 9.41 / 13.85 ms 0
cursor_stream MySQL 8.4 0.002s 500,000 0.15 ms 0.21 ms 0
cursor_stream PostgreSQL 16 0.003s 333,333 0.23 ms 0.30 ms 0
cursor_stream MariaDB 11 0.002s 500,000 0.13 ms 0.17 ms 0

Bounded-concurrency parallel runner (--workers 2 --concurrency 2, cursor_stream, --rows 500 на worker):

Vendor total_rows total_duration throughput ops/sec p50 / p95 / p99 latency errors
MySQL 8.4 400 0.002s 200,000 0.31 / 0.34 / 0.42 ms 0
PostgreSQL 16 1,000 0.002s 500,000 0.23 / 0.24 / 0.24 ms 0
MariaDB 11 1,000 0.002s 500,000 0.32 / 0.35 / 0.38 ms 0

PG и MariaDB масштабируются линейно по воркерам. total_rows=400 в MySQL (вместо 1000) — известная коллизия unique-email между параллельными worker'ами; не correctness-regression.

Envelope contract (ADR-0004 Decision §5) соблюдён end-to-end на всех трёх вендорах; 306/306 PHPUnit проходит; php-cs-fixer показывает 0 violations. bulk_insert упирается в 4-7k rows/sec — зафиксированный gap до нативных COPY / LOAD DATA (ADR-0005, AR-036); не correctness-regression.

Тестирование

Бандл поставляет BulkTest console-command suite в BulkTestCommands/, который прогоняет каждую bulk-операцию против реальной базы. Фикстуры лежат в tests/_db/.

Wiring тестовых команд

Добавьте в services.yaml:

services:
    Elrise\Bundle\DbalBundle\Manager\Bulk\BulkUpserter:
        arguments:
            $connection: '@Doctrine\DBAL\Connection'
            $config: '@Elrise\Bundle\DbalBundle\Config\DbalBundleConfig'
            $sqlBuilder: '@Elrise\Bundle\DbalBundle\Sql\Builder\SqlBuilderInterface'

    Elrise\Bundle\DbalBundle\BulkTestCommands\BulkInsertManyCommand:
        arguments:
            $connection: '@Doctrine\DBAL\Connection'
            $bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
        tags: [ 'console.command' ]

    Elrise\Bundle\DbalBundle\BulkTestCommands\BulkUpdateManyCommand:
        arguments:
            $connection: '@Doctrine\DBAL\Connection'
            $bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
            $bulkUpdater: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkUpdaterInterface'
        tags: [ 'console.command' ]

    Elrise\Bundle\DbalBundle\BulkTestCommands\BulkUpsertManyCommand:
        arguments:
            $connection: '@Doctrine\DBAL\Connection'
            $bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
            $bulkUpserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkUpserterInterface'
        tags: [ 'console.command' ]

    Elrise\Bundle\DbalBundle\BulkTestCommands\BulkDeleteManyCommand:
        arguments:
            $connection: '@Doctrine\DBAL\Connection'
            $bulkDeleter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkDeleterInterface'
            $bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
        tags: [ 'console.command' ]

    Elrise\Bundle\DbalBundle\BulkTestCommands\BulkSoftDeleteManyCommand:
        arguments:
            $connection: '@Doctrine\DBAL\Connection'
            $bulkDeleter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkDeleterInterface'
            $bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
        tags: [ 'console.command' ]

Тестовая таблица

Прогоните SQL-фикстуры против вашей тестовой базы перед выполнением команд:

// для MySQL
tests/_db/init.sql

// для PostgreSQL
tests/_db/init_postgres.sql

Использование команд

bin/console dbal:test:run-all
bin/console dbal:test:bulk-insert-many
bin/console dbal:test:bulk-update-many
bin/console dbal:test:bulk-upsert-many
bin/console dbal:test:bulk-delete-many
bin/console dbal:test:bulk-soft-delete-many
bin/console dbal:test:cursor-iterator
bin/console dbal:test:offset-iterator
bin/console dbal:test:finder
bin/console dbal:test:mutator
bin/console dbal:test:transaction-service
bin/console dbal:test:insert

Каждая команда поддерживает:

Пример:

bin/console app:test:bulk-upsert-many --chunk=200 --count=5000 --cycle=5 --track

Логирование результатов

Когда передан флаг --track, команда сохраняет логи производительности в CSV-файл:

var/log/<тип_теста>_<timestamp>.csv

Каждая строка в логе содержит:

Потрогать руками

dummy-market-agent — reference Symfony-сервис с Application Layer, CQRS, API Platform и DBAL-based persistence. Репозиторий запускается через Docker; в нём доступны API-операции, command/query handlers, persistence, container wiring, границы слоёв и тесты.

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

Источники