12k
All articles

ORM vs Raw SQL: что выбрать?

ORM или raw SQL: сравните безопасность, скорость и контроль, узнайте, почему N+1 бьет сильнее абстракции, и выберите нужный инструмент для каждого запроса.

OpenReplay Team
OpenReplay Team
ORM vs Raw SQL: что выбрать?

Для большинства приложений разумный подход по умолчанию — использовать ORM или построитель запросов для 90% рутинных CRUD-операций и писать SQL вручную для небольшого набора отчётных, аналитических и высоконагруженных запросов, где это действительно оправдано. Это не выбор «одно или другое». Главный вопрос — не «что лучше в абстрактном смысле», а «что подходит для этого запроса, этой команде, с учётом этих ограничений». В статье вы найдёте правило принятия решений, сравнительную таблицу и описание одного антипаттерна — N+1 запросов, — который подводит команды значительно чаще, чем накладные расходы на абстракцию. Примеры приведены для стека JS/TS (Prisma, Drizzle, Kysely, Knex), поскольку именно там сегодня принимается большинство подобных решений.

Ключевые выводы

  • По умолчанию используйте ORM или построитель запросов для рутинных CRUD-операций, а к raw SQL обращайтесь только для конкретных отчётных, аналитических и высоконагруженных запросов, где это измеримо необходимо — большинство production-приложений в итоге работают с обоими подходами.
  • Реальные потери производительности от ORM почти никогда не связаны с самой абстракцией; их причина — паттерн N+1 запросов, когда загрузка списка и связанных данных по одной строке превращает один запрос страницы в сотни обращений к базе данных.
  • Проблема N+1 решается жадной загрузкой связей в одном запросе (через include в Prisma или через JOIN) и выборкой только тех столбцов, которые реально отображаются, — а не отказом от ORM.
  • Построители запросов — Drizzle, Kysely и Knex — это недооценённый третий вариант: запросы в стиле SQL с проверкой типов, полным контролем над генерируемым SQL и без слоя объектного маппинга.
  • ORM параметризуют запросы по умолчанию, что устраняет наиболее распространённый вектор SQL-инъекций; безопасность raw SQL целиком зависит от вашей дисциплины при использовании параметризованных выражений.

Что такое ORM и что означает «raw SQL»

ORM (Object-Relational Mapper) — это библиотека, которая отображает таблицы базы данных на объекты вашего языка программирования, позволяя запрашивать данные через вызовы методов и типизированные модели вместо написания SQL-строк вручную. ORM генерирует SQL, отправляет его в базу данных и гидрирует строки результата обратно в объекты, попутно обрабатывая связи, параметризацию и маппинг типов. В мире JS/TS примерами полноценных ORM являются Prisma и TypeORM. Типичное чтение данных в Prisma выглядит так:

// Пользователи вместе с их постами — в одном вызове
const users = await prisma.user.findMany({
  where: { posts: { some: { title: { contains: 'test' } } } },
  include: { posts: true },
});
// users[0].posts — уже типизированный массив, без ручного преобразования

Raw SQL означает написание текста запроса вручную и его выполнение через драйвер базы данных с последующим преобразованием плоских строк результата в нужную структуру. Вы получаете полный контроль над точным видом запроса, но и ответственность за маппинг тоже лежит на вас. Тот же запрос «пользователи с их постами» через pg требует JOIN и ручной перегруппировки:

const { rows } = await pool.query(
  `SELECT u.id, u.name, p.id AS post_id, p.title
   FROM users u
   LEFT JOIN posts p ON p.author_id = u.id
   WHERE p.title LIKE $1`,
  ['%test%'],
);

// Плоские строки -> вложенные объекты пользователей, вручную
const users = Object.values(
  rows.reduce((acc, r) => {
    acc[r.id] ??= { id: r.id, name: r.name, posts: [] };
    if (r.post_id) acc[r.id].posts.push({ id: r.post_id, title: r.title });
    return acc;
  }, {} as Record<number, { id: number; name: string; posts: any[] }>),
);

Этот reduce и есть настоящая цена raw SQL при реляционных чтениях: база данных возвращает строки, а не объектные графы, и логику объединения и перегруппировки вы пишете каждый раз заново. Плейсхолдер $1 тоже выполняет важную работу — подробнее об этом в разделе про безопасность.

