Skip to main content

Що таке 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-а який повертає дочірні об'єкти зі списку.
  • Запит до одного об'єкта в порядку, для списків майже завжди потрібен батчинг.

Базовий приклад

graphql
# Для клієнта це виглядає як один запит... query { users { id name posts { title } # ...але на сервері запускає N окремих SQL-запитів } }
typescript
// Наївний 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 і відправляє один пакетний запит.

typescript
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 = один запит до БД

typescript
// Хибне припущення: одна GraphQL-операція = один запит до бази posts: (parent) => db.posts.find({ userId: parent.id }) // Реальність: викликається N разів, окремо для кожного parent об'єкта

Resolver-и виконуються незалежно для кожного об'єкта. Схема виглядає як один запит; виконання - ні.

Помилка 2: батчинг тільки верхнього рівня

DataLoader для users -> posts не захищає posts -> author від n + 1. Кожен рівень вкладеності потребує власного loader-а.

typescript
// postLoader добре обробляє users → posts // Але Post.author все одно викличе n + 1 без окремого authorLoader Post: { author: (parent, _, { loaders }) => loaders.authorLoader.load(parent.authorId) }

Помилка 3: відсутність пагінації

graphql
users { posts { title } } # 1000 users = 1001 запитів, можливий OOM

В продакшн-схемах завжди додавай first: N або cursor-based пагінацію до полів що повертають списки.

Помилка 4: DataLoader в неправильному місці

typescript
// Неправильно: singleton на рівні модуля розділяє кеш між усіма запитами const loader = new DataLoader(batchFn); // Правильно: новий екземпляр для кожного запиту всередині context factory const context = (req) => ({ loaders: { postLoader: new DataLoader(batchFn) } });

Shared singleton кешує дані між запитами різних користувачів, що є витоком даних. Завжди створюй loader-и всередині функції контексту.

Помилка 5: вкладений n + 1 залишається без уваги

graphql
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 include1НизькаМоноліт + Prisma
DataLoader2СередняБудь-який Apollo сервер
Apollo FederationVariesВисокаРозподілені графи

Питання на співбесіді

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 захищають від довільно глибоких вкладень ще до того як запит потрапить до БД.

Приклади

Базовий: проблема зі списком замовлень

typescript
// 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

typescript
// 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-ів

typescript
// Схема: 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. Пропустиш один рівень і проблема повертається.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?