user@elrise.ru:~
2026-07-238 min readphpsymfonydoctrinedbalormhigh-loadperformancearchitecture

Почему 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 проектах — стандартный рефлекс. Через полгода команда обычно получает:

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-слой — не обязательный элемент архитектуры. Он оправдан, когда:

Если ни одно из условий не выполняется, отдельный слой — лишний. 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, с теми же контрактами, но другим профилем нагрузки.


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

Обсуждение

Комментарии (0)

Пока никто не комментировал.