ORM vs raw SQL: сравнение, которое действительно помогает принять решение

Выбор сводится к пяти критериям. Вот как три подхода — полноценный ORM, построитель запросов и raw SQL — соотносятся по каждому из них:

КритерийПолноценный ORM (Prisma, TypeORM)Построитель запросов (Drizzle, Kysely, Knex)Raw SQL
Защита от инъекцийПараметризация по умолчаниюПараметризация по умолчаниюБезопасен только при использовании параметризованных выражений
Скорость разработки (CRUD)Максимальная — связи и гидрация обрабатываются автоматическиВысокая — SQL-подобный синтаксис, JOIN пишете самиМинимальная — ручной маппинг
Миграции / схемаПервоклассная поддержка (Prisma Migrate, TypeORM)Встроенные инструменты или парные решения (Drizzle Kit, Knex migrations)DDL-скрипты вручную
Переносимость между СУБДВысокая — смена провайдера требует минимальных измененийСредняя — с учётом диалектовНизкая — SQL привязан к конкретному диалекту
Контроль / специфические возможности СУБДОграниченный; требуется escape hatchВысокий — близко к SQLПолный — любые возможности и хинты
ТипобезопасностьСквозная, от схемыСтрогая (Kysely/Drizzle); частичная (Knex)Отсутствует без дополнительных инструментов

В части безопасности практическая истина проста: ORM и построители запросов параметризуют значения по умолчанию, что устраняет классический вектор инъекций. Безопасность raw SQL целиком зависит от вашей дисциплины — документация Prisma прямо предупреждает, что использование небезопасного метода с пользовательскими данными открывает возможность для SQL-инъекций, которые могут привести к изменению или удалению данных. Используйте плейсхолдеры ($1, ?) и никогда не интерполируйте пользовательский ввод в строку запроса — тогда raw SQL безопасен; пренебрежёте этим правилом — и нет.

В части миграций и переносимости ORM выигрывает в типовых сценариях. Изменение схемы превращается в версионированную, воспроизводимую миграцию, которая одинаково выполняется во всех окружениях, а смена провайдера базы данных в основном сводится к изменению конфигурации. Raw SQL не даёт ни того ни другого бесплатно: DDL-скрипты вы управляете сами, а запросы привязаны к синтаксису конкретного диалекта. В части контроля raw SQL выигрывает безоговорочно: оконные функции, рекурсивные CTE, специфичные для СУБД хинты для индексов и настройка с помощью EXPLAIN ANALYZE — всё это доступно без борьбы с абстракцией.

Правда о производительности: проблема N+1, а не абстракция

Самая распространённая критика ORM — «они генерируют медленные запросы» — в основном неверна применительно к CRUD и в основном верна для одного конкретного паттерна. Современные ORM генерируют вполне компетентный SQL для повседневных операций чтения и записи. Реальные потери производительности — это проблема N+1 запросов: вы загружаете список из N строк, а затем для каждой строки выполняете ещё один запрос для получения связанных данных, превращая один запрос страницы в N+1 обращений к базе данных.

Вот как выглядит этот антипаттерн — обычно он прячется внутри безобидного цикла:

// 1 запрос для пользователей...
const users = await prisma.user.findMany();

// ...затем ещё N запросов, по одному на каждого пользователя — это и есть ловушка N+1
for (const user of users) {
  user.posts = await prisma.post.findMany({ where: { authorId: user.id } });
}

Решение — не отказываться от ORM. Нужно жадно загрузить связанные данные в одном запросе и выбрать только те столбцы, которые реально отображаются:

const users = await prisma.user.findMany({
  include: { posts: { select: { id: true, title: true } } },
});

Это сворачивает 1 + N запросов в один. Симптом неисправленного N+1 на уровне пользователя — страница со списком или дашборд, которые быстро работают на тестовых данных и тормозят под production-нагрузкой. Именно такую клиентскую задержку выявляет запись пользовательских сессий, указывая обратно на слой запросов: нужно посчитать запросы и добавить жадную загрузку.

Проверьте генерируемый SQL, прежде чем обвинять инструмент

Прежде чем делать вывод о медлительности ORM, посмотрите, что он реально генерирует. Prisma логирует каждый запрос, если при инициализации клиента указать log: ['query']; Kysely предоставляет метод .compile(), возвращающий SQL и параметры; Drizzle предоставляет метод .toSQL() на запросе для той же цели. Затем выполните EXPLAIN ANALYZE на полученном выводе, чтобы увидеть план, который реально выполняет база данных.

