Почему 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-операций