Практическое занятие №21: Устранение N+1 в GraphQL-резолверах через DataLoader
Цель работы: научиться обнаруживать проблему N+1 в GraphQL-резолверах, устранять её с помощью DataLoader и подтверждать результат измерением количества обращений к источнику данных.
Основные теоретические положения
Section titled “Основные теоретические положения”1. Почему N+1 возникает в GraphQL
Section titled “1. Почему N+1 возникает в GraphQL”GraphQL позволяет клиенту запрашивать связанные данные на нескольких уровнях вложенности. Каждый объект GraphQL-типа может иметь собственный resolver.
Например, клиент выполняет запрос:
query { posts { id title author { id name } }}Сначала resolver Query.posts получает список публикаций. Затем для каждой публикации GraphQL вызывает resolver поля Post.author.
Наивная реализация может выглядеть так:
Post: { author(post) { return db.getUserById(post.authorId); }}Если было получено 12 публикаций, такой код выполнит:
- 1 запрос для получения публикаций;
- 12 отдельных запросов для получения авторов.
Итого:
1 + N = 13 запросовгде N — количество публикаций.
В реальной СУБД это могло бы выглядеть как многократное выполнение:
SELECT id, title, author_idFROM posts;
SELECT id, name FROM users WHERE id = 1;SELECT id, name FROM users WHERE id = 2;SELECT id, name FROM users WHERE id = 1;...Проблема становится особенно заметной при больших списках и глубокой вложенности GraphQL-запроса.
2. Почему Promise.all сам по себе не устраняет N+1
Section titled “2. Почему Promise.all сам по себе не устраняет N+1”Параллельное выполнение нескольких запросов может уменьшить суммарное время ожидания, но количество обращений к базе данных останется прежним.
Например:
await Promise.all([ db.getUserById("1"), db.getUserById("2"), db.getUserById("3"),]);по-прежнему означает три обращения к источнику данных.
Для устранения N+1 необходимо не только выполнять запросы параллельно, а объединить несколько запросов в один пакетный запрос.
Для SQL это обычно означает переход от нескольких запросов вида:
SELECT * FROM users WHERE id = ?;к одному запросу:
SELECT *FROM usersWHERE id IN (?, ?, ?, ?);3. DataLoader и batching
Section titled “3. DataLoader и batching”DataLoader собирает несколько вызовов load(key), выполненных в рамках одной фазы выполнения, и передаёт все ключи в одну batch-функцию.
Пример:
const userLoader = new DataLoader(async (ids) => { return db.getUsersByIds(ids);});Resolver при этом остаётся простым:
Post: { author(post, _args, context) { return context.loaders.userById.load(post.authorId); }}Если GraphQL одновременно запрашивает авторов публикаций с идентификаторами:
1, 2, 1, 3, 2, 4DataLoader может объединить их в один пакетный вызов.
При включённом стандартном кэше повторяющиеся ключи также не передаются batch-функции повторно:
[1, 2, 3, 4]4. Требование к batch-функции
Section titled “4. Требование к batch-функции”Batch-функция должна вернуть массив:
- той же длины, что и массив ключей;
- в том же порядке, что и ключи.
Например, если DataLoader получил:
["3", "1", "4"]нельзя просто вернуть пользователей в порядке, полученном из базы данных.
Корректный вариант:
async function batchUsers(ids) { const users = await db.findUsersByIds(ids);
const byId = new Map( users.map((user) => [user.id, user]) );
return ids.map((id) => byId.get(id) ?? null);}Это необходимо, потому что результат с индексом i должен соответствовать ключу с тем же индексом i.
5. Кэш DataLoader — не Redis
Section titled “5. Кэш DataLoader — не Redis”DataLoader содержит небольшой memoization cache. Его задача — не хранить данные между запросами пользователей, а не выполнять повторную загрузку одного и того же ключа несколько раз внутри одного GraphQL-запроса.
Поэтому DataLoader обычно создаётся заново для каждого HTTP/GraphQL-запроса:
function createContext() { return { loaders: { userById: new DataLoader(batchUsers), }, };}Нежелательный вариант:
const userById = new DataLoader(batchUsers);
function createContext() { return { loaders: { userById, }, };}Во втором случае один и тот же кэш начинает использоваться разными запросами. Это может привести к устаревшим данным, некорректной работе с правами доступа и неограниченному росту кэша.
Алгоритм выполнения работы
Section titled “Алгоритм выполнения работы”Получение исходного кода проекта
Section titled “Получение исходного кода проекта”Клонируйте репозиторий:
git clone https://github.com/ghostITwe/optimization-web-apps.gitcd optimization-web-apps/lab21-graphql-dataloaderУстановите зависимости:
npm installСтруктура учебного проекта:
lab21-graphql-dataloader/├── starter/│ ├── app.js│ ├── db.js│ └── server.js├── queries/│ ├── 01-n-plus-one.graphql│ └── 02-cache.graphql├── tests/│ ├── measure.js│ └── check.js└── package.jsonВ проекте используется учебный модуль db.js, имитирующий работу базы данных. Он специально считает количество обращений к данным, чтобы проблема N+1 была видна независимо от производительности конкретного компьютера.
1. Анализ исходной реализации
Section titled “1. Анализ исходной реализации”Откройте файл:
starter/app.jsНайдите resolver:
Post: { author(post) { return db.getUserById(post.authorId); },}Объясните, сколько раз будет вызван db.getUserById, если GraphQL вернёт 12 публикаций.
Запустите измерение:
npm run measureОжидаемая картина до оптимизации:
getPosts: 1getUserById: 12getUsersByIds: 0totalQueries: 13После этого запустите сервер:
npm startGraphQL endpoint:
http://localhost:4000/graphqlВыполните запрос из файла:
queries/01-n-plus-one.graphqlСамостоятельно ответьте:
- Почему количество запросов зависит от количества
posts? - Почему повторное появление одного автора в нескольких публикациях не помогает текущей реализации?
- Запишите формулу количества обращений к данным для
Nпубликаций.
В отчёте сохраните результаты для исходной версии.
2. Реализация пакетного метода
Section titled “2. Реализация пакетного метода”Изучите файл:
starter/db.jsВ нём уже присутствуют два метода:
db.getUserById(id)db.getUsersByIds(ids)Метод getUsersByIds(ids) имитирует один SQL-запрос вида:
SELECT id, nameFROM usersWHERE id IN (...);Проверьте его реализацию.
Обратите внимание на строку:
return ids.map( (id) => USERS.find((user) => user.id === id) ?? null);Ответьте письменно:
- Почему нельзя заменить эту строку на обычный
USERS.filter(...)? - Что произойдёт, если база данных вернёт строки не в том порядке, в котором пришли идентификаторы?
- Что должна вернуть batch-функция для отсутствующего пользователя?
Изменять db.js на этом этапе не требуется.
3. Создание DataLoader
Section titled “3. Создание DataLoader”Установленный пакет dataloader необходимо использовать самостоятельно.
В starter/app.js импортируйте DataLoader:
import DataLoader from "dataloader";Создайте функцию:
function createLoaders() { // реализация студента}Внутри неё создайте loader userById.
Требования:
- loader должен принимать отдельный
authorIdчерез.load(id); - batch-функция должна вызывать
db.getUsersByIds(ids); - один batch должен обслуживать несколько resolver-вызовов;
- loader нельзя создавать внутри resolver
Post.author.
Ожидаемый интерфейс:
const loaders = createLoaders();
await loaders.userById.load("1");После реализации временно добавьте вывод ключей batch-функции:
console.log("BATCH:", ids);Запустите:
npm run measureи зафиксируйте массив ключей.
Для 12 публикаций в проекте используются только четыре автора. Объясните, почему в batch-функцию должны попасть четыре уникальных идентификатора, а не 12 значений.
4. Подключение DataLoader к GraphQL context
Section titled “4. Подключение DataLoader к GraphQL context”Измените createContext().
Каждый GraphQL-запрос должен получать новый набор loader-ов:
export function createContext() { return { loaders: createLoaders(), };}После этого измените resolver:
Post: { author(post, _args, context) { // вместо db.getUserById(...) },}Resolver должен получать пользователя через DataLoader.
После изменения снова выполните:
npm run measureЦелевой результат:
getPosts: 1getUserById: 0getUsersByIds: 1totalQueries: 2Сравните результаты:
| Метрика | До DataLoader | После DataLoader |
|---|---|---|
| Получение списка публикаций | 1 | 1 |
| Одиночные запросы пользователя | 12 | 0 |
| Пакетные запросы пользователей | 0 | 1 |
| Всего обращений | 13 | 2 |
Рассчитайте процент уменьшения количества обращений к источнику данных:
(13 - 2) / 13 × 100%5. Проверка request-scoped cache
Section titled “5. Проверка request-scoped cache”Выполните:
npm testТест проверяет:
- отсутствие вызовов
getUserById; - наличие одного batch-вызова
getUsersByIds; - общее число запросов, равное 2;
- отсутствие повторяющихся ключей внутри batch;
- создание нового DataLoader для следующего GraphQL-запроса.
Если DataLoader был создан глобально, тест второго GraphQL-запроса завершится ошибкой: старый кэш сохранит пользователей между запросами.
Исправьте реализацию так, чтобы loader создавался внутри createContext().
6. Эксперимент с кэшированием одинаковых ключей
Section titled “6. Эксперимент с кэшированием одинаковых ключей”Выполните GraphQL-запрос:
query CacheExperiment { posts(limit: 4) { id title
firstAuthor: author { id name }
secondAuthor: author { id name } }}Для каждой публикации поле author запрашивается дважды через разные alias.
Ответьте:
- Сколько раз resolver
Post.authorпотенциально вызывается для четырёх публикаций? - Сколько идентификаторов фактически получает batch-функция?
- Почему повторный вызов
.load(id)не создаёт новое обращение к данным? - Изменится ли ситуация, если создать DataLoader с параметром:
{ cache: false}Проведите эксперимент:
new DataLoader(batchFunction, { cache: false,});Снова выполните запрос и посмотрите массив BATCH.
После эксперимента верните стандартное кэширование.
7. Финальная проверка и отчёт
Section titled “7. Финальная проверка и отчёт”Удалите временный console.log("BATCH:", ids).
Выполните:
npm testВсе проверки должны завершиться успешно.
В отчёт включите:
- количество обращений к данным до оптимизации;
- количество обращений после оптимизации;
- массив ключей, полученный batch-функцией;
- объяснение причины возникновения N+1;
- объяснение роли batching;
- объяснение роли memoization cache;
- объяснение, почему DataLoader должен создаваться на каждый GraphQL-запрос;
- вывод о результате оптимизации.
Контрольные вопросы
Section titled “Контрольные вопросы”- Почему GraphQL сам по себе не предотвращает проблему N+1?
- Почему
Promise.allне является полноценным решением N+1? - Как DataLoader объединяет несколько вызовов
.load(id)в один batch? - Какие два требования предъявляются к массиву, возвращаемому batch-функцией?
- Почему результат SQL-запроса с
WHERE id IN (...)иногда необходимо дополнительно переупорядочивать? - Чем batching отличается от caching?
- Почему повторяющиеся
authorIdне попадают в batch-функцию несколько раз при стандартных настройках DataLoader? - Почему не рекомендуется создавать один глобальный DataLoader для всех пользователей приложения?
- Что произойдёт с количеством запросов, если отключить кэш через
{ cache: false }? - В каком случае после mutation может потребоваться вызов
.clear(id)или.clearAll()у DataLoader?
Ожидаемый результат работы
Section titled “Ожидаемый результат работы”После выполнения практической работы студент должен получить GraphQL-приложение, в котором запрос:
query { posts { id title author { id name } }}обрабатывается за два обращения к источнику данных:
1 × получение списка posts1 × пакетное получение usersвместо исходных:
1 × получение списка posts12 × получение user по idТо есть учебная реализация должна продемонстрировать переход:
13 запросов → 2 запроса