Phân tích
5 phút đọc18 tháng 5, 2026

DataLoader trong NestJS: Giải quyết N+1 Problem một cách sạch sẽ

Hướng dẫn tích hợp DataLoader vào NestJS GraphQL để xử lý N+1 problem — kẻ thù thầm lặng đang làm chậm API của bạn.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Flowchart minh họa cách DataLoader hoạt động: 3 resolver gọi loader.load(id1), loader.load(id2), loader.load(id3) trong cùng event loop tick, DataLoader gom lại thành 1 batch query duy nhất đến database, kết quả được phân phối ngược lại cho từng resolver, style flat design hiện đại với nền tối và accent màu tím

Bạn đã bao giờ ngồi debug tại sao cái GraphQL API chạy chậm kinh khủng, mở query log lên thì thấy hàng trăm câu SQL gần như giống hệt nhau chưa? Mình đã từng. Và cảm giác lúc đó là vừa bực vừa xấu hổ, vì nguyên nhân hóa ra đơn giản đến không ngờ: N+1 problem.

Hôm nay mình sẽ chia sẻ cách dùng DataLoader trong NestJS để giải quyết triệt để vấn đề này. Nếu bạn đang làm GraphQL với NestJS, đây là thứ bạn nên setup từ ngày đầu tiên.

N+1 Problem — Kẻ giết performance thầm lặng

Trước khi nhảy vào code, chúng ta cần hiểu rõ vấn đề đã.

Giả sử bạn có một GraphQL schema đơn giản: Student có nhiều Friend. Client gửi query như thế này:

GraphQL
1query {
2 students {
3 name
4 friends {
5 name
6 }
7 }
8}

GraphQL resolve từng field bằng resolver function tương ứng. Vậy chuyện gì xảy ra?

  1. 1 query để lấy danh sách students
  2. Với mỗi student, lại 1 query nữa để lấy friends của student đó

Nếu có 100 students → bạn có 101 database queries. Có 1000 students? 1001 queries. Đó chính là N+1: 1 query ban đầu + N query cho từng record.

Với 3 records thì không ai để ý. Nhưng lên production với hàng nghìn records, API của bạn sẽ chậm đến mức user nghĩ app bị treo.

Diagram minh họa N+1 problem: bên trái là 1 query lấy danh sách 3 students (mũi tên xanh dương), bên phải là 3 query riêng biệt từ mỗi student đến bảng friends (mũi tên đỏ, cam, vàng), tổng cộng 4 queries, nền tối, flat design với database icon

DataLoader giải quyết vấn đề này như thế nào?

Ý tưởng cốt lõi của DataLoader rất elegant: thay vì gọi database ngay lập tức cho mỗi record, nó đợi đến cuối event loop tick, gom tất cả requests lại, rồi gửi một batch query duy nhất.

Quay lại ví dụ trên, thay vì 100 câu query riêng lẻ kiểu:

SQL
1SELECT * FROM friends WHERE studentId = 1
2SELECT * FROM friends WHERE studentId = 2
3...

DataLoader sẽ gom lại thành:

SQL
1SELECT * FROM friends WHERE studentId IN (1, 2, 3, ..., 100)

Từ 101 queries xuống còn 2 queries. Theo kinh nghiệm của mình, performance improvement thường là 10x-50x tùy dataset.

Ngoài batching, DataLoader còn cung cấp memoization cache trong scope của một request. Nghĩa là nếu cùng một student được resolve 2 lần trong cùng request, DataLoader chỉ load 1 lần. Lưu ý đây không phải application-level cache như Redis nó chỉ sống trong 1 request thôi.

Setup DataLoader trong NestJS

Cài đặt dependencies

Terminal
1npm install dataloader

Mình giả sử bạn đã có NestJS project với GraphQL (code-first hoặc schema-first đều được).

Tạo DataLoader Service

Điều mình thấy hay là pattern tạo một service chuyên quản lý tất cả loaders. Mỗi request sẽ có một instance riêng:

TypeScript
1// dataloader.service.ts
2import { Injectable, Scope } from '@nestjs/common';
3import * as DataLoader from 'dataloader';
4import { FriendsService } from './friends.service';
5
6@Injectable({ scope: Scope.REQUEST })
7export class DataLoaderService {
8 constructor(private readonly friendsService: FriendsService) {}
9
10 createFriendsLoader() {
11 return new DataLoader<string, Friend[]>(async (studentIds: string[]) => {
12 const friends = await this.friendsService.findByStudentIds(studentIds);
13
14 // Quan trọng: phải trả về đúng thứ tự với input keys
15 return studentIds.map(
16 (id) => friends.filter((friend) => friend.studentId === id)
17 );
18 });
19 }
20}

