Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке n + 1 проблема в GraphQL?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**n + 1 проблема** в GraphQL виникає коли список з N батьківських об'єктів запускає N окремих запитів до БД для дочірніх даних плюс 1 початковий запит, всього N + 1 звернень замість двох. ```typescript // 10 users = 11 запитів: 1 для списку users, 10 для їхніх posts User: { posts: (parent) => db.posts.findMany({ where: { userId: parent.id } }) } ``` **Головне:** DataLoader групує всі дочірні запити в один незалежно від розміру списку.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 `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 захищають від довільно глибоких вкладень ще до того як запит потрапить до БД. ## Приклади ### Базовий: проблема зі списком замовлень ```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. Пропустиш один рівень і проблема повертається.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.