Путь разработчика от «Prisma — это магия» через «почему это так медленно?» к решению, которое сохраняет и DX, и производительность.

Проблема, которую я не увидел заранее

Несколько месяцев назад наш API работал отлично. Мы выбрали Prisma для работы с базой данных — и это действительно ощущалось как магия. Типобезопасные запросы? Есть. Автогенерация типов? Есть. Миграции, которые реально работают? Тоже есть.

А потом начал расти трафик.

Эндпоинт /dashboard стал уходить в таймаут. Аналитические запросы начали «ползти». Время ответа выросло до нескольких секунд.

В чём была проблема? Каждая операция чтения из базы занимала в 2–3 раза больше времени, чем должна была.

Дилемма разработчика

Вот о чём редко говорят, когда обсуждают Prisma: это архитектурный компромисс, а не волшебная таблетка.

Когда ты пишешь:

const users = await prisma.user.findMany({
  where: { status: 'ACTIVE' },
  include: { posts: true }
})

Внутри происходит что-то такое:

  1. TypeScript-запрос валидируется относительно схемы

  2. Конвертируется во внутренний формат Prisma

  3. Query Engine (TypeScript в v7, раньше Rust) обрабатывает его

  4. Генерируется и выполняется SQL

  5. Результаты возвращаются обратно через engine

  6. Финально сериализуются в типизированные JavaScript-объекты

Это много слоёв.

Даже с TypeScript движком в v7 каждый шаг добавляет оверхэд. Движок обеспечивает безопасность и корректность, но каждая валидация и трансформация стоит времени. На простых запросах это незаметно. Но когда ты запускаешь сложную аналитику по 100k строкам или ��бслуживаешь 1000 запросов в минуту — эти миллисекунды превращаются в реальные проблемы.

Варианты, которые я рассмотрел (и отклонил)

Когда я поднял эту тему в команде, предложения были предсказуемыми.

Вариант 1: «Используй сырой SQL для медленных запросов»

Да, но тогда я теряю типобезопасность. Нужно поддерживать две разные системы запросов. Новым людям в команде придётся разбираться в обеих.

Вариант 2: «Переходи на Drizzle / Kysely / другую ORM»

Отличные инструменты, но это месяцы миграции. Переписывание сотен запросов, обновление типов, переобучение команды и риск привнести новые баги. На это просто не было ресурсов.

Вариант 3: «Агрессивное кеширование»

Мы попробовали. Redis помог, но инвалидация кеша стала отдельной головной болью. К тому же это не решает фундаментальную проблему — при cache miss запросы всё равно остаются медленными.

Землетрясение и идея

28 марта 2025 года мощное землетрясение магнитудой 7.7 произошло в Мьянме возле Мандалая. Погибли более 3000 человек, были серьёзные разрушения. Толчки дошли более чем на 1000 километров до Бангкока — мягкие почвы усилили сейсмические волны. Здания раскачивались, 33-этажная стройка обрушилась, погибли 95 человек, в городе ввели режим ЧС.

Я схватил ноутбук и уехал из города в Bang Kachao — зелёную зону через реку, где можно было спокойно думать без постоянных уведомлений о повторных толчках.

Проблема производительности Prisma не давала мне покоя неделями. Сидя под деревом с плохим интернетом, я начал формулировать идею: а что если генерировать SQL на этапе сборки?

Концепция была простой: во время prisma generate анализировать схему и создавать оптимизированные SQL-запросы для частых операций. Сохранять их в generated-папку. А в runtime просто выполнять готовый SQL напрямую.

Я начал писать код. Без встреч, без отвлечений — только я и проблема.

Чего я тогда не осознавал — насколько это окажется сложно. Построить корректный SQL-компилятор для ORM с 44.8k звёздами на GitHub, которой доверяют production-базы, оказалось гораздо тяжелее, чем я ожидал. Потребовался почти год разработки, огромное количество edge-case’ов и комплексное тестирование, прежде чем я был достаточно уверен, чтобы выпустить решение.

Первая (провальная) попытка

Изначально я попытался перехватывать debug-запросы Prisma.

Prisma умеет логировать генерируемый SQL:

const prisma = new PrismaClient({
  log: [{ emit: 'event', level: 'query' }]
})
prisma.$on('query', (e) => {
  console.log('Query: ' + e.query)
  console.log('Params: ' + e.params)
})

Я подумал: «Если я могу получить эти SQL-строки, то смогу выполнять их напрямую через postgres.js и полностью обойти engine».

Это не сработало.

Возникли фундаментальные проблемы с параметрами и жёсткой привязкой к прегенерированным строкам. Стало понятно — это тупиковый путь.

Пришлось вернуться к проектированию.

Прорыв

Через несколько месяцев у меня появился рабочий прототип, который генерировал SQL на этапе сборки. Но когда я сделал бенчмарки, произошло неожиданное: даже runtime-генерация SQL (без прегенерации) оказалась заметно быстрее, чем прохождение через Prisma engine.

Прегенерация была полезной оптимизацией, но реальное узкое место оказалось не в генерации SQL — а во всех слоях engine.

Генерируя SQL напрямую из Prisma query format и выполняя его через postgres.js, я обходил весь overhead.

Ключевой инсайт: Prisma публикует полные метаданные схемы через DMMF (Data Model Meta Format). Используя эту информацию, я смог конвертировать Prisma-запросы напрямую в SQL, полностью минуя engine.

Построение решения

Следующие месяцы ушли на доводку:

  • Query builder, который конвертирует Prisma query format напрямую в оптимизированный SQL

  • Прямое выполнение через postgres.js (позже — better-sqlite3)

  • Prisma extension для бесшовного перехвата read-операций

  • Поддержка двух режимов: runtime-генерация SQL и build-time прегенерация

Цель была прагматичной: оставить Prisma там, где она сильна (миграции, схема, типы, записи), но выполнять read-запросы напрямую через нативный драйвер базы.

Я написал много тестов. 137 E2E-кейсов, покрывающих все паттерны запросов, которые смог придумать. Всё валидировалось против Prisma v6, v7 и Drizzle, чтобы гарантировать корректность.

Интеграция в одну строку

Вот что изменилось в коде.

Было:

const prisma = new PrismaClient()

Стало:

import { speedExtension, convertDMMFToModels } from 'prisma-sql'
import postgres from 'postgres'
import { Prisma } from '@prisma/client'

const sql = postgres(process.env.DATABASE_URL)
const models = convertDMMFToModels(Prisma.dmmf.datamodel)
const prisma = new PrismaClient().$extends(
  speedExtension({ postgres: sql, models })
)

Вот и всё. Семь строк настройки. Без переписывания запросов. Без миграций.

Результаты

Результаты оказались впечатляющими.

Сложные агрегации:

  • Было: ~800ms

  • Стало: ~150ms

  • Ускорение: 5–7x

Вложенные отношения:

  • Было: ~230ms

  • Стало: ~90ms

  • Ускорение: 2.5x

Простые выборки:

  • Было: ~45ms

  • Стало: ~18ms

  • Ускорение: 2.5x

Count для пагинации:

  • Было: ~150ms

  • Стало: ~22ms

  • Ускорение: 6–7x

P95 response time упал с >1 секунды до <400ms. Это сразу стало видно в мониторинге.

Для PostgreSQL это типичные цифры. SQLite показал ещё более сильный эффект — в среднем 5.5x, а некоторые операции доходили до 7x.

https://www.npmjs.com/package/prisma-sql