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 → WHERE

  • include → JOIN или подзапрос

  • orderBy → ORDER BY

  • take → 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>=, containsLIKE '%value%', inIN (...).

Связи: каждый 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