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