Как ORM перекладывает работу с базы на язык
ORM — это не волшебная палочка, которая избавляет от SQL. Это инструмент, который переводит структуру данных из таблиц базы в объекты языка программирования. Когда вы пишете user = User.objects.get(id=1), ORM не просто выполняет SELECT * FROM users WHERE id = 1. Она берёт результат этого запроса и создаёт из него объект User с заполненными полями. Это избавляет от ручной сборки результатов: не нужно вручную маппить user['name'] на user.name, ORM делает это за вас.
Но за удобство приходится платить. Запрос, который вы не писали, всё равно выполняется — и иногда это не один запрос, а несколько. Например, если у модели User есть связь с моделью Order, и вы обращаетесь к user.orders, ORM может выполнить отдельный запрос для загрузки заказов. Если это происходит внутри цикла, на каждую итерацию придётся по запросу. На десятке записей это незаметно, но на тысяче — уже проблема.
Главная ловушка: N+1 запросов
Самая распространённая ошибка при работе с ORM — обращение к связанным записям внутри цикла. Представьте, что у вас есть список пользователей, и для каждого нужно вывести его заказы. Если вы пишете:
users = User.objects.all()
for user in users:
print(user.orders.all())
ORM выполнит один запрос для загрузки пользователей и по запросу на каждого пользователя для загрузки его заказов. Если пользователей 1000, это 1001 запрос. Нагрузка на базу растёт линейно с количеством записей, и на больших объёмах это становится критичным.
Первое, что пробуют в такой ситуации — включить eager loading. В Django это select_related для связей один-к-одному и prefetch_related для связей многие-ко-многим. В SQLAlchemy — joinedload или subqueryload. Это позволяет загрузить связанные записи одним запросом:
users = User.objects.prefetch_related('orders').all()
Но это не всегда помогает. Если связей много или они вложенные, eager loading может породить огромный запрос с множеством соединений. Например, если у Order есть связь с Product, а у Product — с Category, и вы хотите загрузить всё сразу, ORM сгенерирует запрос с тремя JOIN. На больших объёмах данных такой запрос может выполняться медленнее, чем несколько отдельных.
Когда ORM выигрывает
ORM почти всегда удобнее для простых операций: выборки по ключу, записи, миграций. В большой команде ORM обеспечивает единообразие: все работают с одними и теми же моделями, а не с разными SQL-запросами. Миграции тоже проще: ORM генерирует их на основе изменений в моделях, и не нужно вручную писать ALTER TABLE.
Ещё одно преимущество — кэширование. ORM может кэшировать объекты и связи между ними, что ускоряет повторные запросы. Но это работает только если кэш не сбрасывается слишком часто. Если вы часто обновляете данные, кэш может стать бесполезным.
Когда лучше писать запросы руками
Для отчётов, агрегатов и оконных функций ORM часто проигрывает. Здесь важен план выполнения, а ORM не всегда генерирует оптимальный SQL. Например, если нужно посчитать среднее значение по группе с фильтрацией, ручной запрос будет эффективнее, потому что ORM может сделать лишние соединения или не использовать индексы.
Рассмотрим пример. Допустим, у вас есть таблица orders с полями user_id, amount и created_at, и нужно посчитать средний чек по пользователям за последний месяц. ORM может сгенерировать что-то вроде:
SELECT AVG(amount)
FROM orders
WHERE created_at >= '2023-01-01'
GROUP BY user_id
Но если у вас есть индекс по created_at, ручной запрос может использовать его эффективнее. Кроме того, ORM может не учесть особенности вашей базы: например, если amount хранится как строка, ORM не сможет правильно посчитать среднее без явного приведения типов.
Ещё один случай — сложные запросы с подзапросами или CTE. ORM может их сгенерировать, но результат будет громоздким и не всегда читаемым. Например, если нужно найти пользователей, у которых больше 10 заказов, ORM может сделать подзапрос:
SELECT *
FROM users
WHERE id IN (
SELECT user_id
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 10
)
Но если база поддерживает оконные функции, можно сделать это одним запросом с COUNT() OVER (PARTITION BY user_id). ORM может не знать об этой возможности или не использовать её оптимально.
Смешанный подход: одна модель, два способа
ORM и ручные запросы — не взаимоисключающие варианты. Можно использовать ORM для простых операций и писать SQL для сложных. Например, в Django можно использовать .raw() для выполнения сырого SQL:
users = User.objects.raw('SELECT * FROM users WHERE id IN (SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) > 10)')
А в SQLAlchemy — text():
from sqlalchemy import text
users = session.execute(text('SELECT * FROM users WHERE id IN (SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) > 10)')).fetchall()
Это позволяет сохранить единую модель данных, но обращаться к ней разными способами. Такой подход — норма, а не компромисс. Он позволяет использовать сильные стороны обоих вариантов: удобство ORM для рутинных задач и гибкость SQL для сложных.
Как понять, что пора смотреть в сгенерированный запрос
Если запрос работает медленно, первым делом стоит посмотреть, что сгенерировала ORM. В большинстве ORM есть инструменты для логирования SQL. Например, в Django можно включить DEBUG=True и смотреть запросы в логах, а в SQLAlchemy — использовать echo=True.
В сгенерированном запросе нужно искать:
- Лишние соединения (JOIN), которые можно заменить подзапросами. Например, если ORM делает JOIN для загрузки связанных записей, но вам нужны только данные из основной таблицы, это лишняя нагрузка.
- Повторяющиеся запросы, которые можно объединить в один. Например, если ORM выполняет отдельный запрос для каждой связанной записи, можно попробовать использовать eager loading.
- Отсутствие индексов на часто используемых полях. Если ORM генерирует запрос с фильтрацией по полю без индекса, это может замедлить выполнение.
Если ORM генерирует неоптимальный SQL, можно попробовать переписать запрос вручную или настроить ORM так, чтобы она использовала более эффективный подход. Например, в Django можно использовать only() для загрузки только нужных полей:
users = User.objects.only('id', 'name').all()
Это уменьшит объём данных, передаваемых из базы.
Тупик: выбрать одно на весь проект и держаться принципа
Самая большая ошибка — пытаться использовать ORM для всего или, наоборот, писать SQL для каждой мелочи. Многие начинают с ORM, потому что это удобно, но когда появляются сложные запросы, пытаются переписать всё на SQL. Или наоборот: начинают с SQL, но потом переходят на ORM, потому что «так проще».
Первое, что пробуют — выбрать один подход и держаться его. Но это не работает. Если использовать ORM для всего, сложные запросы будут медленными и неэффективными. Если писать SQL для всего, простые операции будут громоздкими и неудобными.
Например, если вы пишете отчёт, который требует сложных агрегатов и оконных функций, ORM может не справиться. Но если вы пишете простую форму для редактирования пользователя, SQL будет избыточным. Смешанный подход — это не компромисс, а осознанный выбор: использовать ORM там, где она удобна, и SQL там, где она эффективнее.
Позиция редакции
ORM — это удобный инструмент, но не универсальное решение. Она хороша для простых операций, но для сложных запросов лучше писать SQL вручную. Смешанный подход — норма: одна модель данных, два способа обращения.
Но есть условие, при котором эта позиция неверна: если в команде нет опыта работы с SQL, лучше использовать ORM для всего. В этом случае риск ошибок в ручных запросах перевешивает преимущества гибкости. Кроме того, если проект небольшой и не требует сложных запросов, ORM может быть достаточной. Но как только появляются отчёты, агрегаты или большие объёмы данных, стоит задуматься о ручных запросах.