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.

Проверьте себя
1. Какие два механизма DataLoader решают проблему N+1?
AСжатие и шифрование
BБатчинг вызовов load в один запрос и кэширование в пределах запроса
CПагинация и сортировка
DПодписки и мутации
2. Почему DataLoader создают заново на каждый запрос?
AТак быстрее стартует
BЕго кэш не должен переживать запрос, иначе пользователи увидят чужие данные
CЭтого требует SQL
DЧтобы сменить порт