Решение проблемы 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'ы создаются для каждого запроса — иначе кеш будет общим между пользователями (уязвимость):
// 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 factory
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 рабочих дня.







