Що таке n + 1 проблема в GraphQL?
n + 1 проблема в GraphQL виникає коли запит до списку з N батьківських об'єктів запускає N окремих запитів до бази даних для дочірніх об'єктів, плюс один початковий запит для батьків. Підсумок: N + 1 звернень до БД там де вистачило б двох.
Теорія
TL;DR
- Уяви що замовляєш 10 піц по одній з 10 різних магазинів замість одного оптового замовлення на складі.
- Наївний resolver для
User.postsзапускається окремо для кожного користувача зі списку. - 10 користувачів = 11 запитів до БД: 1 для users, 10 для posts.
- Правило: DataLoader або батчинг ORM для будь-якого resolver-а який повертає дочірні об'єкти зі списку.
- Запит до одного об'єкта в порядку, для списків майже завжди потрібен батчинг.
Базовий приклад
# Для клієнта це виглядає як один запит...
query {
users {
id
name
posts { title } # ...але на сервері запускає N окремих SQL-запитів
}
}// Наївний resolver: причина n + 1
const resolvers = {
Query: {
users: async () => db.users.findMany(), // 1 запит
},
User: {
posts: async (parent) =>
db.posts.findMany({ where: { userId: parent.id } }) // викликається для кожного user!
}
};
// 10 users: 1 + 10 = 11 запитів. 100 users: 101 запитів.GraphQL запускає User.posts окремо для кожного об'єкта в списку. Батчингу за замовчуванням немає. Результат: 11 запитів до БД там де достатньо двох.
Чому GraphQL resolvers так поводяться
GraphQL.js виконує resolver-и depth-first: спочатку отримує батьківський список через Query.users, потім для кожного об'єкта викликає User.posts окремо. Node.js async/await передає управління між resolver-ами, тому кожен звертається до БД незалежно. ORM (Prisma, Sequelize) отримує N окремих запитів замість одного WHERE id IN (1, 2, 3, ...).
Ось де і виникає розрив: схема GraphQL дозволяє клієнту отримати вкладені дані в одному HTTP-запиті, але сервер за замовчуванням робить окремий запит для кожного елемента списку.
Коли це важливо
- Запит до одного об'єкта: проблеми немає, виконується один дочірній запит.
- Список до 5 елементів: прийнятно для прототипів, простий код важливіший.
- Пагіновані списки: батчинг завжди, навіть з cursor-пагінацією
first: 10. - High-traffic API: DataLoader тут не є опціональним.
- Вкладені списки (posts, а потім comments для кожного поста): кожен рівень множить проблему.
Як DataLoader вирішує проблему
DataLoader це бібліотека від Meta для батчингу та кешування. Замість того щоб звертатися до БД для кожного батька, вона збирає всі запитані ключі за один tick event loop і відправляє один пакетний запит.
import DataLoader from 'dataloader';
// Batch-функція отримує всі зібрані ID одразу
const postLoader = new DataLoader(async (userIds: readonly number[]) => {
const posts = await db.posts.findMany({
where: { userId: { in: [...userIds] } }
});
// Повертаємо posts згруповані за userId в тому ж порядку що й вхідні userIds
return userIds.map(id => posts.filter(p => p.userId === id));
});
// Resolver делегує роботу loader-у
const resolvers = {
User: {
posts: (parent, _, { loaders }) => loaders.postLoader.load(parent.id)
}
};
// Результат: 1 запит users + 1 пакетний запит posts = 2 загаломDataLoader групує однакові ключі (userIds) в один batchFind і мемоізує результати в межах одного запиту. Екземпляр loader-а має жити в контексті запиту, а не на рівні модуля, щоб кеш не потрапляв між запитами різних користувачів.
Типові помилки
Помилка 1: думати що вкладеність у GraphQL = один запит до БД
// Хибне припущення: одна GraphQL-операція = один запит до бази
posts: (parent) => db.posts.find({ userId: parent.id })
// Реальність: викликається N разів, окремо для кожного parent об'єктаResolver-и виконуються незалежно для кожного об'єкта. Схема виглядає як один запит; виконання - ні.
Помилка 2: батчинг тільки верхнього рівня
DataLoader для users -> posts не захищає posts -> author від n + 1. Кожен рівень вкладеності потребує власного loader-а.
// postLoader добре обробляє users → posts
// Але Post.author все одно викличе n + 1 без окремого authorLoader
Post: {
author: (parent, _, { loaders }) => loaders.authorLoader.load(parent.authorId)
}Помилка 3: відсутність пагінації
users { posts { title } } # 1000 users = 1001 запитів, можливий OOMВ продакшн-схемах завжди додавай first: N або cursor-based пагінацію до полів що повертають списки.
Помилка 4: DataLoader в неправильному місці
// Неправильно: singleton на рівні модуля розділяє кеш між усіма запитами
const loader = new DataLoader(batchFn);
// Правильно: новий екземпляр для кожного запиту всередині context factory
const context = (req) => ({
loaders: { postLoader: new DataLoader(batchFn) }
});Shared singleton кешує дані між запитами різних користувачів, що є витоком даних. Завжди створюй loader-и всередині функції контексту.
Помилка 5: вкладений n + 1 залишається без уваги
query {
users {
posts {
comments { body } # 10 posts x 5 comments = 50 зайвих запитів
}
}
}Навіть з DataLoader для posts, resolver comments все одно спричиняє n + 1 без власного loader-а. Для глибоко вкладених схем Prisma include може звести все до 1 запиту, але тоді сервер завжди повертає всі вкладені дані, навіть якщо клієнт їх не просив.
Де це зустрічається на практиці
- GitHub GraphQL API: DataLoader-и батчать
repos -> stargazersвиклики. - Shopify Hydrogen: Prisma
includeзводитьorders -> lineItemsдо одного запиту. - Hasura: батчинг вбудований через Postgres CTE, n + 1 обробляється на рівні движка.
- WPGraphQL: кастомні resolver-и з
WP_Queryбатчингом. - Орієнтир для вибору: Prisma
includeдля монолітних схем, DataLoader для cross-service або мікросервісних графів.
| Підхід | Запитів до БД | Складність налаштування | Найкраще для |
|---|---|---|---|
| Наївні resolver-и | N + 1 | Немає | Тільки прототипи |
Prisma include | 1 | Низька | Моноліт + Prisma |
| DataLoader | 2 | Середня | Будь-який Apollo сервер |
| Apollo Federation | Varies | Висока | Розподілені графи |
Питання на співбесіді
Q: Як DataLoader знає коли відправляти пакетний запит?
A: Він збирає всі виклики .load(key) в межах одного tick event loop і виконує batch-функцію на наступному tick. Тому він прозоро працює всередині ланцюжків resolver-ів без жодної ручної координації.
Q: Яка різниця між n + 1 проблемою і waterfall?
A: N + 1 це N паралельних запитів для об'єктів одного рівня (всі posts-запити для всіх users). Waterfall це послідовне depth-first виконання (спочатку posts, потім comments для кожного поста). Вкладений n + 1 поєднує обидва патерни.
Q: Як виявити n + 1 в продакшені?
A: Apollo Studio показує тайминг resolver-ів і кількість викликів. Prisma логує кожен SQL-запит. Якщо бачиш 11 майже однакових запитів що відрізняються тільки ID параметром, це n + 1.
Q: Apollo Federation запобігає n + 1 між subgraph-ами?
A: Gateway батчить top-level entity lookups, але кожен subgraph може мати власний внутрішній n + 1. Кожен сервіс потребує свого DataLoader.
Q: Senior: як оптимізувати GraphQL endpoint для 1M запитів на день з глибоко вкладеними запитами?
A: Денормалізуй гарячі шляхи, вбудувавши дочірні дані прямо в батьківський документ. Persisted queries прибирають overhead парсингу при кожному запиті. CDN кеш для публічних операцій. DataLoader на кожному рівні resolver-ів. Query complexity limits захищають від довільно глибоких вкладень ще до того як запит потрапить до БД.
Приклади
Базовий: проблема зі списком замовлень
// Apollo Server + Prisma, наївна реалізація
const resolvers = {
Query: {
orders: () => prisma.order.findMany({ take: 10 }), // 1 запит
},
Order: {
items: (parent) =>
prisma.orderItem.findMany({ where: { orderId: parent.id } }), // 10 запитів!
},
};
// Всього з 10 замовленнями: 11 викликів до БД, ~500ms latency з реальним PostgresЗ 10 замовленнями сервер робить 11 запитів. Додай пагінацію але забудь про DataLoader, і все одно платиш N + 1 за кожну сторінку.
Середній рівень: DataLoader в контексті Apollo Server
// context.ts - loader-и створюються окремо для кожного запиту
import DataLoader from 'dataloader';
import { prisma } from './db';
export function createLoaders() {
return {
orderItemLoader: new DataLoader(async (orderIds: readonly string[]) => {
const items = await prisma.orderItem.findMany({
where: { orderId: { in: [...orderIds] } },
});
return orderIds.map(id => items.filter(i => i.orderId === id));
}),
};
}
// server.ts
const server = new ApolloServer({
typeDefs,
resolvers: {
Query: {
orders: () => prisma.order.findMany({ take: 10 }),
},
Order: {
items: (parent, _, { loaders }) =>
loaders.orderItemLoader.load(parent.id), // автоматичний батчинг
},
},
context: ({ req }) => ({ loaders: createLoaders() }),
});
// Всього: 2 запити до БД незалежно від кількості замовлень. Latency ~50ms.Ключова деталь: createLoaders() викликається один раз на запит всередині context, а не при старті сервера. Кожен запит отримує свіжий ізольований кеш.
Senior: вкладений n + 1 на трьох рівнях resolver-ів
// Схема: User -> Post -> Comment (3 рівні вкладеності)
const resolvers = {
Query: {
users: () => db.users.findMany(), // 1 запит
},
User: {
posts: (parent, _, { loaders }) =>
loaders.postLoader.load(parent.id), // батчинг: 1 запит
},
Post: {
comments: (parent, _, { loaders }) =>
loaders.commentLoader.load(parent.id), // батчинг: 1 запит
},
};
// Без commentLoader цей запит:
// query { users { posts { comments { body } } } }
// = 1 + N(users) + N(posts) запитів до БД
// З обома loader-ами: завжди рівно 3 запити незалежно від розміру даних
// Альтернатива через Prisma include (простіше, але менш гнучко):
const users = await prisma.user.findMany({
include: { posts: { include: { comments: true } } }
});
// 1 запит, але повертає всі вкладені дані навіть якщо клієнт запитав тільки назви постівПідхід через Prisma include перестає масштабуватись коли схема росте і різні клієнти запитують різну глибину вкладеності. DataLoader на кожному рівні дає більше контролю, але вимагає дисципліни: новий resolver зі списком дочірніх об'єктів означає новий loader. Пропустиш один рівень і проблема повертається.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.