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

API

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

Суть DataLoader ждёт окончания текущего тика event loop и делает один батчевый запрос за все накопленные ключи — превращая N per-item SQL-вызовов в один.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 13 min

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

Что делает DataLoader

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

Резолверы вызывают loader.load(key) и получают Promise. Под капотом:

  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-строки.

Связанные уроки
встречается в178
Продолжить восхождение ↑Контракты batch-функции: порядок, формы, ошибки
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.