DataLoader: батчинг и кэширование
DataLoader решает N+1 двумя приёмами: собирает все вызовы load(id) за один тик в единый батч-запрос и кэширует результаты в пределах одного запроса.
Сто резолверов попросили по одному автору. DataLoader тихо собрал сто id, сходил в базу один раз и раздал каждому его ответ.
В прошлом уроке мы получили 101 запрос на ровном месте. DataLoader — крошечная библиотека от создателей GraphQL — превращает их обратно в 2. Идея в двух механизмах: батчинг (объединение) и кэширование в пределах запроса.
Как им пользуются
Вместо того чтобы каждый резолвер сам ходил в БД, он зовёт loader.load(id). Loader не бежит в базу сразу — он копит ключи и в конце текущего тика событий вызывает твою batch-функцию один раз со всем списком ключей:
import DataLoader from "dataloader";
// batch-функция: получает массив id, возвращает массив авторов
// В ТОМ ЖЕ ПОРЯДКЕ, что и ключи!
const authorLoader = new DataLoader(async (ids) => {
const rows = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
// отсортировать строго по порядку ids:
return ids.map(id => rows.find(r => r.id === id));
});
// в резолвере вместо похода в БД:
const resolvers = {
Post: { author: (post) => authorLoader.load(post.authorId) },
};
Сто резолверов author вызвали load сто раз — но batch-функция выполнилась один раз с массивом из ста (а с учётом дедупликации — из уникальных) id. Одно WHERE id IN (...) вместо ста WHERE id = ?.
Как работает под капотом
Два правила DataLoader критичны: (1) порядок результатов должен совпадать с порядком ключей — это контракт; (2) кэш живёт на один запрос — повторный load того же id вернёт уже загруженное, не дёргая базу. Поэтому loader создают заново на каждый запрос, в функции context. Смоделируем оба механизма на чистом JS:
function createLoader(batchFn) {
let queue = []; // накопленные ключи
const cache = new Map(); // кэш на время "запроса"
let scheduled = false;
function dispatch() {
const batch = queue; queue = []; scheduled = false;
const keys = batch.map(b => b.key);
const values = batchFn(keys); // ОДИН вызов на все ключи
batch.forEach((b, i) => b.resolve(values[i]));
}
return function load(key) {
if (cache.has(key)) return Promise.resolve(cache.get(key)); // дедуп
return new Promise(resolve => {
queue.push({ key, resolve: v => { cache.set(key, v); resolve(v); } });
if (!scheduled) { scheduled = true; queueMicrotask(dispatch); }
});
};
}
let batchCalls = 0;
const table = { a1: "Аня", a2: "Боб" };
const load = createLoader(keys => {
batchCalls++;
console.log("BATCH за ключами:", keys); // один общий поход
return keys.map(k => table[k]);
});
// имитируем 6 резолверов: повторяющиеся id a1/a2
Promise.all([load("a1"), load("a2"), load("a1"), load("a2"), load("a1"), load("a2")])
.then(r => {
console.log("Результаты:", r);
console.log("Batch-вызовов:", batchCalls); // 1 вместо 6!
});
Попробуй сам ▶ — добавь ещё load("a3") (и a3 в table). Все вызовы за один тик всё равно соберутся в один батч, а повторные a1/a2 не задвоятся благодаря кэшу.
Частые ошибки
- Нарушить порядок результатов. Если batch-функция вернёт авторов не в порядке входных id, клиенты перемешаются. Всегда выстраивай ответ строго по ключам.
- Один общий loader на все запросы. Кэш DataLoader не предназначен для долгой жизни — иначе разные пользователи увидят чужие данные. Создавай loader на каждый запрос в context.
- Грузить по одному внутри batch-функции. Если внутри batchFn ты в цикле снова дёргаешь БД по одному — смысла нет. Нужен один запрос
IN (...).
Best practices
- Создавай по одному DataLoader на сущность (authorLoader, commentsLoader) и клади их в
contextпри каждом запросе. - Гарантируй контракт порядка: маппинг
ids.map(id => found[id])надёжнее, чем полагаться на порядок строк из БД. - Используй встроенный кэш на запрос осознанно: повторные
loadодного id бесплатны, но кэш не должен переживать запрос.
Итоги
DataLoader превращает N+1 обратно в 2 запроса: он копит вызовы load(id) за один тик и отдаёт их batch-функции единым списком (один IN (...) вместо сотни), а кэш в пределах запроса убирает повторы. Контракт: порядок результатов = порядок ключей, и loader создаётся на каждый запрос. Дальше — как клиент использует данные эффективно: кэширование на стороне Apollo Client.