Что такое DataLoader и как он решает проблему N+1 запросов в NestJS?

SeniorNestJS · Backend·Обновлено 27 августа 2026
Коротко
DataLoader — это утилита для батчинга и кэширования запросов к данным в рамках одного тика event loop: вместо N отдельных запросов к БД для N сущностей он собирает все id за один такт, делает один запрос через batch-функцию и раздаёт результаты вызывающим. В NestJS это стандартное решение проблемы N+1, особенно в GraphQL-резолверах, где вложенные поля вызывают отдельные запросы для каждого элемента списка.

В чём проблема N+1

Проблема N+1 возникает, когда сначала выполняется один запрос за списком из N сущностей, а затем для каждой из них отдельно выполняется ещё один запрос за связанными данными. В итоге вместо 2 запросов получаем 1 + N. Особенно остро это проявляется в GraphQL-резолверах NestJS: если у запроса posts { author { name } } резолвер поля author вызывается независимо для каждого поста, а под капотом обращается к БД или внешнему сервису — при 100 постах будет 100 отдельных запросов к таблице пользователей, каждый из которых мог бы быть объединён в один WHERE id IN (...).

Проблема не ограничена GraphQL — она возникает в любом месте, где сериализация или маппинг DTO триггерит await внутри цикла или Promise.all над списком с отдельным запросом на каждый элемент.

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

DataLoader (библиотека dataloader, созданная Facebook для GraphQL.js) решает проблему за счёт двух механизмов:

Батчинг (batching)

DataLoader откладывает выполнение до конца текущего тика event loop (через process.nextTick или microtask). Все вызовы load(id), сделанные синхронно в рамках одного тика, собираются в массив id, после чего вызывается ровно один раз batch-функция (keys) => Promise<value[]>, которая должна вернуть массив значений строго в том же порядке и той же длины, что и массив ключей.

Кэширование (per-request caching)

В рамках одного экземпляра DataLoader повторный вызов load(id) с уже запрошенным ключом не порождает новый запрос — результат берётся из внутреннего кэша (обычно Map). Это критично: экземпляр DataLoader должен создаваться заново на каждый HTTP/GraphQL-запрос (request-scoped), иначе кэш одного пользователя может утечь к другому или устареть.

Интеграция с NestJS

В NestJS DataLoader обычно оформляют как request-scoped провайдер (Scope.REQUEST) и внедряют в резолверы через @Inject. Ключевые моменты:

  • batch-функция должна делать один запрос через IN, WHERE ANY, findByIds или groupBy-агрегацию, а не цикл с отдельными запросами;
  • результат обязательно нужно переупорядочить в соответствии со входными ключами — БД не гарантирует порядок строк для IN (...);
  • при отсутствии значения для ключа нужно вернуть null или Error в соответствующей позиции массива, а не выбрасывать исключение из batch-функции целиком — иначе упадут все запросы в батче;
  • DataLoader создаётся на запрос, поэтому его удобно провайдить через ContextIdFactory или контекст GraphQL-модуля (context: () => ({ loaders: createLoaders() })), а не как singleton-сервис.

Когда DataLoader не нужен

Если данные уже загружаются через TypeORM/Prisma с relations/include за один SQL-запрос с JOIN, DataLoader избыточен — он решает проблему именно на уровне резолверов, где вызовы происходят независимо друг от друга и не видят общего контекста списка.

Что хочет услышать интервьюер

Кандидат чётко объясняет механику N+1 на конкретном примере (например, GraphQL-резолвер поля связанной сущности)

Понимание, что DataLoader батчит вызовы в пределах одного тика event loop, а не произвольного окна времени

Знание, что batch-функция должна возвращать результаты в том же порядке, что и входные ключи

Осознание необходимости request-scoped жизненного цикла DataLoader (не singleton) для избежания утечки кэша между запросами

Умение сравнить DataLoader с альтернативой — JOIN/eager loading на уровне ORM

Пример: Проблема N+1 в GraphQL-резолвере NestJS

@Resolver(() => Post)
export class PostResolver {
  constructor(private readonly usersService: UsersService) {}

  // Этот резолвер вызывается отдельно для КАЖДОГО поста в списке
  @ResolveField(() => User)
  async author(@Parent() post: Post) {
    // При 100 постах — 100 отдельных запросов к БД
    return this.usersService.findById(post.authorId);
  }
}

Пример: Решение через DataLoader

// loaders/user.loader.ts
import DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST }) // важно: новый экземпляр на каждый запрос
export class UserLoader {
  constructor(private readonly usersService: UsersService) {}

  public readonly batchUsers = new DataLoader<string, User>(
    async (userIds: readonly string[]) => {
      // один запрос вместо N
      const users = await this.usersService.findByIds([...userIds]);
      const usersById = new Map(users.map((u) => [u.id, u]));

      // критично: результат должен совпадать по порядку и длине с userIds
      return userIds.map(
        (id) => usersById.get(id) ?? new Error(`Пользователь ${id} не найден`),
      );
    },
  );
}

// post.resolver.ts
@Resolver(() => Post)
export class PostResolver {
  constructor(private readonly userLoader: UserLoader) {}

  @ResolveField(() => User)
  async author(@Parent() post: Post) {
    // все вызовы за один тик соберутся в один батч-запрос
    return this.userLoader.batchUsers.load(post.authorId);
  }
}

Типичные ошибки

Кандидат путает DataLoader с обычным кэшированием (Redis/in-memory TTL-кэш) и не упоминает батчинг как ключевой механизм

Создание DataLoader как singleton-провайдера, из-за чего кэш и батчи расшариваются между разными пользователями и запросами

Игнорирование требования сохранять порядок и длину результата, совпадающие с массивом ключей batch-функции

Реализация batch-функции через цикл с отдельными await-запросами вместо одного агрегирующего запроса к БД

Уверенность, что DataLoader сам решает N+1 «магически», без понимания, что batch-функцию нужно писать эффективно самому

Лучшие курсы по теме

изображение курса

Docker и Ansible

Антон Ларичев
AI-тренажерыAI-тренажеры
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Node.js с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Nest.js с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.6
3 999 ₽ 6 990 ₽
Подробнее