50 лет борьбы между удобством и производительностью
В 1974 году, когда SQL только вышел из исследовательских лабораторий IBM, работа с базами данных выглядела просто: разработчик писал запрос и получал результат. Без слоёв, абстракций и фреймворков — только строки, описывающие нужные данные.
Эта прямота дорого обходилась. Переименование колонки превращалось в поиск по тысячам строк кода в надежде отловить все упоминания. Неаккуратная работа с пользовательским вводом приводила к SQL-инъекциям. Миграция с Oracle на PostgreSQL часто означала переписывание значительной части запросов из-за различий диалектов.
К середине 1990-х проблема стала настолько заметной, что начали появляться Object-Relational Mapper’ы (ORM). Идея выглядела привлекательно: работать с таблицами как с объектами, писать код на «родном» языке программирования вместо SQL-строк, а перевод на SQL оставлять фреймворку.
Первая революция ORM (1996–2005)
Hibernate появился в 2001 году для Java. ActiveRecord стал одной из ключевых причин популярности Rails в 2004-м. Разработчики быстро к этому привыкли. Код, который раньше выглядел так:
-- Разбросано по всей кодовой базе
SELECT * FROM users WHERE email = ?
SELECT * FROM posts WHERE user_id = ?
SELECT * FROM comments WHERE post_id = ?
превратился в:
user = User.find_by_email("user@example.com")
posts = user.posts
comments = post.comments
Типобезопасно (в рамках возможностей языка и фреймворка). Удобно рефакторится. Читается приятно.
Но довольно быстро стало ясно, что за удобство приходится платить производительностью.
Проблема N+1
Классический паттерн проявился практически во всех ORM:
posts = Post.limit(10) # 1 запрос
posts.each do |post|
puts post.author.name # ещё 10 запросов
end
ORM подгружал автора отдельно для каждого поста. То, что могло уместиться в два запроса, превращалось в одиннадцать. На больших объёмах это легко «убивало» приложение.
Обычно спасал eager loading:
posts = Post.includes(:author).limit(10)
Но в этот момент становилось ясно, что абстракция «протекает»: помимо языка программирования требовалось понимать и ORM-специфичные приёмы оптимизации.
На Stack Overflow и в issue-трекерах стало много вопросов про то, как «правильно» оптимизировать ORM-запросы. ORM обещал спрятать сложность SQL — на практике добавлялся второй слой сложности поверх первого.
Золотая середина query builder’ов (2010–2015)
Не всем подходили «тяжёлые» ORM. Knex.js (2012) шёл с другой философией: SQL не скрывался, а становился композируемым.
knex('users')
.where('status', 'active')
.andWhere('age', '>=', 18)
.join('posts', 'users.id', 'posts.user_id')
.select('users.*', 'posts.title')
Такой подход выглядел чище, чем конкатенация строк, и гибче, чем полноценные ORM. Но запрос всё ещё собирался во время выполнения, а опечатка в названии колонки по-прежнему обнаруживалась только в runtime.
Параллельно набирал популярность MongoDB с простым посылом: «меньше борьбы с ORM — больше JSON». Казалось, что ORM постепенно уходят, SQL — это прошлое, а будущее за schemaless.
Потом начали накапливаться production-инциденты: несогласованность данных, потерянные записи из-за отсутствия foreign key, повреждённое состояние из-за слабых или отсутствующих транзакций, запросы, сканирующие коллекции целиком из-за отсутствия привычного query planner’а.
Паттерн повторился: многие команды вернулись с MongoDB на PostgreSQL. Выяснилось, что данные почти всегда имеют схему — просто в schemaless-подходе она оказывается хуже зафиксированной и хуже применённой.
Отсюда выглядел естественным вывод: «медленно» часто становилось не из-за SQL как такового, а из-за количества слоёв между приложением и базой данных.
Эксперимент с GraphQL (2015–2020)
Facebook открыл GraphQL в 2015 году с сильной идеей: клиент сам описывает форму данных, которую хочет получить. Один запрос — и данные «идеальной формы».
query {
user(id: 1) {
name
posts(limit: 10) {
title
comments { text }
}
}
}
Но этот запрос всё равно нужно было перевести в обращения к базе данных. Резолверы быстро начали напоминать мини-ORM — и снова всплывала проблема N+1:
// Каждый резолвер выполняется отдельно
User: {
posts: (parent) => db.query('SELECT * FROM posts WHERE user_id = ?', parent.id)
}
Post: {
comments: (parent) => db.query('SELECT * FROM comments WHERE post_id = ?', parent.id)
}
Появился DataLoader для батчинга. Затем — анализ сложности запросов, чтобы не допускать слишком дорогих операций. Затем — persisted queries, чтобы убрать парсинг. Экосистема строила дополнительные слои, чтобы компенсировать проблемы, созданные слоями.
На практике GraphQL нередко вёл к необходимости rate limiting: пользователи могли запросить слишком много данных. Гибкость тоже имела цену.
Ренессанс TypeScript (2018–2024)
TypeORM (2016) и MikroORM (2018) принесли декораторы и более выраженную типизацию в Node.js:
@Entity()
class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@OneToMany(() => Post, post => post.author)
posts: Post[];
}
Компилятор помогал. Но модель оставалась классической: классы, active record, выбор между eager и lazy loading.
Drizzle ORM (2022) выбрал другой путь: SQL-подобный синтаксис с выводом типов в TypeScript.
const result = await db
.select()
.from(users)
.where(eq(users.status, 'active'))
.leftJoin(posts, eq(users.id, posts.userId))
Это было ближе к SQL и обычно быстрее традиционных ORM. Но сборка запроса всё равно происходила в runtime и опиралась на цепочки вызовов.
После этого на сцене закрепилась Prisma.
Другая ставка Prisma (2018–2024)
Prisma 1.0 вышла в 2018 году, а Prisma 2.0 (2020) предложила заметно иную модель. Без классов и декораторов, без «классического» method chaining — схема и сгенерированный клиент:
model User {
id Int @id
email String @unique
posts Post[]
}
Запросы выглядели декларативно:
prisma.user.findMany({
where: { status: 'ACTIVE', age: { gte: 18 } },
include: { posts: { where: { published: true } } },
orderBy: { createdAt: 'desc' },
take: 10
})
TypeScript «знает» поля, связи, фильтры. Переименование колонки в схеме приводило к ошибкам компиляции во всех местах использования. Сюрпризов в runtime становилось меньше.
Это многим понравилось: типобезопасность без ручного обслуживания схемы в коде, миграции без постоянного ручного SQL, IntelliSense, который действительно помогает.
Со временем в issues стали всплывать вопросы про производительность. В типичных сценариях чтения запросы через Prisma могли выполняться заметно медленнее, чем через чистый драйвер (postgres.js/pg). Prisma улучшала ситуацию от релиза к релизу; в Prisma 7, например, появился новый акцент на характеристиках движка.
Разрыв, однако, оставался. Причина выглядела не в качестве реализации, а в необходимости слоя трансляции.
Другой подход
Если смотреть на запрос Prisma как на структуру, он очень близок к SQL:
{
where: { status: 'ACTIVE', age: { gte: 18 } },
include: { posts: { where: { published: true } } },
orderBy: { createdAt: 'desc' },
take: 10
}
Почти 1:1:
where→ WHEREinclude→ JOIN или подзапросorderBy→ ORDER BYtake→ LIMIT
Но одного JSON недостаточно. Ключевым элементом Prisma становится DMMF (Data Model Meta Format) — метаданные схемы:
{
models: [{
name: "User",
fields: [
{ name: "id", type: "Int", isRequired: true },
{ name: "email", type: "String", isRequired: true },
{
name: "posts",
type: "Post",
isRelation: true,
relationName: "UserPosts",
foreignKey: ["userId"],
references: ["id"]
}
]
}]
}
DMMF описывает:
список полей и их типы
какие поля являются связями и как они устроены
маппинг внешних ключей
обязательность/опциональность
массивы vs скалярные типы
Без DMMF генерация SQL превращалась бы в угадывание: является ли posts полем или связью, какая колонка выступает внешним ключом, какого типа age.
С DMMF всё становится детерминированным: JSON запроса + метаданные схемы дают полную спецификацию, достаточную для генерации SQL.
Отсюда возникает гипотеза: если SQL можно строить напрямую из JSON + DMMF, то отдельный query engine для части операций чтения превращается в накладные расходы.
Проверка гипотезы
Реализация получилась как аккуратное сопоставление «формы запроса» со стандартными конструкциями SQL.
WHERE: рекурсивный обход дерева условий. { AND: [...] } превращается в (...) AND (...). Операторы отображаются напрямую: gte → >=, contains → LIKE '%value%', in → IN (...).
Связи: каждый include становится JSON-агрегационным подзапросом. DMMF даёт маппинг внешних ключей. В Postgres используется json_agg(), в SQLite — json_group_array(). Объединение выполняется на стороне базы, а не в коде приложения.
Пагинация: cursor + orderBy превращаются в условия, которые корректно позиционируют выборку «после курсора». Это сложнее, чем OFFSET, но устойчиво при изменениях данных.
Диалекты: Postgres и SQLite отличаются в операциях с массивами, регистронезависимом поиске и работе с JSON. Различия изолируются в функции вроде arrayContains() и caseInsensitiveLike(). SQL-сборщик опирается на них, не размазывая диалектные особенности по всему коду.
Результат — около 6000 строк чистых функций: одинаковый вход даёт одинаковый выход. Детерминизм становится важным, если запросы хочется предварительно подготовить на этапе сборки.
Насколько это выглядит просто
Полная миграция для ускорения чтений укладывается в небольшой набор изменений.
Шаг 1: установка
npm install prisma-sql postgres
Шаг 2: несколько строк в коде
import { PrismaClient, Prisma } from '@prisma/client'
import { speedExtension, convertDMMFToModels } from 'prisma-sql'
import postgres from 'postgres'
const models = convertDMMFToModels(Prisma.dmmf.datamodel)
const sql = postgres(process.env.DATABASE_URL)
const prisma = new PrismaClient().$extends(speedExtension({ postgres: sql, models }))
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
include: { posts: true }
})
Поведение по методам:
findMany,findFirst,findUnique,count,aggregate,groupBy→ быстрый путь (прямой SQL)create,update,delete, транзакции → обычная Prisma без изменений
Примеры остаются прежними:
await prisma.user.findMany({ where: { status: 'ACTIVE' } })
await prisma.user.create({ data: { email: 'user@example.com' } })
await prisma.user.findMany({
where: { status: 'ACTIVE' },
include: {
posts: {
where: { published: true },
include: { comments: true }
}
}
})
Наблюдение и логирование (опционально)
const prisma = new PrismaClient().$extends(
speedExtension({
postgres: sql,
models,
debug: true,
onQuery: (info) => {
console.log(`${info.model}.${info.method}: ${info.duration}ms`)
}
})
)
Режим генератора для минимальных накладных расходов (опционально)
В схеме можно описать «форму» горячих запросов:
/// @optimize {
/// "method": "findMany",
/// "query": { "where": { "status": "" }, "take": "$take" }
/// }
model User {
id Int @id
status String
}
После prisma generate запросы этого класса выполняются с меньшими накладными расходами за счёт предзаготовленного SQL.
Проверка реальностью
Тестирование на 137 запросах: сравнение Prisma v7, Drizzle ORM и прямой генерации SQL.
PostgreSQL (выбранные запросы):
Тип запроса |
Prisma v7 |
Drizzle |
Prisma-SQL |
vs Prisma |
vs Drizzle |
|---|---|---|---|---|---|
Простой WHERE |
0.34ms |
0.24ms |
0.17ms |
2.0x |
1.4x |
Сложные условия |
6.90ms |
5.58ms |
2.37ms |
2.9x |
2.4x |
Со связями |
0.72ms |
N/A* |
0.41ms |
1.8x |
- |
Вложенные связи |
14.34ms |
N/A* |
4.81ms |
3.0x |
- |
Многопольная ORDER BY |
2.38ms |
1.54ms |
1.09ms |
2.2x |
1.4x |
SQLite (выбранные запросы):
Тип запроса |
Prisma v7 |
Drizzle |
Prisma-SQL |
vs Prisma |
vs Drizzle |
|---|---|---|---|---|---|
Простой WHERE |
0.23ms |
0.10ms |
0.03ms |
7.7x |
3.3x |
Сложные условия |
3.87ms |
1.85ms |
0.93ms |
4.2x |
2.0x |
Фильтры связей |
128.44ms |
N/A* |
2.40ms |
53.5x |
- |
Многопольная ORDER BY |
0.59ms |
0.43ms |
0.37ms |
1.6x |
1.2x |
* У Drizzle эти паттерны выражаются иначе; обычно требуются ручные подзапросы.
Сводка по сравнению:
Против Prisma: устойчивое ускорение в 2–7 раз за счёт обхода query engine в чтениях.
Против Drizzle: Drizzle уже строит SQL напрямую, но цепочки построения и нормализация всё равно дают накладные расходы. Прямой подход выигрывает примерно в 1.4–3.3 раза на сопоставимых запросах. Дополнительно у Drizzle нет прямого аналога include в стиле Prisma — вложенные связи чаще требуют ручных подзапросов или нескольких round-trip’ов.
Связи: разрыв наиболее заметен. Prisma-синтаксис include очень удобен, но через движок он может стоить дорого. Прямая генерация SQL с JSON-агрегационными подзапросами сохраняет опыт разработки и приближает чтения к скорости «чистого SQL».
Для наглядности путь выполнения выглядит так:
Prisma: JavaScript → протокол query engine → трансляция → генерация SQL → postgres.js → база данных
Drizzle: JavaScript → разрешение цепочки → сборка SQL → postgres.js → база данных
Прямой (runtime): JavaScript → генерация SQL (~0.2ms) → postgres.js → база данных
Прямой (generator): JavaScript → поиск по ключу (~0.03ms) → postgres.js → база данных
Режим генератора: минимальные накладные расходы
Генерация SQL в runtime быстрая (~0.2ms), но для горячих путей даже это бывает заметно. Директива @optimize позволяет заранее подготовить SQL для заданных «форм» запросов.
/// @optimize {
/// "method": "findMany",
/// "query": {
/// "where": { "status": "" },
/// "orderBy": { "createdAt": "desc" },
/// "take": "$take",
/// "skip": "$skip"
/// }
/// }
model User {
id Int @id
status String
createdAt DateTime
}
Значения в директиве выполняют роль заполнителей: важна структура (поля, операторы, наличие параметров), а реальные значения остаются параметризованными.
В результате появляется предзаготовленная таблица:
const QUERIES = {
User: {
findMany: {
'{"where":{"status":""},"orderBy":{"createdAt":"desc"},"take":"$take","skip":"$skip"}': {
sql: 'SELECT * FROM users WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3',
params: [],
dynamicKeys: ['status', 'take', 'skip']
}
}
}
}
Runtime-часть сводится к нормализации формы запроса, O(1) поиску и извлечению параметров.
Безопасность сохраняется: значения не попадают в SQL-строку, а передаются отдельно драйверу. Это оставляет параметризацию и исключает SQL-инъекции на этом уровне.
Ограничения
Ускоряются только чтения. Записи (
create,update,delete) и транзакции остаются в Prisma клиенте.Зависимость от postgres.js или better-sqlite3. Подход завязан на конкретные драйверы.
Режим генератора требует знания «горячих» форм запросов. Сильно динамические сценарии остаются в runtime-генерации.
Установка: npm install prisma-sql postgres
Репо: https://github.com/multipliedtwice/prisma-to-sql
Комментарии (12)

