Vấn đề
App chạy ổn lúc dev, lên production với dữ liệu thật thì API bỗng dưng chậm 3–10 giây. Không có lỗi, không có exception, chỉ chậm. Nhìn vào log database thấy hàng trăm query nhỏ giống nhau bắn liên tục.
Đây là N+1 query.
N+1 query là gì
N+1 xảy ra khi code lấy 1 danh sách (1 query), rồi với mỗi item trong danh sách lại bắn thêm 1 query — tổng cộng 1 + N queries thay vì chỉ cần 1–2.
Ví dụ kinh điển với Prisma (TypeScript):
// Lấy danh sách 100 bài viết
const posts = await prisma.post.findMany({ take: 100 });
// Với mỗi bài, lấy tên tác giả → 100 query riêng
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId },
});
console.log(post.title, author.name);
}
Kết quả: 101 queries cho 100 bài viết. Nếu danh sách 500 bài → 501 queries.
Ví dụ với TypeORM:
const posts = await postRepository.find({ take: 100 });
for (const post of posts) {
// TypeORM lazy loading → query riêng cho mỗi post
const author = await post.author;
console.log(post.title, author.name);
}
Ví dụ với Sequelize:
const posts = await Post.findAll({ limit: 100 });
for (const post of posts) {
const author = await post.getAuthor(); // N query
}
Pattern này xuất hiện ở mọi ORM, mọi ngôn ngữ. Thường xuất hiện ở:
- Serializer / transformer khi format response
- Service layer lấy thêm related data
- Template render liệt kê nested objects
Tại sao nguy hiểm
| 10 items | 100 items | 1.000 items | |
|---|---|---|---|
| Số queries | 11 | 101 | 1.001 |
| Latency ước tính | ~50ms | ~500ms | ~5s |
N+1 không báo lỗi — app vẫn trả về đúng kết quả. Lúc dev với dataset nhỏ thì không ai phát hiện ra. Lên production với dữ liệu thật mới thấy chậm, và càng nhiều user càng chậm.
Mỗi query thêm một round-trip đến database (~1–5ms local, ~10–50ms nếu database ở server khác). 1.000 queries × 10ms = 10 giây.
Cách phát hiện
1. Log tất cả SQL query
Prisma — bật query logging:
const prisma = new PrismaClient({
log: ["query", "info", "warn", "error"],
});
Hoặc dùng event:
prisma.$on("query", (e) => {
console.log(`[SQL] ${e.query} — ${e.duration}ms`);
});
Khi thấy hàng chục dòng SELECT * FROM users WHERE id = ? với các giá trị id khác nhau liên tiếp → đang có N+1.
TypeORM:
const dataSource = new DataSource({
logging: ["query", "error"],
// hoặc logging: true để log tất cả
});
NestJS + TypeORM:
TypeOrmModule.forRoot({
logging: process.env.NODE_ENV === "development",
})
2. Đếm số queries per request
Với Express/NestJS, dùng middleware đếm query trong một request:
// Prisma middleware đếm query
let queryCount = 0;
prisma.$use(async (params, next) => {
queryCount++;
const result = await next(params);
return result;
});
// Middleware Express log sau mỗi response
app.use((req, res, next) => {
queryCount = 0;
res.on("finish", () => {
if (queryCount > 20) {
console.warn(`[N+1 WARNING] ${req.path} — ${queryCount} queries`);
}
});
next();
});
3. Dùng Prisma $queryRawUnsafe counter trong test
// Kiểm tra số query trong unit/integration test
const queries: string[] = [];
prisma.$on("query", (e) => queries.push(e.query));
await getPostsWithAuthors(); // hàm đang test
expect(queries.length).toBeLessThanOrEqual(3); // không được quá 3 queries
4. APM tools
Datadog, New Relic, Sentry Performance đều có thể group slow traces và cho thấy số lượng DB calls per endpoint. Trace có 100+ DB spans nhỏ giống nhau là dấu hiệu N+1.
Cách fix
1. Eager loading (cách phổ biến nhất)
Thay vì lazy load từng item, load tất cả related data trong một query.
Prisma — dùng include:
// Trước: 1 + N queries
const posts = await prisma.post.findMany({ take: 100 });
for (const post of posts) {
const author = await prisma.user.findUnique({ where: { id: post.authorId } });
}
// Sau: 1 query duy nhất với JOIN
const posts = await prisma.post.findMany({
take: 100,
include: { author: true },
});
// posts[0].author.name — truy cập trực tiếp, không query thêm
TypeORM — dùng relations:
// Trước
const posts = await postRepository.find({ take: 100 });
// Sau: JOIN trong một query
const posts = await postRepository.find({
take: 100,
relations: ["author"],
});
TypeORM QueryBuilder:
const posts = await postRepository
.createQueryBuilder("post")
.leftJoinAndSelect("post.author", "author")
.take(100)
.getMany();
Sequelize:
// Trước
const posts = await Post.findAll({ limit: 100 });
// Sau
const posts = await Post.findAll({
limit: 100,
include: [{ model: User, as: "author" }],
});
2. Select chỉ field cần thiết
include eager loading có thể kéo về nhiều cột không cần thiết. Dùng select để chỉ lấy đúng cột:
const posts = await prisma.post.findMany({
take: 100,
select: {
id: true,
title: true,
author: {
select: { name: true, avatar: true },
},
},
});
3. Manual batch query
Khi ORM không hỗ trợ hoặc cần kiểm soát hơn — query một lần lấy tất cả, rồi map trong memory:
const posts = await prisma.post.findMany({ take: 100 });
// Collect tất cả authorId, query một lần
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await prisma.user.findMany({
where: { id: { in: authorIds } },
});
// Map trong memory — O(n) thay vì N queries
const authorMap = new Map(authors.map((a) => [a.id, a]));
const result = posts.map((post) => ({
...post,
author: authorMap.get(post.authorId),
}));
2 queries thay vì N+1, dù N lớn bao nhiêu.
4. DataLoader pattern (cho GraphQL hoặc nested resolver)
N+1 đặc biệt phổ biến trong GraphQL vì mỗi field resolver chạy độc lập. DataLoader giải quyết bằng cách batch và deduplicate queries trong cùng một tick của event loop:
import DataLoader from "dataloader";
const userLoader = new DataLoader(async (ids: readonly number[]) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
});
// Phải trả về theo đúng thứ tự ids
return ids.map((id) => users.find((u) => u.id === id) ?? null);
});
// Resolver cho Post.author
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId),
// DataLoader tự batch tất cả .load() trong cùng tick → 1 query
},
};
Dù có 100 Post resolver chạy song song, DataLoader gom tất cả authorId lại và chỉ bắn 1 query với WHERE id IN (...).
Khi eager loading gây vấn đề
Eager loading giải quyết N+1 nhưng có thể gây over-fetching — load quá nhiều data không cần thiết.
// Lấy 100 bài viết kèm author kèm posts của author kèm comments...
const posts = await prisma.post.findMany({
include: {
author: {
include: {
posts: { // tất cả bài của từng author
include: {
comments: true, // tất cả comment của từng bài
},
},
},
},
},
});
Kết quả: 1 query nhưng trả về cực kỳ nhiều data, query JOIN phức tạp, có thể còn chậm hơn N+1 ban đầu.
Nguyên tắc: chỉ include hoặc join đúng những quan hệ cần trong request đó. Kết hợp với select để giới hạn cột.
Tóm lại
| Tình huống | Giải pháp |
|---|---|
| ORM eager loading hỗ trợ | include / relations / leftJoin |
| Cần kiểm soát query hơn | Manual batch + WHERE id IN (...) |
| GraphQL resolver | DataLoader |
| Serializer gọi thêm query | Preload data trước khi serialize |
| Không biết đang có N+1 không | Bật SQL logging, đếm queries per request |
N+1 không khó fix — khó là phát hiện. Bật query logging ở môi trường dev, đặt ngưỡng cảnh báo khi số query per request vượt quá mức bình thường. Bắt sớm khi viết code sẽ dễ hơn nhiều so với debug trên production khi đã có dữ liệu thật.