ORM: что вы отдаёте за удобство
ORM берёт плату не производительностью. Она прячет границу между памятью процесса и состоянием базы — и поэтому N+1 переживает любое код-ревью.
Коротко. Главная цена ORM — не медленный SQL, а то, что обращение к базе выглядит в коде как обращение к полю объекта. Пока граница невидима, её будут пересекать в цикле.
Спор про ORM обычно идёт не о том. Говорят про производительность: «генерирует плохой SQL», «на сложных выборках всё равно пишешь руками». И то и другое отчасти верно, и то и другое — не главное.
Главное вот что. ORM делает пересечение границы процесса неотличимым от работы с памятью.
$order->getCustomer()->getName()
Здесь может не быть ни одного обращения к базе. А может быть одно. Из кода это не видно — и в этом весь размен.
Почему N+1 бессмертен
N+1 знают все. Он описан в каждой книге, его ловят линтеры, про него спрашивают на собеседованиях. И он всё равно в каждом втором проекте.
Причина не в неграмотности. Причина в том, что в коде он не выглядит как ошибка:
foreach ($orders as $order) {
$rows[] = [$order->getId(), $order->getCustomer()->getName()];
}
Это чистый, понятный цикл. Он проходит ревью, потому что читается как работа с уже загруженными данными. Никакой строчки, похожей на запрос, здесь нет. Запросы появляются на сотом заказе, в проде, когда список вырос со ста строк до десяти тысяч.
Сравните с тем же кодом, где обращение к базе видно:
foreach ($orders as $order) {
$customer = $repo->findCustomer($order->customerId);
}
Второй вариант ревьюер остановит. Не потому что он умнее, а потому что
$repo->find выглядит как поход наружу, а ->getCustomer() — как поле.
Отсюда мой вывод, с которым спорят: проблема не в ленивой загрузке как механизме, а в том, что она молчит. Ленивая загрузка, которая пишет в журнал при каждом срабатывании внутри цикла, перестаёт быть проблемой за один спринт. Большинство ORM это умеют и почти нигде это не включено.
Что ORM действительно даёт
Мне не хочется, чтобы это читалось как «пишите руками». Не пишите, в большинстве случаев это проигрыш.
ORM закрывает три вещи, и первые две недооценивают.
Отображение результата на типы. Ручной код превращает строки в
объекты вручную, и это самая скучная и самая ошибкоопасная часть работы
с базой. Пропущенное приведение типа, null там, где ждали строку,
разъехавшееся имя колонки — всё это ORM снимает.
Единица работы. Отслеживание изменений и одна транзакция на запрос — не сахар. Ручная реализация того же самого заканчивается тем, что где-то забыли обновить, а где-то обновили дважды.
Миграции как часть кодовой базы. Схема живёт рядом с кодом и едет вместе с ним.
Третье — вообще не про объектное отображение, но именно за него инструмент чаще всего и берут.
Тождество объектов — то, за что платят молча
Есть механизм, о котором вспоминают только когда он ломается.
ORM держит карту загруженных объектов: если сущность с таким идентификатором уже в памяти сессии, повторный запрос вернёт тот же экземпляр, а не новый. Это правильно — иначе одна и та же строка существовала бы в памяти в двух версиях, и было бы неизвестно, какая поедет в базу.
Плата в том, что сессия накапливает объекты и не отпускает их до конца. Обработчик, который в цикле проходит сто тысяч строк, к концу держит в памяти сто тысяч сущностей — вместе с их отслеживанием изменений. Процесс падает по памяти на задаче, которая по смыслу требует одного курсора и постоянного объёма.
Лечится это тремя способами, и все три надо знать заранее:
- периодически сбрасывать и очищать сессию порциями;
- читать пакетную задачу мимо ORM, курсором;
- вообще не грузить сущности там, где нужны три поля.
Первый способ — самый частый и самый хрупкий: очистка сессии обесценивает все ранее загруженные объекты, и код, который держал ссылку, получает сущность, отвязанную от сессии. Дальше обращение к её ленивому полю падает, и падает не там, где очищали.
«Нам всё равно приходится писать SQL»
Аргумент против ORM, который я считаю неверно сформулированным.
Да, приходится. Но из этого не следует, что ORM не нужна, — из этого следует, что задача была не той, для которой её берут. Отчёт с четырьмя соединениями и оконной функцией не является операцией над объектом. Это реляционная операция, и выражать её объектами — работа против инструмента.
Правильный вывод: ORM не обязана покрывать сто процентов обращений к базе. Она должна покрывать операции над сущностями, а рядом должен быть нормальный способ выполнить произвольный SQL и получить плоскую структуру. Если такого способа нет или он неудобен — вот это настоящая претензия к инструменту.
Где я границу провожу
Отчёты и агрегации пишутся на SQL. Всегда. Как только в задаче появляются
GROUP BY, оконные функции или соединение пяти таблиц ради одной цифры —
ORM здесь не помогает, а мешает: вы всё равно описываете реляционную
операцию, только на языке, который для неё не предназначен.
Работа с одной сущностью и её ближайшим окружением — через ORM. Загрузить заказ, поменять статус, сохранить. Здесь единица работы и отображение типов окупаются полностью.
Между этими полюсами — зона решения, и решение принимается по одному признаку: сколько строк вернёт запрос и сколько раз он выполнится.
Три настройки, которые я включаю первыми
Они дешевле любого спора об архитектуре.
- Журнал запросов с счётчиком на запрос. Не в проде постоянно — в разработке и в тестах. Тест, который падает, когда запросов на эндпоинт стало больше двадцати, ловит N+1 до релиза, а не после.
- Запрет на ленивую загрузку в HTTP-слое. Всё, что нужно контроллеру, загружается явно на входе. Не нашлось — исключение, а не тихий поход в базу.
- Отдельный тип для чтения. Список заказов на экране не нуждается в полноценных сущностях с отслеживанием изменений. Ему нужны шесть полей. Плоская структура, собранная одним запросом, снимает половину вопросов производительности и всю проблему ленивой загрузки разом.
Третий пункт — тот, который обычно откладывают, и зря. Разделение чтения и записи не требует ни CQRS как архитектуры, ни отдельной базы, ни шины событий. Достаточно признать, что читать и писать — разные задачи.
Когда всё это неважно. Внутренняя админка на сорок пользователей с таблицами по тысяче строк переживёт любой N+1 и не заметит. Считать запросы там — потраченное время. Разговор начинается, когда данные растут быстрее, чем команда успевает переписывать.