Проблема N+1: откуда берётся
N+1 — главный подвох GraphQL на сервере: запрос к списку с вложенной связью незаметно превращается в сотню обращений к базе вместо двух.
GraphQL экономит запросы по сети — но может устроить их лавину внутри сервера. N+1 не видно в запросе клиента, зато прекрасно видно в логах БД.
Гибкость резолверов имеет цену. Вспомним: для каждого поля и каждого элемента списка резолвер вызывается отдельно. Если у поля есть вложенная связь, которую резолвер достаёт из БД, число обращений умножается. Это и есть проблема N+1.
Где она прячется
Возьмём безобидный запрос: 100 постов, и у каждого — автор.
query {
posts { # 1 запрос: достать 100 постов
title
author { # для КАЖДОГО поста — отдельный запрос за автором!
name
}
}
}
Что происходит на сервере: 1 запрос вернул список из 100 постов. Затем для каждого поста вызывается резолвер author, и каждый идёт в БД за своим автором. Итого: 1 + 100 = 101 запрос вместо разумных двух. Отсюда и название: 1 запрос за списком + N запросов за связями.
posts -> [p1, p2, ... p100] (1 запрос) p1.author -> SELECT * FROM users WHERE id = a1 (+1) p2.author -> SELECT * FROM users WHERE id = a2 (+1) ... p100.author -> SELECT ... id = a100 (+1) ------------------------------------------------- ИТОГО: 1 + 100 = 101 обращение к БД
На 10 постах это незаметно. На 1000 — сервер встаёт. И что коварно: запрос клиента выглядит абсолютно нормально, проблема скрыта в реализации резолверов. Ещё обиднее, что среди этих сотни обращений много повторных: если у тридцати постов один и тот же автор, наивные резолверы сходят за ним в базу тридцать раз вместо одного. То есть мы не просто делаем много запросов — мы делаем много одинаковых запросов. Запомни этот симптом: в логах базы всплеск однотипных строк вида SELECT ... WHERE id = ?, отличающихся только значением id, — почти всегда верный признак того, что где-то в резолверах притаилась N+1. В следующем уроке мы убьём её двумя приёмами сразу: объединением запросов в один и кэшированием повторов.
Как работает под капотом
Резолвер вложенного поля по умолчанию ничего не знает о «соседях» по списку — он видит только свой parent. Поэтому каждый честно идёт за своими данными сам. Смоделируем лавину запросов и посчитаем их:
let dbCalls = 0;
const usersTable = { a1: "Аня", a2: "Боб", a1b: "Аня" };
// эмуляция похода в БД за одним автором
function fetchAuthor(id) {
dbCalls++;
return usersTable[id] || "?";
}
// 100 постов (для краткости — 6), у каждого author_id
const posts = Array.from({ length: 6 }, (_, i) => ({
title: "Пост " + i, authorId: i % 2 ? "a2" : "a1"
}));
// наивные резолверы: для каждого поста — свой fetchAuthor
const result = posts.map(p => ({ title: p.title, author: fetchAuthor(p.authorId) }));
console.log("Постов:", posts.length);
console.log("Обращений в БД за авторами:", dbCalls); // = числу постов (N)
Попробуй сам ▶ — увеличь length до 100 и посмотри, как dbCalls вырастет до 100. Заметь: многие авторы повторяются (a1, a2) — мы запрашиваем одно и то же снова и снова. Это и есть та неэффективность, которую решает DataLoader в следующем уроке.
Частые ошибки
- Не замечать N+1, пока не вырастет нагрузка. На малых данных всё «работает», проблема всплывает в проде на больших списках.
- Винить GraphQL. N+1 — не баг GraphQL, а следствие наивной реализации резолверов; та же беда бывает и в ORM REST-бэкендов.
- Грузить связи «жадно» всегда. Тащить авторов даже когда их не просили — другая крайность; нужна именно умная пакетная загрузка.
Best practices
- Включай логирование SQL на разработке: всплеск однотипных запросов
WHERE id = ?— верный признак N+1. - Думай о «пакетировании»: вместо 100 запросов «дай автора X» можно сделать один «дай авторов из списка [X, Y, Z, ...]».
- Кэшируй в пределах запроса: если автор a1 встречается у 30 постов, грузить его стоит один раз.
Итоги
Проблема N+1 возникает, когда резолвер вложенной связи вызывается отдельно для каждого элемента списка: 1 запрос за списком + N запросов за связями. Она невидима в запросе клиента, но кладёт сервер на больших данных. Решение — батчинг и кэширование в пределах запроса. Им и займёмся: знакомимся с DataLoader.