Клієнт скаржиться: GraphQL-ендпоінт «зависає» на списку з 100 постів. Причина — класична N+1 проблема: для кожного поста виконується окремий запит автора, разом 101 SQL замість одного-двох. Таймаути, падіння сервера, незадоволені користувачі — це типовий сценарій. Ми за 5 років оптимізували понад 20 GraphQL-проектів, скорочуючи кількість запитів у 20–30 разів. Результат — зниження затримки на 40–60% та економія на хмарних ресурсах до $400–600 на місяць. DataLoader — основний інструмент для боротьби з N+1. Типовий проект окупається за 2–3 місяці завдяки зниженню витрат на інфраструктуру.
Чому проблема N+1 критична для GraphQL?
GraphQL-резолвери викликаються незалежно для кожного батьківського об'єкта. Якщо схема містить вкладене поле, що потребує окремого SQL-запиту, то для N елементів виконується N+1 запит. Наприклад:
query { posts { id title author { name } } } Без DataLoader цей запит генерує SELECT * FROM posts, а потім для кожного з 100 постів — SELECT * FROM users WHERE id = ? — разом 101 запит до БД. Навантаження на базу зростає лінійно, а час відповіді — квадратично. Позбавлення від N+1 — ключ до стабільної продуктивності.
Як DataLoader батчить запити?
DataLoader — бібліотека (Node.js DataLoader — реалізація для JavaScript), яка батчить виклики. Вона збирає всі .load() за один такт event loop і виконує один груповий запит. Крім того, вона кешує результати на час запиту, уникаючи повторного завантаження тих самих даних. Ось приклад реалізації:
import DataLoader from 'dataloader' async function batchUsers(userIds) { const users = await db.query( 'SELECT * FROM users WHERE id = ANY($1)', [userIds] ) const userMap = new Map(users.map(u => [u.id, u])) return userIds.map(id => userMap.get(id) || null) } const userLoader = new DataLoader(batchUsers) const resolvers = { Post: { author: async (post, args, context) => { return context.loaders.userById.load(post.author_id) } } } Важно: DataLoader створюється на кожен HTTP-запит, щоб уникнути витоку даних між різними користувачами.
Реєстр завантажувачів (per-request)
Ми рекомендуємо збирати всі DataLoader'и в єдиний клас, який ініціалізується в контексті запиту. Це спрощує підтримку і гарантує, що кожен завантажувач живе рівно один запит.
export class DataLoaderRegistry { constructor(db) { this.db = db this.userById = new DataLoader(async (ids) => { const rows = await db.query( 'SELECT * FROM users WHERE id = ANY($1::int[])', [ids] ) const map = new Map(rows.map(r => [r.id, r])) return ids.map(id => map.get(id) ?? null) }) this.postsByAuthorId = new DataLoader(async (authorIds) => { const rows = await db.query( 'SELECT * FROM posts WHERE author_id = ANY($1::int[])', [authorIds] ) const map = new Map() for (const row of rows) { if (!map.has(row.author_id)) map.set(row.author_id, []) map.get(row.author_id).push(row) } return authorIds.map(id => map.get(id) ?? []) }) this.commentsByPostId = new DataLoader(async (postIds) => { const rows = await db.query( 'SELECT * FROM comments WHERE post_id = ANY($1::int[]) ORDER BY created_at', [postIds] ) const map = new Map() for (const row of rows) { if (!map.has(row.post_id)) map.set(row.post_id, []) map.get(row.post_id).push(row) } return postIds.map(id => map.get(id) ?? []) }) } } // В context factory context: async ({ req }) => { const user = await authenticate(req) const loaders = new DataLoaderRegistry(db) return { user, db, loaders } } DataLoader зі складеними ключами
Зауважте: коли потрібна фільтрація за додатковими аргументами, використовуйте складений ключ:
this.productsByCategoryAndStatus = new DataLoader( async (keys) => { const categoryIds = [...new Set(keys.map(k => k.categoryId))] const statuses = [...new Set(keys.map(k => k.status))] const rows = await db.query(` SELECT * FROM products WHERE category_id = ANY($1::int[]) AND status = ANY($2::text[]) `, [categoryIds, statuses]) const map = new Map() for (const row of rows) { const key = `${row.category_id}:${row.status}` if (!map.has(key)) map.set(key, []) map.get(key).push(row) } return keys.map(k => map.get(`${k.categoryId}:${k.status}`) ?? []) }, { cacheKeyFn: (key) => `${key.categoryId}:${key.status}` } ) Праймінг кешу DataLoader
Якщо ви вже завантажили дані (наприклад, авторів у запиті постів), можна підказати DataLoader'у їх значення — це запобіжить повторному батчу:
const resolvers = { Query: { posts: async (parent, { limit }, context) => { const posts = await context.db.posts.findAll({ limit }) for (const post of posts) { if (post.author) { context.loaders.userById.prime(post.author.id, post.author) } } return posts } } } Кейс: каталог товарів з 50 категоріями
На одному з проектів ми оптимізували каталог, де на сторінці відображалися товари з 50 категорій. Без DataLoader кожен резолвер категорії робив окремий запит — разом 51 SQL. Після впровадження DataLoader з батчингом по author_id запитів стало 2: один на список товарів, один на всіх авторів разом. Час відповіді впав з 2.5 секунд до 300 мс.
Порівняння підходів: наївний резолвер проти DataLoader
| Підхід | Кількість SQL (100 постів + автор) | Складність впровадження |
|---|---|---|
| Наївний резолвер | 101 | Низька |
| DataLoader | 3 | Середня |
| Ручна оптимізація запитів | Залежить від реалізації | Висока |
DataLoader кращий за наївний резолвер у 20-30 разів за кількістю запитів до БД при тій самій складності підтримки. З точки зору GraphQL performance, це одне з найефективніших рішень.
Вигода від впровадження DataLoader
| Сценарій | Без DataLoader | З DataLoader |
|---|---|---|
| 100 постів + автор | 101 SQL | 3 SQL (posts + users batch + comments batch) |
| 50 постів + теги | 51 SQL | 2 SQL |
| 20 категорій + товари | 21 SQL | 2 SQL |
DataLoader скорочує кількість запитів до БД у 20–30 разів порівняно з наївною реалізацією. На проектах з навантаженням від 1000 RPS це дає зниження затримки на 40–60% та економію хмарних ресурсів до $400–600 на місяць. Типовий проект окупається за 2–3 місяці завдяки зниженню витрат на інфраструктуру.
Покрокове впровадження DataLoader
- Визначте всі точки з N+1: запишіть поточні SQL-запити на кожен резолвер.
- Для кожної сутності створіть DataLoader з функцією батчингу.
- Впровадьте реєстр завантажувачів у контекст запиту.
- Замініть прямі виклики БД у резолверах на
.load(). - Протестуйте: порівняйте кількість SQL-запитів до та після.
Що входить в реалізацію DataLoader
- Аудит поточної GraphQL-схеми: виявляємо всі N+1 точки.
- Написання DataLoader'ів для кожної сутності (до 10 відношень).
- Інтеграція реєстру завантажувачів у контекст запиту.
- Навантажувальне тестування: порівнюємо час відповіді до та після.
- Документація з підтримки та розширення рішення.
- Гарантія: наші інженери з 5-річним досвідом забезпечують стабільну роботу навіть під високим навантаженням.
Зв'яжіться з нами — ми оцінимо ваш проект і дамо точний кошторис. Отримайте консультацію прямо зараз: напишіть нам, і ми розберемо ваш випадок безкоштовно. Замовте впровадження DataLoader у вашому проекті — напишіть нам.
Посилання на офіційну документацію: DataLoader (GraphQL Foundation) DataLoader official repository.







