Skip to content

Практическое занятие №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_id
FROM 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 users
WHERE id IN (?, ?, ?, ?);

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, 4

DataLoader может объединить их в один пакетный вызов.

При включённом стандартном кэше повторяющиеся ключи также не передаются 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.

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 “Получение исходного кода проекта”

Клонируйте репозиторий:

Terminal window
git clone https://github.com/ghostITwe/optimization-web-apps.git
cd optimization-web-apps/lab21-graphql-dataloader

Установите зависимости:

Terminal window
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 публикаций.

Запустите измерение:

Terminal window
npm run measure

Ожидаемая картина до оптимизации:

getPosts: 1
getUserById: 12
getUsersByIds: 0
totalQueries: 13

После этого запустите сервер:

Terminal window
npm start

GraphQL endpoint:

http://localhost:4000/graphql

Выполните запрос из файла:

queries/01-n-plus-one.graphql

Самостоятельно ответьте:

  1. Почему количество запросов зависит от количества posts?
  2. Почему повторное появление одного автора в нескольких публикациях не помогает текущей реализации?
  3. Запишите формулу количества обращений к данным для N публикаций.

В отчёте сохраните результаты для исходной версии.


2. Реализация пакетного метода

Section titled “2. Реализация пакетного метода”

Изучите файл:

starter/db.js

В нём уже присутствуют два метода:

db.getUserById(id)
db.getUsersByIds(ids)

Метод getUsersByIds(ids) имитирует один SQL-запрос вида:

SELECT id, name
FROM users
WHERE id IN (...);

Проверьте его реализацию.

Обратите внимание на строку:

return ids.map(
(id) => USERS.find((user) => user.id === id) ?? null
);

Ответьте письменно:

  1. Почему нельзя заменить эту строку на обычный USERS.filter(...)?
  2. Что произойдёт, если база данных вернёт строки не в том порядке, в котором пришли идентификаторы?
  3. Что должна вернуть batch-функция для отсутствующего пользователя?

Изменять db.js на этом этапе не требуется.


Установленный пакет 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);

Запустите:

Terminal window
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.

После изменения снова выполните:

Terminal window
npm run measure

Целевой результат:

getPosts: 1
getUserById: 0
getUsersByIds: 1
totalQueries: 2

Сравните результаты:

МетрикаДо DataLoaderПосле DataLoader
Получение списка публикаций11
Одиночные запросы пользователя120
Пакетные запросы пользователей01
Всего обращений132

Рассчитайте процент уменьшения количества обращений к источнику данных:

(13 - 2) / 13 × 100%

Выполните:

Terminal window
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.

Ответьте:

  1. Сколько раз resolver Post.author потенциально вызывается для четырёх публикаций?
  2. Сколько идентификаторов фактически получает batch-функция?
  3. Почему повторный вызов .load(id) не создаёт новое обращение к данным?
  4. Изменится ли ситуация, если создать DataLoader с параметром:
{
cache: false
}

Проведите эксперимент:

new DataLoader(batchFunction, {
cache: false,
});

Снова выполните запрос и посмотрите массив BATCH.

После эксперимента верните стандартное кэширование.


7. Финальная проверка и отчёт

Section titled “7. Финальная проверка и отчёт”

Удалите временный console.log("BATCH:", ids).

Выполните:

Terminal window
npm test

Все проверки должны завершиться успешно.

В отчёт включите:

  • количество обращений к данным до оптимизации;
  • количество обращений после оптимизации;
  • массив ключей, полученный batch-функцией;
  • объяснение причины возникновения N+1;
  • объяснение роли batching;
  • объяснение роли memoization cache;
  • объяснение, почему DataLoader должен создаваться на каждый GraphQL-запрос;
  • вывод о результате оптимизации.
  1. Почему GraphQL сам по себе не предотвращает проблему N+1?
  2. Почему Promise.all не является полноценным решением N+1?
  3. Как DataLoader объединяет несколько вызовов .load(id) в один batch?
  4. Какие два требования предъявляются к массиву, возвращаемому batch-функцией?
  5. Почему результат SQL-запроса с WHERE id IN (...) иногда необходимо дополнительно переупорядочивать?
  6. Чем batching отличается от caching?
  7. Почему повторяющиеся authorId не попадают в batch-функцию несколько раз при стандартных настройках DataLoader?
  8. Почему не рекомендуется создавать один глобальный DataLoader для всех пользователей приложения?
  9. Что произойдёт с количеством запросов, если отключить кэш через { cache: false }?
  10. В каком случае после mutation может потребоваться вызов .clear(id) или .clearAll() у DataLoader?

Ожидаемый результат работы

Section titled “Ожидаемый результат работы”

После выполнения практической работы студент должен получить GraphQL-приложение, в котором запрос:

query {
posts {
id
title
author {
id
name
}
}
}

обрабатывается за два обращения к источнику данных:

1 × получение списка posts
1 × пакетное получение users

вместо исходных:

1 × получение списка posts
12 × получение user по id

То есть учебная реализация должна продемонстрировать переход:

13 запросов → 2 запроса