1
+N
SQL
SLOW
N+1 Query
orm • join • batch

N+1 Query trong ORM: cách phát hiện bằng SQL log và fix bằng eager loading

N+1 query không gây lỗi nhưng làm API chậm dần khi data tăng — ORM gửi 1 query lấy danh sách rồi lặp N query con. Cách bật SQL log để bắt, đếm query per request, và fix bằng include, batch, hoặc DataLoader.

11 phút đọc18/06/2026

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.