Пошук уроків, статей та іншого контенту
Виявимо N+1 запити під час роботи зі зв’язками та оптимізуємо доступ до даних за допомогою Prisma.
Проблема N+1 виникає, коли для отримання одного набору даних виконується:
один запит для отримання N основних записів;
ще N запитів для отримання пов’язаних даних кожного запису.
Наприклад, потрібно отримати користувачів разом із їхніми публікаціями:
1 запит: отримати 100 користувачів
100 запитів: отримати публікації кожного користувача
-----------------------------------------------
101 запитКількість запитів залежить від кількості записів у результаті, а не від складності самої операції.
Це призводить до:
збільшення часу відповіді;
зайвого навантаження на базу даних;
великої кількості мережевих взаємодій між NestJS і базою;
проблем під час масштабування;
нестабільної продуктивності для великих наборів даних.
Важливо: N+1 — це не лише проблема повільного SQL. Навіть швидкий запит стає проблемою, якщо його виконати сотні або тисячі разів.
Нехай є користувачі та їхні публікації:
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}Між моделями існує зв’язок «один до багатьох»:
один User має багато Post;
кожен Post належить одному User.
Розглянемо сервіс, який спочатку отримує користувачів, а потім окремо завантажує публікації для кожного з них:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findAllWithPosts() {
const users = await this.prisma.user.findMany({
orderBy: {
id: 'asc',
},
});
return Promise.all(
users.map(async (user) => {
const posts = await this.prisma.post.findMany({
where: {
authorId: user.id,
},
orderBy: {
createdAt: 'desc',
},
});
return {
...user,
posts,
};
}),
);
}
}Якщо база повернула 100 користувачів, код виконає:
один findMany для користувачів;
100 findMany для публікацій.
Promise.all не усуває проблему. Він лише запускає всі додаткові запити паралельно:
await Promise.all(
users.map((user) => this.prisma.post.findMany(...)),
);Тобто замість послідовного виконання 100 запитів ми отримуємо одночасне виконання 100 запитів. Загальна кількість запитів залишається такою самою, а навантаження на базу може навіть зрости.
Для діагностики потрібно побачити, скільки запитів фактично виконується. Prisma можна налаштувати на надсилання подій для SQL-запитів.
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super({
log: [
{
emit: 'event',
level: 'query',
},
{
emit: 'stdout',
level: 'error',
},
],
});
this.$on('query', (event) => {
console.log(`[Prisma] ${event.duration} ms: ${event.query}`);
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}Під час виконання наївного методу в логах можна побачити один запит до User і багато подібних запитів до Post, які відрізняються лише значенням authorId.
Кількість запитів можна також рахувати в тестах або під час профілювання. Важливо перевіряти не тільки час відповіді, а й:
кількість SQL-запитів;
час кожного запиту;
обсяг отриманих даних;
поведінку для малих і великих наборів даних.
Типові ознаки:
запит до дочірньої моделі знаходиться всередині map, for...of або іншого циклу;
одна й та сама операція викликається для кожного елемента;
SQL-логи містять багато запитів із тією самою структурою;
кількість SQL-запитів збільшується разом із кількістю рядків;
локально метод працює швидко, але на реальних даних стає повільним.
includePrisma підтримує отримання пов’язаних записів через include:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findAllWithPosts() {
return this.prisma.user.findMany({
orderBy: {
id: 'asc',
},
include: {
posts: {
orderBy: {
createdAt: 'desc',
},
},
},
});
}
}Тепер Prisma отримує зв’язок як частину одного запиту до клієнта Prisma. Залежно від версії Prisma, конфігурації та стратегії завантаження зв’язків усередині можуть виконуватися один або кілька SQL-запитів. Але кількість запитів більше не залежить лінійно від кількості користувачів.
Головна відмінність:
Наївний варіант:
1 + N запитів
include:
фіксована кількість запитів для всієї операціїinclude підходить, коли потрібно повернути пов’язані записи як частину відповіді.
Не завжди потрібно повертати всі поля та всі публікації. Для зменшення обсягу даних можна використовувати select, where, orderBy і take всередині зв’язку.
async findUsersWithRecentPublishedPosts() {
return this.prisma.user.findMany({
orderBy: {
id: 'asc',
},
select: {
id: true,
name: true,
email: true,
posts: {
where: {
published: true,
},
orderBy: {
createdAt: 'desc',
},
take: 5,
select: {
id: true,
title: true,
createdAt: true,
},
},
},
});
}У цьому прикладі:
не завантажуються всі поля User;
вибираються лише опубліковані публікації;
для кожного користувача повертається максимум п’ять публікацій;
зайві поля Post не передаються в застосунок.
Це важливо для продуктивності, адже усунення N+1 не означає, що потрібно завантажувати весь граф пов’язаних даних.
include та selectinclude зручний, коли потрібно повернути повну модель і додати зв’язки:
const users = await prisma.user.findMany({
include: {
posts: true,
},
});select дає точніший контроль над відповіддю:
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
posts: {
select: {
id: true,
title: true,
},
},
},
});Не варто без потреби поєднувати надмірну кількість зв’язків в одному запиті:
const users = await prisma.user.findMany({
include: {
posts: {
include: {
comments: {
include: {
author: true,
},
},
},
},
profile: true,
roles: true,
},
});Такий запит може усунути N+1, але створити іншу проблему — завантажити надто великий обсяг даних. Оптимізація полягає не лише в зменшенні кількості запитів, а й у виборі необхідної структури результату.
Іноді потрібно отримати не всіх користувачів, а лише тих, у кого є опубліковані публікації. Це можна зробити без завантаження всіх користувачів у пам’ять:
async findAuthorsWithPublishedPosts() {
return this.prisma.user.findMany({
where: {
posts: {
some: {
published: true,
},
},
},
select: {
id: true,
name: true,
posts: {
where: {
published: true,
},
select: {
id: true,
title: true,
},
},
},
});
}Тут some фільтрує користувачів за наявністю хоча б однієї пов’язаної публікації.
Важливо розрізняти:
where на рівні user визначає, які користувачі потраплять у результат;
where всередині posts визначає, які публікації будуть вкладені в кожного користувача.
Пагінація обмежує кількість основних записів, але сама по собі не усуває N+1.
Цей код усе ще має проблему:
const users = await this.prisma.user.findMany({
skip: 0,
take: 20,
});
return Promise.all(
users.map(async (user) => {
const posts = await this.prisma.post.findMany({
where: {
authorId: user.id,
},
});
return {
...user,
posts,
};
}),
);Якщо сторінка містить 20 користувачів, це все одно 21 запит.
Оптимізований варіант:
async findPage(page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
return this.prisma.user.findMany({
skip,
take: pageSize,
orderBy: {
id: 'asc',
},
select: {
id: true,
name: true,
posts: {
select: {
id: true,
title: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
take: 10,
},
},
});
}Тут кількість основних користувачів контролюється пагінацією, а кількість публікацій для кожного користувача також обмежена.
Для API з великими колекціями потрібно окремо продумати пагінацію вкладених зв’язків. Поле posts без обмеження може повернути тисячі записів для одного користувача.
include не завжди є найкращим варіантом. Наприклад, потрібно отримати список користувачів і додаткову агреговану інформацію про їхні публікації.
У такому випадку можна виконати два пакетні запити:
async findUsersWithPostCounts() {
const users = await this.prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
},
orderBy: {
id: 'asc',
},
});
const postCounts = await this.prisma.post.groupBy({
by: ['authorId'],
where: {
published: true,
},
_count: {
_all: true,
},
});
const countsByAuthorId = new Map(
postCounts.map((item) => [item.authorId, item._count._all]),
);
return users.map((user) => ({
...user,
publishedPostsCount: countsByAuthorId.get(user.id) ?? 0,
}));
}Цей варіант виконує:
один запит для користувачів;
один агрегований запит для кількості публікацій;
об’єднання результатів у пам’яті.
Кількість запитів залишається сталою — два — незалежно від кількості користувачів.
Такий підхід може бути кращим за include, якщо потрібні лише лічильники або інші агрегати, а не самі пов’язані записи.
Для зв’язків у Prisma зазвичай використовують одну з таких стратегій.
includeВикористовуйте, коли:
потрібно повернути пов’язані записи;
структура відповіді відповідає структурі моделей;
кількість вкладених даних контрольована.
const users = await prisma.user.findMany({
include: {
posts: true,
},
});selectВикористовуйте, коли:
потрібні лише окремі поля;
важливо контролювати розмір відповіді;
API не має повертати внутрішні поля моделей.
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
posts: {
select: {
id: true,
title: true,
},
},
},
});Використовуйте, коли:
потрібні лічильники;
потрібно обчислити статистику;
повні пов’язані записи не потрібні;
вкладена структура є надто великою.
Після зміни коду потрібно перевірити не тільки його типи та результат, а й фактичну кількість запитів.
Практичний порядок перевірки:
Увімкнути логування Prisma.
Викликати endpoint на невеликій кількості даних.
Порахувати SQL-запити.
Повторити перевірку для десятків і сотень записів.
Переконатися, що кількість запитів не зростає пропорційно до N.
Перевірити обсяг отриманих полів і вкладених колекцій.
Перевірити час відповіді під навантаженням.
Коректна оптимізація має зберігати сталу або контрольовану кількість запитів:
Було:
1 + N
Стало:
1–кілька запитів для всієї операціїТочна кількість SQL-запитів може залежати від версії Prisma та обраної стратегії завантаження зв’язків. Тому остаточний висновок потрібно робити за логами, а не лише за виглядом коду.
Promise.all як «оптимізації»await Promise.all(
items.map((item) => prisma.post.findMany(...)),
);Це паралелізує запити, але не прибирає N+1. Для великого N такий код може перевантажити пул з’єднань бази даних.
for (const user of users) {
user.posts = await prisma.post.findMany({
where: { authorId: user.id },
});
}Це класичний N+1. Запит до зв’язку потрібно винести на рівень пакетної операції.
includeinclude: {
posts: {
include: {
comments: {
include: {
author: true,
},
},
},
},
}Так можна зменшити кількість запитів, але отримати надмірно великий результат. Потрібно обмежувати поля, кількість записів і рівень вкладеності.
Навіть оптимізований запит може бути повільним, якщо повертає всі публікації, коментарі та інші пов’язані записи для кожного користувача.
Для колекцій варто розглядати:
take;
фільтрацію через where;
сортування через orderBy;
вибір лише потрібних полів через select;
окрему пагінацію.
Зменшення кількості SQL-запитів не гарантує оптимальний результат. Один запит, який повертає мільйони рядків або надто багато колонок, також може бути проблемою.
Оцінюйте одночасно:
кількість запитів;
обсяг даних;
час виконання;
споживання пам’яті;
розмір HTTP-відповіді.
N+1 — це один запит для основних записів і по одному додатковому запиту для кожного запису.
Promise.all не усуває N+1, а лише запускає додаткові запити паралельно.
Для зв’язків у Prisma використовуйте include або вкладений select.
Фільтруйте та обмежуйте пов’язані записи безпосередньо в Prisma-запиті.
Для лічильників і статистики часто краще виконати пакетний агрегований запит.
Перевіряйте оптимізацію за логами Prisma та фактичною кількістю SQL-запитів.
Усунення N+1 не повинно призводити до завантаження надмірного обсягу даних.