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.
Nguyễn Nhật Long
@nguyennhatlong1303

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:
1query {2 students {3 name4 friends {5 name6 }7 }8}
GraphQL resolve từng field bằng resolver function tương ứng. Vậy chuyện gì xảy ra?
- 1 query để lấy danh sách students
- 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.

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:
1SELECT * FROM friends WHERE studentId = 12SELECT * FROM friends WHERE studentId = 23...
DataLoader sẽ gom lại thành:
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
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:
1// dataloader.service.ts2import { Injectable, Scope } from '@nestjs/common';3import * as DataLoader from 'dataloader';4import { FriendsService } from './friends.service';56@Injectable({ scope: Scope.REQUEST })7export class DataLoaderService {8 constructor(private readonly friendsService: FriendsService) {}910 createFriendsLoader() {11 return new DataLoader<string, Friend[]>(async (studentIds: string[]) => {12 const friends = await this.friendsService.findByStudentIds(studentIds);1314 // Quan trọng: phải trả về đúng thứ tự với input keys15 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:
1// app.module.ts2GraphQLModule.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
1// students.resolver.ts2@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)

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:
1export interface IDataLoaders {2 friendsLoader: DataLoader<string, Friend[]>;3 coursesLoader: DataLoader<string, Course[]>;4 // thêm loader mới ở đây5}
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.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễ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è!