Почему high-load Symfony нуждается в отдельном DBAL-слое рядом с ORM

В high-load Symfony-проектах раз за разом возникает одна и та же картина: ORM хорош на своей территории (Entity, миграции, CRUD), но не тянет инфраструктурные задачи — bulk, поток, ночные миграции на миллиарды параметров, отчёты по миллионам записей. В этой заметке разобран архитектурный сдвиг, который я для себя зафиксировал: держать ORM и DBAL-слой рядом, как два слоя данных с разной ответственностью. API, компоненты, бенчмарки и требования бандла elriseio/dbal-bundle описаны на странице проекта.

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

Я не сравниваю ORM и DBAL как конкурентов. Я предлагаю один из вариантов архитектуры для high-load: оставить ORM на его задачах и добавить рядом DBAL-слой для инфраструктурных сценариев. В production это вопрос разделения ответственности, а не замены технологии.

Зона ответственности ORM

Doctrine ORM в Symfony отвечает за:

Задача | Инструмент
Структура БД | миграции Doctrine Migrations
Маппинг таблиц | Entity и ORM metadata
CRUD-операции | EntityRepository, findBy, persist/flush
Целостность агрегата | unit of work, identity map, lazy-load

Эти задачи ORM закрывает хорошо. Под них он и проектировался.

Где у ORM заканчивается зона

В high-load появляются задачи, под которые ORM не оптимизирован. Они не ломают его контракт, но требуют другого уровня абстракции.

Гидрация там, где нужны данные, а не сущности. $repo->findAll() строит для каждой строки объект с прокси, lazy-loaders и коллекциями. На 1000 строк это незаметно. На миллионе — гидрация съедает 80% времени и потребляет в десятки раз больше памяти, чем весят сами данные, при том что через 5 секунд объекты превращаются обратно в массив для отчёта.

N+1 на bulk-операциях. Обновить 50 000 строк через ORM — это либо 50 000 UPDATE'ов, либо 50 000 гидрированных объектов в памяти. Оба варианта нерабочие. ORM не предназначен для операции «обновить много строк одним SQL».

«Чёрный ящик» SQL. В slow log с 200-строчным запросом и 12 JOIN'ами нужна уверенность, откуда он пришёл. ORM превращает это в расследование: стек, UnitOfWork, гадание. В инфраструктурных операциях — ночных миграциях, биллинге, отчётах — SQL нужен под явным контролем, до последней запятой, потому что от плана запроса зависит, пройдёт ли операция за час или за восемь.

Потоковое чтение. 10 миллионов строк через ORM — костыль. toIterable() появился недавно и работает с оговорками. Для стриминга нужны генераторы поверх Statement::fetch(), а не коллекции поверх ResultSetMapping. Это другой инструмент.

Эти задачи не враждебны ORM. Они просто не его уровень абстракции.

Почему именно DBAL, а не PDO

Прямой переход на голый PDO в high-load проектах — стандартный рефлекс. Через полгода команда обычно получает:

собственный велосипед для bulk-insert (с багами);

обёртку над транзакциями, которая теряет коннект ровно тогда, когда не до отладки;

свой маппинг массив ↔ DTO, который ломается на вложенных структурах;

свою обработку UniqueConstraintViolationException (и игнор остальных).

Doctrine DBAL — это уже готовая прослойка: Connection, параметризация, транзакции, кросс-вендорные типы. Она закрывает большую часть того, что иначе пришлось бы писать руками. Поверх неё остаётся собрать правильную архитектуру: фабрики, интерфейсы, исполнители, SQL-генераторы.

elriseio/dbal-bundle — это собранная поверх DBAL инфраструктура, которая в каждой high-load команде всё равно появляется. Бандл не «заменяет PDO своей обёрткой», а формализует тот слой, который иначе пишется по-разному в каждом проекте.

Гибридная архитектура

Два слоя данных сосуществуют, и у каждого своя зона:

┌────────────────────────────────────────────┐
│ Application Layer │
│ Command / Query handlers │
│ бизнес-логика │
└────────────────────────────────────────────┘
│ │
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────────────────────┐
│ ORM layer │ │ DBAL layer │
│ Entity + Repository │ │ BulkInserter / BulkUpdater │
│ find / persist │ │ CursorIterator / OffsetIterator │
│ миграции │ │ DbalFinder / DbalMutator / DTO │
│ unit of work │ │ TransactionService (advisory, row) │
└─────────────────────┘ └──────────────────────────────────────┘
│ │
└──────────┬───────────────┘
▼
Doctrine DBAL (Connection, parameters, types)
│
▼
MySQL / MariaDB / PostgreSQL

ORM обслуживает структуру БД и CRUD-логику. DBAL-слой берёт на себя инфраструктурные операции: bulk, поток, отчёты, миграции данных. Они дополняют друг друга, а не конкурируют.

Когда DBAL-слой действительно нужен

Отдельный DBAL-слой — не обязательный элемент архитектуры. Он оправдан, когда:

ORM обслуживает CRUD и миграции, но инфраструктурные задачи упираются в производительность;

нужны ночные миграции, ETL, синхронизация между системами;

есть bulk-операции: импорт CSV, массовое обновление, перенос данных;

есть потоковая обработка: очереди, экспорт, webhooks с прогревом;

используется несколько БД-коннектов (read replicas, шарды, разные вендоры);

есть жёсткие требования по latency/p99 на инфраструктурных запросах.

Если ни одно из условий не выполняется, отдельный слой — лишний. CRUD-проект на одной БД с одной схемой не нуждается в DBAL-инфраструктуре.

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

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

Итог

Проблема не в том, что ORM плох, и не в том, что Doctrine DBAL его заменяет. Проблема — в смешении разных классов задач на одном уровне абстракции. Когда ORM-уровень тянет bulk, поток и ночные миграции, он вынужден работать не как ORM. Когда DBAL-уровень остаётся в инфраструктурных сценариях, ORM продолжает обслуживать свою территорию чисто.

elriseio/dbal-bundle — один из вариантов формализации DBAL-слоя для high-load Symfony. Это не замена ORM, а инфраструктурный слой поверх существующего doctrine/doctrine-bundle, с теми же контрактами, но другим профилем нагрузки.

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

dbal-bundle: реализация, API, бенчмарки

elriseio/dbal-bundle на GitHub

Doctrine DBAL — официальная документация

Doctrine ORM Bundle для Symfony

PostgreSQL COPY — целевая fast-path для bulk insert

MySQL LOAD DATA — то же для MySQL

PSR-3 Logger Interface — логирование bulk-операций