PrismaSeniorTechnical

Как оптимизировать запросы Prisma, чтобы избежать проблемы N+1?

Проблему N+1 в Prisma решают через include/select для eager loading, DataLoader для батчинга в GraphQL, findMany с оператором in вместо findUnique в цикле. Диагностика — через DEBUG=prisma:query.

Проблема N+1 в Prisma и способы её решения

Проблема N+1 возникает, когда для получения N связанных записей выполняется N отдельных запросов вместо одного. В Prisma она чаще всего проявляется в GraphQL-резолверах или при ручном обходе массивов результатов с обращением к БД внутри цикла.

Пример проблемы

// ПЛОХО: N+1 — для каждого поста отдельный запрос за автором
const posts = await prisma.post.findMany();
for (const post of posts) {
  const author = await prisma.user.findUnique({
    where: { id: post.authorId },
  });
  console.log(post.title, author?.name);
}

Решение 1: include

Самый простой способ — использовать include для eager loading связанных данных. Prisma выполнит JOIN или отдельный IN-запрос:

// ХОРОШО: один запрос с JOIN
const posts = await prisma.post.findMany({
  include: {
    author: true,
    tags: true,
  },
});

Решение 2: select — только нужные поля

select даёт точечный контроль над полями и экономит трафик:

const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    author: {
      select: { name: true, email: true },
    },
  },
});

Решение 3: Prisma DataLoader (для GraphQL)

В GraphQL-резолверах include не всегда помогает, потому что каждый резолвер вызывается отдельно. Используйте пакет @paljs/plugins или напишите собственный DataLoader с findMany + where: { id: { in: ids } }:

import DataLoader from 'dataloader';
import { prisma } from './prisma';

// DataLoader батчирует запросы в рамках одного event-loop тика
const userLoader = new DataLoader(async (ids: readonly number[]) => {
  const users = await prisma.user.findMany({
    where: { id: { in: [...ids] } },
  });
  // DataLoader требует порядок в точности как ids
  const userMap = new Map(users.map(u => [u.id, u]));
  return ids.map(id => userMap.get(id) ?? null);
});

// В резолвере:
const author = await userLoader.load(post.authorId);

Решение 4: $transaction для批量 операций

Когда нужно выполнить несколько независимых запросов, объедините их в $transaction — они выполнятся в одном round-trip:

const [posts, users] = await prisma.$transaction([
  prisma.post.findMany(),
  prisma.user.findMany(),
]);

Решение 5: findMany с фильтром вместо findUnique в цикле

// Собрать все ID авторов, потом один запрос
const posts = await prisma.post.findMany();
const authorIds = [...new Set(posts.map(p => p.authorId))];
const authors = await prisma.user.findMany({
  where: { id: { in: authorIds } },
});
const authorMap = new Map(authors.map(a => [a.id, a]));
const result = posts.map(p => ({
  ...p,
  author: authorMap.get(p.authorId),
}));

Диагностика

# включить логирование всех SQL-запросов
DEBUG="prisma:query" node server.js

Или через конструктор клиента:

const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'],
});

Подводные камни

  • include с глубокой вложенностью (3+ уровня) может генерировать медленные JOIN-ы. Иногда два отдельных запроса быстрее одного сложного.
  • Prisma при include на many-to-many делает отдельный IN-запрос, а не JOIN — это нормально, но объём данных может быть неожиданно большим.
  • DataLoader работает только в рамках одного тика event loop. Если резолверы выполняются асинхронно с await между вызовами — батчинг не сработает.
  • $transaction с массивом запросов не поддерживает интерактивные транзакции — для условной логики нужен callback-вариант $transaction(async (tx) => { ... }).
  • Использование select и include одновременно на одном уровне запрещено в Prisma — нужно выбрать одно из двух.
  • Логирование DEBUG=prisma:query в production снижает производительность — включайте только при отладке.
  • Без мониторинга запросов (APM, slow query log) N+1 легко пропустить на ревью кода — добавьте алерт на количество запросов за HTTP-запрос.

Common mistakes

  • Путает Prisma Client API с гарантиями базы данных: индексы, блокировки и isolation level не создаются магически.
  • Не объясняет, где в lifecycle находится оптимизация N+1.
  • Не разделяет validation, authorization, business logic и persistence.
  • Игнорирует ошибки, лимиты входных данных, observability и тестирование.

What the interviewer is testing

  • Может объяснить оптимизация N+1 на примере кода.
  • Называет ключевые API: include, relationLoadStrategy.
  • Отделяет ORM/query builder поведение от реального поведения СУБД.
  • Видит production-риски: безопасность, отказоустойчивость, логирование и тесты.

Sources

Related topics