Проблема 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.

Проверьте себя
1. Из чего складывается число запросов в проблеме N+1?
AN запросов всегда
B1 запрос за списком + N запросов за связью каждого элемента
C2 запроса в сумме
DN*N запросов
2. Почему N+1 — не баг самого GraphQL?
AЭто баг graphql-пакета
BЭто следствие наивной реализации резолверов; та же проблема есть и в ORM REST-бэкендов
CGraphQL её специально создаёт
DОна бывает только в подписках