awesome-everything EN
↑ Обратно к восхождению

Производительность

DataLoader: батчинг по дереву резолверов

Суть DataLoader накапливает ID-шники из всего запроса и выполняет один батч-запрос на тип при следующем тике event loop — канонический фикс для N+1 в GraphQL и multi-source fan-out.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 13 min

GraphQL-запрос me { posts { author { name } } } для 50 постов запускает 1 + 50 запросов к авторам. Подход с ORM eager load здесь не помогает — потребность в данных разбросана по 50 независимым вызовам резолвера, не сконцентрирована в одном месте запроса. DataLoader — структурный фикс.

Почему ORM eager loading недостаточен для GraphQL

ORM eager loading (includes, select_related, joinedload) работает путём объявления связей в точке построения запроса. Вы пишете один запрос со связями, объявленными заранее. В GraphQL-сервере нет одной точки построения запроса — каждый резолвер запускается независимо для каждого родительского объекта. Нет естественного места сказать “кстати, мне также понадобится автор для всех этих постов.”

Когда 50 резолверов постов каждый вызывает db.user.findUnique({ where: { id: authorId } }), ORM не знает, что все они запрашивают один и тот же тип данных. Он выполняет 50 запросов.

DataLoader решает это, перенося батчинг в scope запроса, а не области запроса к БД.

Как работает DataLoader

Facebook открыл исходники DataLoader в 2015 году вместе с GraphQL.js. Механизм:

  1. Экземпляр DataLoader создаётся один раз на запрос (не на вызов).
  2. Любой код в запросе вызывает loader.load(id), который возвращает Promise и ставит id в очередь внутри — он не выполняет запрос.
  3. Когда текущая синхронная работа заканчивается и event loop достигает следующего тика, loader выполняет один батч-запрос: SELECT * FROM users WHERE id IN (все поставленные в очередь id).
  4. Loader распределяет результаты каждому ждущему Promise по порядку.
// Создаётся один раз на запрос (например, в GraphQL context)
const userLoader = new DataLoader(async (ids) => {
  // ids — это батч: все ID, поставленные в очередь с последнего тика
  const users = await db.user.findMany({
    where: { id: { in: ids } },
  });
  // Должен вернуть результаты в том же порядке, что и ids
  return ids.map(id => users.find(u => u.id === id) ?? null);
});

// В резолвере Post — вызывается 50 раз для 50 постов
const resolveAuthor = async (post) => {
  return userLoader.load(post.authorId);
  // НЕ запрашивает сразу — ставит ID в очередь
};
// После того как все 50 резолверов поставили свои ID в очередь,
// DataLoader выполняет один запрос:
// SELECT * FROM users WHERE id IN (1, 2, ..., 50)

Три свойства, которые даёт DataLoader:

  1. Автоматический батчинг — множество вызовов load(id) в одном тике event loop становятся одним запросом.
  2. Автоматическое кэшированиеload(id), вызванный дважды в одном запросе, возвращает кэшированный результат без второго запроса.
  3. Request scope — кэш ограничен объектом запроса, поэтому устаревшие данные не утекают между запросами.
ШагЧто происходитЗапросов выполнено
Резолвер вызывает load(1)ID 1 в очереди; возвращён Promise0
Резолвер вызывает load(2) … load(50)ID 2–50 в очереди0
Event loop тикаетLoader выполняет батч-запрос1
Результаты приходятКаждый Promise резолвится со своей строкой0

GraphQL четырёхуровневый N+1

Форма умножается с глубиной вложенности. Для запроса me { teams { projects { members { name } } } }:

  • резолвер teams: 1 запрос
  • резолвер projects (на каждую team): N запросов
  • резолвер members (на каждый project): N×M запросов
  • name: включено в members, нет лишних запросов

Итого без DataLoader: 1 + N + (N×M) запросов. С DataLoader на тип: 4 запроса всего — один на тип на запрос.

// Каждый loader батчит один тип
const teamLoader = new DataLoader(ids => batchLoadTeams(ids));
const projectLoader = new DataLoader(ids => batchLoadProjects(ids));
const memberLoader = new DataLoader(ids => batchLoadMembers(ids));

// Каждый резолвер просто вызывает loader:
const resolveTeams = (user) => teamLoader.load(user.id);
const resolveProjects = (team) => projectLoader.load(team.id);
const resolveMembers = (project) => memberLoader.load(project.id);

Эффект: количество запросов падает в 100–1000 раз в зависимости от глубины fan-out. p99 падает с 1.4 с до ~150 мс для типичного четырёхуровневого запроса.

DataLoader vs ORM eager loading: когда использовать каждый

DataLoader более мощный и более сложный, чем ORM eager loading. Выбирайте по тому, откуда возникают потребности в данных:

  • Известная форма в точке запроса → ORM eager loading. Одно объявление, ORM справляется.
  • Потребности в данных разбросаны по многим путям кода → DataLoader. Батчит по деревьям резолверов или границам модулей.

Решающий вопрос: “знаю ли я в одном месте кода, что нужны все данные?” Если да — eager load. Если нет — DataLoader.

Почему это работает

DataLoader фундаментально связан с async / promise-based runtime, поскольку зависит от тиков event loop для запуска батчинга. Синхронные кодовые базы требуют явной batch-координации: собрать ID в первом проходе, запросить один раз, распределить во втором проходе. Многие языки теперь имеют порты DataLoader: graphql-java/dataloader (Java), aiodataloader (Python asyncio), DataLoader.NET (C#), graphql-dataloader (Go).

Викторина

GraphQL-резолвер вызывает userLoader.load(post.authorId) 50 раз для 50 постов. Сколько запросов к базе данных выполняет DataLoader?

Викторина

Команда строит REST API endpoint, собирающий данные из трёх таблиц базы данных через ID-поиски, разбросанные по трём вспомогательным модулям. Какой инструмент подходит лучше всего?

Расставь шаги по порядку

Упорядочите события в цикле батчинга DataLoader от первого вызова до резолвленных promises:

  1. 1 Резолвер вызывает loader.load(id) — возвращён Promise, ID поставлен в очередь
  2. 2 Ещё вызовы load() от других резолверов — ID накапливаются в батче
  3. 3 Текущая синхронная работа завершается; event loop тикает
  4. 4 DataLoader выполняет: SELECT * WHERE id IN (все поставленные в очередь ID)
  5. 5 Результаты приходят; каждый Promise резолвится со своей соответствующей строкой
Вспомните перед уходом
  1. 01
    Объясните, чем DataLoader отличается от ORM eager loading, и когда каждый является правильным инструментом.
  2. 02
    GraphQL-запрос имеет четыре уровня вложенности: me { teams { projects { members } } }. Объясните, как DataLoader снижает количество запросов.
Итог

DataLoader батчит ID-поиски из всего запроса в один запрос на тип, выполняемый при тике event loop. В отличие от ORM eager loading, который должен быть объявлен в одном месте запроса, DataLoader работает по разбросанным путям кода — что делает его каноническим фиксом для N+1 в GraphQL-резолверах, где каждый резолвер независимо запрашивает связанные данные. Он даёт три гарантии: автоматический батчинг, request-scope кэширование и отсутствие утечки устаревших данных между запросами. Паттерн DataLoader ориентирован на async; синхронным кодовым базам нужна явная двухпроходная координация collect-then-query вместо него.

Связанные уроки
встречается в159
Продолжить восхождение ↑Кросс-протокольный N+1: HTTP fan-out и Redis MGET
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.