const prisma = new PrismaClient({ log: ['query'] });
// каждый запрос Prisma теперь выводится в лог — считайте их, копируйте в EXPLAIN ANALYZE

Это превращает «ORM работает медленно» из ощущения в проверяемый рабочий процесс: читайте SQL, считайте обращения к базе, проверяйте план.

Примечание об архитектуре движка: одно из наиболее значимых изменений в Prisma 7 — полное удаление движка запросов на Rust в пользу реализации на TypeScript. Новый Query Compiler работает на TypeScript и WebAssembly, что устраняет шаг кросс-языковой сериализации и ускоряет выполнение запросов; поскольку движок больше не зависит от нативного бинарного файла, Prisma теперь можно использовать в средах с поддержкой JavaScript или WASM, таких как Cloudflare Workers, Bun и Deno. По данным Prisma, переписанный движок нередко значительно быстрее там, где это важнее всего — на больших и сложных запросах, — при этом сопоставим по скорости на простых. Вывод для данного сравнения: дискуссия о движке изменила форму, но не исчезла, и по-прежнему остаётся второстепенной по отношению к количеству запросов. Для подавляющего большинства медленных страниц причина — N+1 и избыточная выборка данных, а не слой абстракции.

Построители запросов: третий вариант, который теряется в бинарном противопоставлении

Формулировка «ORM vs raw SQL» скрывает сильную промежуточную альтернативу. Построители запросов — Drizzle, Kysely и Knex — позволяют писать типизированные запросы в стиле SQL с полным контролем над генерируемым SQL и без слоя объектного маппинга, присущего ORM. Kysely — наиболее мощный типобезопасный построитель SQL-запросов для TypeScript, используемый в production компаниями Deno, Maersk и Cal.com; он рассчитан на современный TypeScript и не имеет накладных расходов в рантайме. Это тонкий слой абстракции над SQL, ориентированный на привычность через именование и структуру, а также на предсказуемость через компиляцию 1:1.

const users = await db
  .selectFrom('users')
  .innerJoin('posts', 'posts.author_id', 'users.id')
  .select(['users.id', 'users.name', 'posts.title'])
  .where('posts.title', 'like', '%test%')
  .execute();

Структура отражает SQL, но имена столбцов и таблиц проверяются по схеме на уровне типов. Как сформулировано в документации Kysely, построитель запросов даёт полный контроль над SQL, позволяя TypeScript выявлять ошибки на ранних этапах — ещё до запуска кода. Knex — давно зарекомендовавший себя вариант, работающий с Postgres, MySQL, SQLite и другими СУБД, однако стоит учитывать ограничения его типизации: согласно собственной документации, поддержка TypeScript реализована по принципу best-effort, не все паттерны использования поддаются проверке типов, а отсутствие ошибок типов не гарантирует корректность генерируемых запросов — поэтому написание тестов рекомендуется даже при использовании TypeScript. Для нового TS-проекта, которому нужна типобезопасность без тяжеловесности ORM, Kysely или Drizzle — более сильный выбор.

Правило принятия решений по сценарию

Подбирайте инструмент под конкретную задачу, а не выбирайте один для всей кодовой базы:

  1. Простые и умеренно сложные CRUD-операции, командная разработка, развивающаяся схема → ORM. Вы получаете параметризованные запросы, версионированные миграции, загрузку связей и сквозную типизацию. Это покрывает большую часть кода приложения.
  2. Отчётность, аналитика, массовые операции или измеренный горячий путь → raw SQL, представления или хранимые процедуры. Когда нужны оконные функции, сложная агрегация или вручную настроенные планы, проверенные через EXPLAIN ANALYZE, — пишите SQL напрямую.
  3. Нужна типобезопасность и контроль над SQL без ORM → построитель запросов. Drizzle или Kysely дают типизированные запросы в стиле SQL с предсказуемым выводом и без скрытого поведения N+1.

Гибридный подход, к которому приходит большинство production-приложений

На практике зрелые кодовые базы не выбирают одну сторону — они используют ORM для основной части работы и оставляют escape hatch для raw SQL на остальное. Prisma делает это явным через TypedSQL — функциональность Prisma ORM, которая позволяет писать raw SQL запросы в полностью типобезопасной манере. Вы пишете SQL в файле .sql и вызываете его через сгенерированную типизированную функцию:

