Алексей Меленчук
вся записка
дата
объём 6 мин чтения

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. Загрузить заказ, поменять статус, сохранить. Здесь единица работы и отображение типов окупаются полностью.

Между этими полюсами — зона решения, и решение принимается по одному признаку: сколько строк вернёт запрос и сколько раз он выполнится.

Три настройки, которые я включаю первыми

Они дешевле любого спора об архитектуре.

  1. Журнал запросов с счётчиком на запрос. Не в проде постоянно — в разработке и в тестах. Тест, который падает, когда запросов на эндпоинт стало больше двадцати, ловит N+1 до релиза, а не после.
  2. Запрет на ленивую загрузку в HTTP-слое. Всё, что нужно контроллеру, загружается явно на входе. Не нашлось — исключение, а не тихий поход в базу.
  3. Отдельный тип для чтения. Список заказов на экране не нуждается в полноценных сущностях с отслеживанием изменений. Ему нужны шесть полей. Плоская структура, собранная одним запросом, снимает половину вопросов производительности и всю проблему ленивой загрузки разом.

Третий пункт — тот, который обычно откладывают, и зря. Разделение чтения и записи не требует ни CQRS как архитектуры, ни отдельной базы, ни шины событий. Достаточно признать, что читать и писать — разные задачи.

Когда всё это неважно. Внутренняя админка на сорок пользователей с таблицами по тысяче строк переживёт любой N+1 и не заметит. Считать запросы там — потраченное время. Разговор начинается, когда данные растут быстрее, чем команда успевает переписывать.