
Путь разработчика от «Prisma — это магия» через «почему это так медленно?» к решению, которое сохраняет и DX, и производительность.
Проблема, которую я не увидел заранее
Несколько месяцев назад наш API работал отлично. Мы выбрали Prisma для работы с базой данных — и это действительно ощущалось как магия. Типобезопасные запросы? Есть. Автогенерация типов? Есть. Миграции, которые реально работают? Тоже есть.
А потом начал расти трафик.
Эндпоинт /dashboard стал уходить в таймаут. Аналитические запросы начали «ползти». Время ответа выросло до нескольких секунд.
В чём была проблема? Каждая операция чтения из базы занимала в 2–3 раза больше времени, чем должна была.
Дилемма разработчика
Вот о чём редко говорят, когда обсуждают Prisma: это архитектурный компромисс, а не волшебная таблетка.
Когда ты пишешь:
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
include: { posts: true }
})
Внутри происходит что-то такое:
TypeScript-запрос валидируется относительно схемы
Конвертируется во внутренний формат Prisma
Query Engine (TypeScript в v7, раньше Rust) обрабатывает его
Генерируется и выполняется SQL
Результаты возвращаются обратно через engine
Финально сериализуются в типизированные 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.