Akuma
19.01.2026 18:23О господи. Статья не про ИИ и вместо ссылки на телегу - ссылка на гитхаб.
А по делу: для себя перепробовал многие эти подходы и остановился на чистом sql. В typescript через template strings очень удобно. Экранирование, задание возвращаемого типа.
Конечно приходится следить за опечатками в названии полей и иногда лезть в бд и вспоминать их, но по скорости - нет равных. Запрос любой сложности как на ладони.

anisimovih
19.01.2026 18:23Мне кажется sql-билдеры должны кэшировать большую часть, оставляя рантайму буквально то же форматирование строки. Но в замен предоставляя полную типо-безопасность и встроенную защиту от инъекций.

oookkdjjjdjdj
19.01.2026 18:23Если честно, удивляет только одно, почему это не сделали раньше внутри самой Prisma

WinnerSonX
19.01.2026 18:23Hibernate это худшее что могло случиться с Java как минимум,юзаю только r2dbc,никаких orm

ARAmiss
19.01.2026 18:23
thousandsofraccoons Автор
19.01.2026 18:23Действительно, спасибо
https://github.com/multipliedtwice/prisma-to-sql

anisimovih
19.01.2026 18:23Звучало отлично, думал это новая фича призмы, как недавние нативные join (если кто не знал, всё это время они выполнялись в рантайме, хоть и в rust части движка). Но пробовать либу без единой звёзды в проде никто не даст. Буду следить за успехами!

thousandsofraccoons Автор
19.01.2026 18:23Поставьте звезду, будьте первым ж)
AccountForHabr
Я вижу у Вас в примере - include: { posts: true }. Чем это лучше Post.includes(:author).limit(10) ? и нет ли тут протекающих абстракций?
thousandsofraccoons Автор
Да, абстракция протекает. Но:
ActiveRecord протекает в синтаксисе:
.includes vs .joins vs .eager_load
Какой использовать? Нужно знать SQL
Когда LEFT JOIN, когда подзапрос? Нужно знать реализацию
Prisma протекает в производительности:
include всегда работает одинаково
Но может быть медленным
Отсюда и появился prisma-sql
Prisma уменьшила "протечку" с уровня синтаксиса до уровня производительности. Разработчик пишет рабочий код без знания SQL. Но для оптимизации всё равно нужно понимать, что происходит под капотом.
prisma-sql пытается заткнуть последнюю "протечку" - производительность - не увеличивая сложность API. Та же декларативность, просто быстрее.