open atlas
↑ К треку
API API · 06 · 02

Механика DataLoader: батчинг на границе тика

DataLoader ждёт окончания текущего тика event loop и делает один батчевый запрос за все накопленные ключи — превращая N per-item SQL-вызовов в один.

API Middle ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior

После подключения DataLoader страница Sven’а падает с 51 запроса до 2 — без изменений схемы и клиентского запроса. GraphQL-документ и все резолверы остаются ровно такими же. Меняется только fetcher.

К концу урока ты будешь знать точный механизм, превращающий 50 изолированных вызовов резолвера в один SQL — и единственную ошибку, которая тихо утекает tenant-данные между запросами.

Что делает DataLoader

DataLoader — небольшая библиотека, изначально извлечённая из реализации GraphQL в Facebook и поддерживаемая GraphQL Foundation. Конструктор: new DataLoader(batchLoadFn, options), где batchLoadFn(keys: Array<K>): Promise<Array<V | Error>> — твой батчевый fetcher (функция пакетной загрузки).

Резолверы вызывают loader.load(key) и получают Promise. Под капотом — вот что стоит понять, прежде чем трогать любой production DataLoader:

  1. Каждый .load(key) ставит ключ во внутренний массив. Запрос ещё не выполняется.
  2. Когда текущий синхронный шаг JavaScript заканчивается — когда очередь микротасок начинает сливаться — DataLoader берёт все накопленные ключи и вызывает batchLoadFn один раз со всем массивом.
  3. batchLoadFn выполняет один WHERE id IN (...) и возвращает значения в том же порядке, что входные ключи.
  4. DataLoader разрешает каждый ожидающий Promise его значением.

Граница тика — естественная точка склейки: движок GraphQL заканчивает обход одного уровня запроса синхронно. Все 50 вызовов резолвера Post.author ставят ID в очередь во время обхода этого уровня. Микротаска DataLoader срабатывает после — когда все ID уже накоплены.

Почему фиксированное временное окно было бы хуже

Буфер в 5 мс либо добавил бы 5 мс латентности к каждому запросу (ненужное ожидание), либо сработал бы слишком рано под нагрузкой (не захватив все ID). Граница тика срабатывает в самый ранний возможный момент, когда все ID для текущего уровня уже в очереди — без лишнего ожидания.

Тик event loop (GraphQL разрешает уровень 2):
  Post.author(post1) → loader.load(7)   // в очереди
  Post.author(post2) → loader.load(9)   // в очереди
  Post.author(post3) → loader.load(7)   // деdup: уже в очереди

Микротаска (тик кончился):
  batchLoadFn([7, 9])
  → SELECT id, name FROM users WHERE id IN (7, 9)
  → разрешает: 7→Bea, 9→Sven

Все три Post.author Promise разрешаются: post1→Bea, post2→Sven, post3→Bea

Дедупликация внутри одного запроса

Если loader.load(7) вызывается дважды в одном запросе, DataLoader возвращает тот же Promise — ключ появляется в батче только один раз. Это полезно, когда одна сущность доступна по нескольким путям в документе (например, автор поста и автор комментария — один и тот же user). Отключается через options.cache = false для резолверов, которым нужны свежие чтения внутри одного запроса (редко: паттерн write-after-read).

Правило per-request инстанса

DataLoader-инстанс — это кеш. Его время жизни должно быть request, не процесс. Модульно-скоупленный DataLoader, разделённый между всеми запросами:

  • Возвращает stale-данные: Request A грузит user 7, строка обновляется, Request B грузит user 7 из кеша и получает pre-update строку.
  • Утекает между арендаторами: Request A и B принадлежат разным tenant’ам. Tenant B получает закешированную строку Tenant A для того же ID.

Дисциплина: создавай DataLoader внутри фабрики request-context (функция, которую Apollo Server вызывает на каждый запрос), прикрепляй к context, дай GC почистить по окончании запроса.

// Фабрика context Apollo Server — правильно
context: async ({ req }) => ({
  loaders: {
    author: new DataLoader(batchAuthors),
    tags:   new DataLoader(batchTags),
  },
})

// Резолвер — правильно
Post: {
  author: (post, _args, ctx) => ctx.loaders.author.load(post.authorId),
}
lesson.inset.warning

Глобальный DataLoader теоретически экономит память. На практике — утекает данные между арендаторами и отдаёт stale-строки. Документация Apollo явно: «DataLoader-инстансы per-request — если используешь DataLoader в источнике данных, создавай новый инстанс на каждый запрос».

Викторина

DataLoader вызывают дважды с тем же ключом в одном запросе. Что происходит?

Викторина

Почему DataLoader батчит на границе тика event loop, а не на фиксированном окне 5 мс?

Закончи аналогию

Заполни пропуск: DataLoader собирает все .load() в одном _______ event loop и вызывает один батч.

Вспомните перед уходом
  1. 01
    Почему DataLoader-инстанс должен создаваться per-request, а не один раз при старте сервера?
  2. 02
    Когда DataLoader вызывает batch-функцию относительно вызовов резолверов?
Итог

DataLoader переносит fetch из каждого резолвера в per-request батч. Каждый резолвер вызывает loader.load(id) и получает Promise. DataLoader накапливает все ID до конца тика event loop, затем делает один WHERE id IN (...) за полный набор. Ключи дедуплицируются в батче: loader.load(7) с двух разных путей в одном документе порождает один SQL-lookup, а не два. Инстанс должен создаваться per-request — глобальный инстанс утекает tenant-данные и возвращает stale-строки. Теперь, встретив DataLoader в чужом коде, первым делом проверяй: он создаётся в context-фабрике или живёт на уровне модуля? Ответ на этот вопрос решает, есть ли у вас скрытый баг корректности.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
встречается в204

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.