import { getUsersByAge } from './generated/prisma/sql';
const users = await prisma.$queryRawTyped(getUsersByAge(18, 30));

Это preview-функциональность: её включают через previewFeatures = ["typedSql"] в блоке генератора, и поскольку она статически типизирована, она может не справляться с некоторыми сценариями, например с динамически формируемыми WHERE-условиями — для них по-прежнему нужны нетипизированные raw-методы. Prisma формулирует этот рабочий процесс именно так, как описано в данной статье: высокоуровневая абстракция для большинства запросов плюс типобезопасный escape hatch для случаев, когда SQL нужно составить вручную. Именно поэтому современный ответ на вопрос «ORM или raw SQL» обычно звучит так: «ORM (или построитель запросов), который позволяет при необходимости перейти к raw SQL».

Заключение

Используйте ORM или построитель запросов как основной инструмент для повседневных CRUD-операций, обращайтесь к raw SQL для запросов, где это измеримо оправдано, и рассматривайте оба подхода как слои единой стратегии доступа к данным, а не как конкурирующие лагеря. Конкретный следующий шаг для любого существующего проекта: включите логирование запросов, найдите эндпоинты со списками и дашбордами, посчитайте количество запросов на один запрос страницы и устраните N+1 с помощью жадной загрузки и более точной выборки столбцов — этот единственный проход, как правило, даёт больший прирост производительности, чем любой переход с ORM на SQL или обратно.

Часто задаваемые вопросы

В чём разница между ORM и построителем запросов?

ORM отображает таблицы базы данных на объекты и гидрирует результаты запросов в типизированные модели, автоматически обрабатывая связи и объектный маппинг, тогда как построитель запросов генерирует типизированный SQL, но возвращает обычные строки без слоя объектного маппинга. Prisma и TypeORM — это ORM; Drizzle, Kysely и Knex — построители запросов. Построители запросов находятся ближе к SQL, давая больше контроля над генерируемым запросом при меньшей абстракции и без скрытого поведения при загрузке связей.

Защищает ли ORM от SQL-инъекций?

Да, при обычном использовании. ORM и построители запросов параметризуют значения по умолчанию, передавая пользовательский ввод как связанные параметры, а не интерполируя его в строку запроса, — это устраняет классический вектор инъекций. Исключение составляют небезопасные методы для raw-запросов: документация Prisma предупреждает, что использование небезопасного метода с пользовательскими данными открывает возможность для SQL-инъекций. Безопасность raw SQL целиком зависит от вашей дисциплины при использовании плейсхолдеров вида $1 или ?.

Быстрее ли raw SQL, чем ORM?

В большинстве реальных случаев — нет. Современные ORM генерируют компетентный SQL для повседневных CRUD-операций, а типичный разрыв в производительности обусловлен количеством запросов, а не абстракцией. Основная причина медленных страниц — паттерн N+1, когда загрузка списка и связанных данных по одной строке создаёт сотни обращений к базе данных. К raw SQL стоит обращаться для отчётности, аналитики и измеренных горячих путей, где действительно нужны вручную настроенные планы выполнения.

Как увидеть реальный SQL, генерируемый ORM?

Каждый инструмент предоставляет прямой доступ к генерируемому SQL. Prisma логирует каждый запрос, если при инициализации клиента включить опцию логирования запросов. Kysely предоставляет метод compile, возвращающий строку SQL и её параметры. Drizzle предоставляет метод toSQL на запросе для той же цели. Получив SQL, выполните EXPLAIN ANALYZE в PostgreSQL, чтобы проверить план выполнения и убедиться, что индексы используются ожидаемым образом.

Можно ли писать raw SQL внутри проекта с ORM?

Да, и это распространённый паттерн в production. Большинство ORM предоставляют escape hatch для raw SQL наряду с генерируемыми запросами. Prisma предлагает TypedSQL — preview-функциональность, включаемую флагом typedSql, которая позволяет писать SQL в .sql-файле и вызывать его через сгенерированную полностью типизированную функцию. Поскольку она статически типизирована, она не поддерживает динамически формируемые WHERE-условия — для них по-прежнему нужны нетипизированные методы raw-запросов.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.