Что такое DataLoader и как он решает проблему N+1 запросов в NestJS?
В чём проблема 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-функцию нужно писать эффективно самому


