Когда ORM — неподходящий инструмент
Когда ORM становится узким местом, переходите на SQL для N+1 запросов, оконных функций, CTE, массовых записей и безопасных параметров.
ORM — правильный выбор по умолчанию для CRUD и неподходящий инструмент в тот момент, когда ваш запрос перестаёт выглядеть как доступ к объектам и начинает выглядеть как отчёт.
Вероятно, вам знаком этот момент: эндпоинт со списком, который прекрасно работал в staging, в продакшене отвечает четыре секунды, а лог запросов забит почти идентичными SELECT’ами, которых никто не писал руками. Оконные функции, CTE, агрегации с множественными join’ами и специфичные для конкретной СУБД операторы — именно те места, где сгенерированный ORM’ом SQL становится неэффективным или невозможным, и где переход на «сырой» SQL себя оправдывает. Эта статья проводит границу точно: где объектно-реляционное отображение является правильным выбором по умолчанию, где оно незаметно становится узким местом и как обойти его, не жертвуя защитой от инъекций.
Ключевые выводы
- ORM — правильный выбор по умолчанию для тех самых ~80% простого CRUD: они сокращают шаблонный код, автоматически параметризуют входные данные и остаются независимыми от конкретной СУБД.
- «Сырой» SQL не является изначально более быстрым, чем ORM. Он выигрывает конкретно тогда, когда узким местом становится сгенерированный ORM’ом запрос — на горячих путях, при массовых операциях или в паттернах N+1.
- Проблема N+1 — самый распространённый сценарий, при котором ORM незаметно превращается в неподходящий инструмент; сначала решайте её через жадную загрузку (eager loading) и переходите к «сырому» SQL только тогда, когда даже жадно загруженная форма данных не подходит.
- Уход от ORM не означает отказ от него. Переходите к «сырому» SQL через его собственный «аварийный люк»:
connection.cursor()илиManager.raw()в Django,text()в SQLAlchemy, TypedSQL в Prisma. - Когда вы пишете «сырой» SQL, ответственность за защиту от инъекций переходит к вам, поэтому всегда передавайте пользовательский ввод через плейсхолдеры (
%sв psycopg,$1в Postgres/SQLx) и никогда не конкатенируйте его в строку запроса.
«Сырой» SQL, конструкторы запросов, ORM: спектр абстракций
«ORM против raw SQL» никогда не было бинарным противопоставлением. Доступ к данным — это спектр от полного контроля до полного удобства, со средним уровнем, который большинство сравнений упускает. На одном конце «сырой» SQL даёт вам нативный язык базы данных без слоя трансляции. На другом ORM — такие как Django ORM, ActiveRecord, Hibernate, Prisma, Sequelize и SQLAlchemy — отображают строки в объекты и генерируют SQL за вас. Между ними находятся конструкторы запросов (query builders).
Конструктор запросов формализует шаблоны запросов в виде цепочек методов, оставаясь при этом близким к SQL, который он порождает. Большинство ORM также предоставляют способ передать базе данных «сырую» строку, что отключает экранирование, выполняемое их обычными методами запросов, и снова открывает дверь для SQL-инъекций. Конструктор — это другой инструмент: он программно составляет SQL, не притворяясь доступом к объектам. Knex — активно поддерживаемый конструктор запросов для JavaScript, находящийся на линии 3.3.0 с июня 2026 года согласно его changelog; на JVM jOOQ представляет собой типобезопасный SQL DSL, в настоящее время на линии 3.21, чья Open Source Edition ориентирована на JDK 21. Ни то, ни другое не является ORM, и оба сохраняют параметризацию — в этом и суть. Когда абстракция ORM начинает сопротивляться, уровень конструктора запросов часто оказывается правильным шагом вниз перед написанием SQL вручную.
Discover how at OpenReplay.com.
Когда ORM — неподходящий инструмент?
Сигнал к переключению — это не ощущение, а конкретика. Выходите за пределы ORM, когда сталкиваетесь с одним из этих пяти паттернов:
- Аналитические и отчётные запросы. Оконные функции, рекурсивные CTE, свёртки
GROUP BY ... HAVINGи отчёты с множественными join’ами — именно там сгенерированный SQL становится неэффективным или невыразимым. ORM оптимизирован под доступ к объектам, а не под вывод в форме OLAP. - Горячие пути и массовые операции. На высоконагруженном эндпоинте или при пакетных
UPDATE/INSERTпострочные вызовыsave()и лишние round-trip’ы накапливаются. Один множественный (set-based) оператор заменяет сотни записей через ORM. - Ловушка N+1 запросов. Подробно разбирается ниже: самый распространённый провал производительности ORM.
- Специфичные для СУБД возможности. Операторы JSONB в Postgres вроде
@>и->>, полнотекстовый поиск сtsvector/tsquery,LATERAL-join’ы и геопространственные функции PostGIS — возможности, которые многие ORM не могут выразить полностью или идиоматично. Некоторые ORM предоставляют вспомогательные средства (contrib.postgresв Django), но покрытие частичное. - Непрозрачное, «магическое» поведение. Когда вы не видите SQL, который порождает ORM, и не можете его настроить, отладка и работа над производительностью превращаются в гадание. Это объектно-реляционное несоответствие импедансов (impedance mismatch), проявляющееся как реальные издержки, и у него есть аспект безопасности: методы «сырых» запросов, которые предоставляет большинство ORM, находятся вне их собственного экранирования, поэтому подстановка значения в такой запрос оставляет вас уязвимым.
Проблема N+1 запросов: назвать и устранить
Проблема N+1 — самый распространённый сценарий, при котором ORM незаметно становится неподходящим инструментом: ленивая загрузка порождает по одному запросу на строку, поэтому список из 100 элементов молча превращается в 101 обращение к базе. Цикл выглядит невинно:
# One query for authors, then one MORE per author for their books
for author in Author.objects.all():
print(author.name, author.books.count())
Решение — жадная загрузка, а не «сырой» SQL. select_related и prefetch_related в Django схлопывают эти обращения в JOIN или в один запрос с IN:
# Two queries total, regardless of author count
authors = Author.objects.prefetch_related("books")
Сначала устраняйте N+1 жадной загрузкой и переходите к «сырому» SQL только тогда, когда даже жадно загруженная форма данных не подходит — например, когда вам нужна оконная агрегация по каждому автору, которую ORM выразил бы как ещё одно обращение к базе. Неэффективные ORM-запросы редко заявляют о себе в вашем коде; они проявляются как медленные ответы API и медленная загрузка страниц. Инструмент записи сессий вроде OpenReplay показывает медленный сетевой запрос на таймлайне сессии, указывая на эндпоинт, чей backend-запрос требует внимания: это локализация симптома, а не самого запроса. О более глубоком компромиссе читайте в руководстве OpenReplay по предотвращению SQL-инъекций.
Чем вы жертвуете, когда пишете «сырой» SQL?
Когда вы пишете «сырой» SQL, вы принимаете на себя ту единственную работу, которую ORM молча выполнял за вас: защиту от инъекций. Всегда передавайте пользовательский ввод через плейсхолдеры параметров и никогда не конкатенируйте его в строку запроса. Руководство Django по выполнению «сырых» SQL-запросов излагает механику: cursor.execute() принимает плейсхолдеры %s плюс отдельный список значений, а драйвер экранирует каждое значение на входе, так что оно никогда не становится частью текста оператора.
# Safe: %s is the psycopg/DB-API placeholder, not string formatting
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM book WHERE author = %s", [user_input])
rows = cursor.fetchall()
Оставляйте плейсхолдеры без обрамления: заключение %s в кавычки внутри SQL-строки сводит эту защиту на нет. SQLx для Rust берёт вид плейсхолдера из базы данных, поэтому это $1 в Postgres, но ? в MySQL, MariaDB и SQLite. Помимо инъекций, вы также берёте на себя больше шаблонного кода, более жёсткую привязку к одному диалекту SQL и ручное отображение строк результата обратно в объекты.
Отказ от ORM не обязательно означает отказ от страховочной сетки. Инструменты с проверкой на этапе компиляции её сохраняют: SQLx (0.9) проверяет запросы по схеме до запуска приложения, и в его собственной документации указано, что это не ORM; jOOQ (3.21) делает то же самое на JVM. Конструкторы запросов находятся посередине. «ORM против raw SQL» — ложная дихотомия: реальная ось измерения — сколько абстракции заслуживает каждый конкретный запрос.
Прагматичный вердикт: ORM против «сырого» SQL
Используйте ORM для тех самых ~80% простого CRUD и переходите к «сырому» SQL через его собственный «аварийный люк» для конкретных запросов, которые этого заслуживают. Уход от ORM не означает отказ от него. Django документирует три маршрута: RawSQL для встраивания параметризованного фрагмента в ORM-запрос, Manager.raw() для «сырого» запроса, который всё же возвращает экземпляры моделей, и connection.cursor() для полного обхода слоя моделей. SQLAlchemy предоставляет text(); Prisma поставляет TypedSQL, в настоящее время в статусе preview-функции, плюс $queryRaw для нетипизированного доступа.
Решение сводится к короткой таблице:
| Ситуация | Что выбрать |
|---|---|
| CRUD, формы, стандартные связи | ORM |
| Важна переносимость между диалектами | ORM или конструктор запросов |
| Отчёты с множественными join’ами, оконные функции, CTE | «Сырой» SQL |
| Горячий эндпоинт или массовая запись | «Сырой» SQL |
| N+1 в представлении со списком | Сначала жадная загрузка, «сырой» SQL при необходимости |
| Возможность СУБД, которую ORM не может выразить | «Сырой» SQL |
«Сырой» SQL — это не переписывание проекта; это точечный «аварийный люк» для той горстки запросов, где узким местом является сгенерированный SQL. Оставьте ORM в качестве варианта по умолчанию, профилируйте медленный эндпоинт и подставляйте написанный вручную параметризованный SQL ровно там, где план запроса доказывает, что это оправдано, и больше нигде.
Часто задаваемые вопросы
Действительно ли «сырой» SQL быстрее ORM?
Не по своей природе. Хорошо написанный ORM-запрос и хорошо написанный «сырой» запрос попадают в один и тот же планировщик запросов, поэтому «сырой» SQL не является автоматически более быстрым. «Сырой» SQL выигрывает конкретно тогда, когда узким местом становится сгенерированный ORM'ом запрос — лишние обращения к базе, паттерны N+1, широкие неограниченные выборки или горячие пути, где множественные (set-based) операторы заменяют построчные записи. Преимущество в скорости даёт исправление плохого сгенерированного SQL, а не «сырой» SQL сам по себе.
В чём разница между конструктором запросов и ORM?
Конструктор запросов программно составляет SQL через цепочки методов, оставаясь близким к SQL, который он порождает; он не отображает строки в объекты. ORM отображает строки базы данных в объекты языка и полностью скрывает SQL. Knex — конструктор запросов для JavaScript, а jOOQ — типобезопасный SQL DSL для JVM; ни то, ни другое не является ORM. Оба сохраняют параметризацию, поэтому вы отказываетесь от абстракции объектного отображения, не теряя защиты от инъекций.
Как писать «сырой» SQL, не подвергая приложение риску SQL-инъекций?
Передавайте каждое значение, полученное от пользователя, через плейсхолдеры параметров и никогда не конкатенируйте ввод в строку запроса. В Django с psycopg плейсхолдер — это %s, и драйвер базы данных экранирует параметры автоматически; SQLx для Rust берёт вид плейсхолдера из базы данных, поэтому это $1 в PostgreSQL, но ? в MySQL, MariaDB и SQLite. Не добавляйте кавычки вокруг плейсхолдеров в SQL-строке. Инструменты с проверкой на этапе компиляции, такие как SQLx, проверяют запросы по схеме до запуска приложения, добавляя ещё один уровень безопасности.
Можно ли использовать «сырой» SQL внутри ORM, не отказываясь от ORM?
Да. Каждый крупный ORM предоставляет «аварийный люк», позволяющий выполнять «сырой» SQL, сохраняя ORM в качестве варианта по умолчанию. Django предлагает connection.cursor() для прямого выполнения, Manager.raw() для возврата экземпляров моделей и RawSQL для параметризованных фрагментов внутри ORM-запросов; SQLAlchemy предоставляет text(); Prisma поставляет TypedSQL в статусе preview-функции плюс $queryRaw для нетипизированного доступа. Используйте ORM для стандартного CRUD и обращайтесь через него к «сырому» SQL только для тех конкретных запросов, где узким местом является сгенерированный SQL.