Клієнт скаржиться: 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.







