Перейти к содержанию
Innovialabs

Проблема N+1 запросов: как найти и исправить

Практика4 мин

Коротко

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Решение
Djangoselect_related("author") для ForeignKey (JOIN), prefetch_related("tags") для M2M и обратных связей (отдельный запрос с IN)
Laravel EloquentPost::with('author')->take(100)->get()
Prismaprisma.post.findMany({ take: 100, include: { author: true } })
TypeORMfind({ 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 возвращается при следующем изменении.

Частые вопросы

Для списка из трёх элементов на редкой админской странице разница незаметна. Проблема появляется, когда N растёт вместе с данными или страница часто открывается. Привычка загружать связи заранее почти ничего не стоит.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.