Проблема N+1 запросов: как найти и исправить
Практика4 минРедакция Innovialabs
Коротко
N+1 — это когда код делает один запрос за списком и ещё по запросу на каждый элемент; исправляется загрузкой связанных данных заранее одним-двумя запросами через eager loading, JOIN или батчинг.
Что такое N+1 и почему это проблема
N+1 — ситуация, когда приложение делает 1 запрос за списком записей и потом N отдельных запросов — по одному на каждую запись, чтобы подгрузить связанные данные.
Пример: страница со 100 постами, у каждого показывается автор. Код получает посты одним запросом, а затем в цикле запрашивает автора для каждого поста. Итого 101 запрос вместо 1–2.
На локальной базе с десятью записями это незаметно. В продакшене, где база стоит на другом сервере и каждый запрос — это сетевой round-trip, страница начинает тормозить, а нагрузка на БД растёт вместе с размером списка.
Как ORM создают N+1 незаметно
ORM удобна тем, что связи выглядят как обычные поля объекта. Но обращение к такому полю может выполнить запрос.
Django:
posts = Post.objects.all()[:100]
for post in posts:
print(post.author.name) # запрос к БД на каждой итерации
Laravel Eloquent:
$posts = Post::take(100)->get();
foreach ($posts as $post) {
echo $post->author->name; // lazy loading: запрос на каждый пост
}
Prisma и TypeORM не подгружают связи сами, поэтому N+1 там чаще появляется явно — когда запрос пишут внутри цикла или внутри Promise.all по списку:
const posts = await prisma.post.findMany({ take: 100 });
const result = await Promise.all(
posts.map((p) => prisma.user.findUnique({ where: { id: p.authorId } }))
);
Отдельный источник N+1 — GraphQL: резолвер поля author вызывается для каждого поста отдельно.
Как найти N+1
- Логи SQL. Включите логирование запросов в dev-окружении. Признак N+1 — серия одинаковых запросов, которые отличаются только параметром
id. - APM и трейсинг. В трейсе запроса N+1 выглядит как «лесенка» из десятков одинаковых коротких спанов БД внутри одного HTTP-запроса.
- Инструменты фреймворка. Django Debug Toolbar показывает число запросов и дубли. В Laravel можно включить
Model::preventLazyLoading()в dev-режиме — тогда ленивая загрузка вызовет исключение. - Тесты на количество запросов. Например,
assertNumQueriesв Django: тест падает, если эндпоинт начал делать больше запросов, чем ожидалось.
Как исправить
Eager loading
Скажите ORM заранее, какие связи нужны:
| ORM | Решение |
|---|---|
| Django | select_related("author") для ForeignKey (JOIN), prefetch_related("tags") для M2M и обратных связей (отдельный запрос с IN) |
| Laravel Eloquent | Post::with('author')->take(100)->get() |
| Prisma | prisma.post.findMany({ take: 100, include: { author: true } }) |
| TypeORM | find({ relations: { author: true } }) или leftJoinAndSelect в QueryBuilder |
До: 1 запрос за постами + 100 за авторами = 101.
После: 2 запроса (посты + авторы через IN) или 1 запрос с JOIN.
JOIN вручную
Если нужны только пара полей, проще написать один запрос:
SELECT p.id, p.title, u.name AS author_name
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 100;
Батчинг и DataLoader
Когда связи загружаются в разных местах кода (GraphQL-резолверы, сервисы), используют DataLoader: он собирает все id, запрошенные за один тик, и делает один запрос WHERE id IN (...).
const userLoader = new DataLoader(async (ids: readonly number[]) => {
const users = await prisma.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => byId.get(id) ?? null);
});
Важно: DataLoader должен вернуть результаты в том же порядке, что и ids. Создавайте новый экземпляр на каждый входящий запрос, чтобы кеш не смешивал данные разных пользователей.
Частые ошибки при исправлении
- Загрузить всё подряд.
includeна пять уровней вложенности тянет лишние данные — это уже другая проблема производительности. - JOIN на связи «один ко многим» с пагинацией. Строки дублируются, и
LIMITсрезает не посты, а строки результата. Для таких связей лучше отдельный запрос сIN. - Исправить один эндпоинт и забыть про остальные. Без тестов на количество запросов N+1 возвращается при следующем изменении.