Lưu ý cực kỳ quan trọng: Batch function phải trả về array có cùng length và cùng thứ tự với input keys. Đây là lỗi phổ biến nhất mình thấy khi mọi người mới dùng DataLoader quên sort lại kết quả theo thứ tự input.

Thêm Loaders vào GraphQL Context

Để các resolver có thể truy cập loader, chúng ta cần đưa nó vào GraphQL context:

TypeScript
1// app.module.ts
2GraphQLModule.forRootAsync({
3 imports: [DataLoaderModule],
4 inject: [DataLoaderService],
5 useFactory: (dataloaderService: DataLoaderService) => ({
6 autoSchemaFile: true,
7 context: () => ({
8 loaders: {
9 friendsLoader: dataloaderService.createFriendsLoader(),
10 },
11 }),
12 }),
13}),

Sử dụng trong Resolver

TypeScript
1// students.resolver.ts
2@ResolveField('friends', () => [Friend])
3async getFriends(
4 @Parent() student: Student,
5 @Context() { loaders }: { loaders: IDataLoaders },
6) {
7 return loaders.friendsLoader.load(student.id);
8}

Khi GraphQL resolve field friends cho mỗi student, thay vì query database ngay, nó gọi loader.load(id). DataLoader sẽ tự động gom tất cả .load() calls trong cùng tick lại và gọi batch function một lần duy nhất.

'. Results flow back from database through DataLoader and fan out to each resolver. Modern flat design, dark background with purple accent colors, clean labeled arrows)

Flowchart minh họa cách DataLoader hoạt động: 3 resolver gọi loader.load(id1), loader.load(id2), loader.load(id3) trong cùng event loop tick, DataLoader gom lại thành 1 batch query duy nhất đến database, kết quả được phân phối ngược lại cho từng resolver, style flat design hiện đại với nền tối và accent màu tím

Những điều mình học được sau khi dùng DataLoader thực tế

Scope.REQUEST là bắt buộc. Mỗi request phải có DataLoader instance riêng. Nếu bạn share loader giữa các requests, cache sẽ trả về data cũ và gây bug rất khó debug. Mình từng mất nửa ngày vì lỗi này.

Đừng quên thứ tự kết quả. Batch function nhận [id1, id2, id3] thì phải trả về [result1, result2, result3] đúng thứ tự. Nếu database trả về kết quả không theo thứ tự (rất hay xảy ra), bạn phải map lại.

DataLoader không thay thế Redis. Nhiều bạn nhầm lẫn điểm này. DataLoader cache chỉ sống trong 1 request. Bạn vẫn cần application-level cache cho những data ít thay đổi.

Nên tạo một interface cho loaders. Khi project lớn lên, bạn sẽ có hàng chục loaders. Một interface IDataLoaders giúp TypeScript check được type và IDE autocomplete ngon lành:

TypeScript
1export interface IDataLoaders {
2 friendsLoader: DataLoader<string, Friend[]>;
3 coursesLoader: DataLoader<string, Course[]>;
4 // thêm loader mới ở đây
5}

Takeaways

  • N+1 problem là kẻ thù số 1 của GraphQL performance và nó rất dễ vô tình tạo ra
  • DataLoader giải quyết bằng cách batch nhiều loads thành 1 query duy nhất trong cùng event loop tick
  • Trong NestJS, dùng Scope.REQUEST cho DataLoader service, inject vào GraphQL context
  • Luôn đảm bảo batch function trả về kết quả đúng thứ tự với input keys
  • DataLoader cache không thay thế Redis nó chỉ hoạt động trong scope 1 request

Theo kinh nghiệm của mình, DataLoader là một trong những thứ đầu tiên nên setup khi bắt đầu project NestJS + GraphQL. Nó không tốn nhiều effort để implement, nhưng cái performance gain mà nó mang lại thì rất đáng kể. Đừng đợi đến lúc production chậm rồi mới nghĩ đến nó lúc đó refactor sẽ đau hơn nhiều.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.

Thấy hay? Chia sẻ cho bạn bè!