Вирішення проблеми N+1 в GraphQL з DataLoader
N+1—класична проблема GraphQL: при запиті списку з N об'єктів з вкладеними відносинами виконується N+1 запит до БД (1 для списку + N для кожного вкладеного поля). DataLoader вирішує це батчингом: збирає всі запити за один тик event loop і виконує один груповий запит.
Демонстрація проблеми
# Цей запит без DataLoader генерує 1 + N запитів до БД
query {
posts { # SELECT * FROM posts → 100 рядків
id
title
author { # SELECT * FROM users WHERE id = ? × 100 разів!
name
}
}
}
// БЕЗ DataLoader—N+1
const resolvers = {
Post: {
author: async (post) => {
// Викликається окремо для кожного з 100 постів
return db.users.findById(post.author_id) // 100 запитів!
}
}
}
Базовий DataLoader
import DataLoader from 'dataloader'
// Функція батчингу: отримує масив ключів, повертає масив значень
async function batchUsers(userIds) {
// Один запит замість N
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'ів (Per-Request)
DataLoader'и створюються per-request—інакше кеш спільний між користувачами (вразливість):
// dataloaders.js
import DataLoader from 'dataloader'
export class DataLoaderRegistry {
constructor(db) {
this.db = db
// Автоматично батчується за один тик event loop
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]
)
// One-to-many: повертаємо масив для кожного authorId
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) ?? [])
})
// З фільтрацією—ключ включає параметри
this.tagsByPostId = new DataLoader(async (postIds) => {
const rows = await db.query(`
SELECT pt.post_id, t.* FROM tags t
JOIN post_tags pt ON pt.tag_id = t.id
WHERE pt.post_id = ANY($1::int[])
`, [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: async ({ req }) => {
const user = await authenticate(req)
// Новий екземпляр для кожного HTTP-запиту!
const loaders = new DataLoaderRegistry(db)
return { user, db, loaders }
}
DataLoader з параметрами
Коли потрібна фільтрація за додатковими аргументами:
// Погано: окремий лоадер для кожної комбінації параметрів
// Добре: складений ключ
this.productsByCategoryAndStatus = new DataLoader(
async (keys) => {
// keys = [{categoryId: 1, status: 'active'}, ...]
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}`
}
)
// Використання в резолвері
const resolvers = {
Category: {
activeProducts: (category, args, context) => {
return context.loaders.productsByCategoryAndStatus.load({
categoryId: category.id,
status: 'active'
})
}
}
}
Прайміння кешу
Уникає повторних запитів до вже завантажених даних:
const resolvers = {
Query: {
posts: async (parent, { limit }, context) => {
const posts = await context.db.posts.findAll({ limit })
// Примірити кеш userById даними, уже присутніми в posts
// Якщо posts містять embedded author—DataLoader їх не перезапросить
for (const post of posts) {
if (post.author) {
context.loaders.userById.prime(post.author.id, post.author)
}
}
return posts
}
}
}
Вимірювання ефективності
// Middleware для логування кількості SQL-запитів
function queryCounterPlugin() {
return {
async requestDidStart() {
let queryCount = 0
const originalQuery = db.query.bind(db)
db.query = (...args) => {
queryCount++
return originalQuery(...args)
}
return {
async willSendResponse({ response }) {
console.log(`GraphQL operation executed ${queryCount} SQL queries`)
// У продакшені—метрика в Prometheus
response.http.headers.set('X-SQL-Count', queryCount.toString())
}
}
}
}
}
До DataLoader: запит 100 постів → 101 SQL-запит. Після DataLoader: той же GraphQL-запит → 3–5 SQL-запитів (posts, users batch, comments batch).
Терміни
Реалізація DataLoader'ів для всіх відносин у GraphQL-схемі—1–2 робочих дні.







