N+1 Query — Cái bẫy ORM mà ai cũng dính ít nhất một lần

API trả về danh sách bài viết kèm tên tác giả. Code trông sạch, logic đúng, test pass. Dev server trả về trong 30ms.

Lên staging với 200 bài viết: 1.2 giây. Production với 500 bài: timeout.

Không có slow query nào trong DB log. Từng câu query chạy dưới 1ms. Nhưng hệ thống vẫn chết.

Đây là N+1.

Vấn đề

Lazy loading trong ORM có nghĩa là dữ liệu liên quan chỉ được fetch khi code thực sự truy cập đến nó. Tính năng này tiện — cho đến khi bạn dùng nó trong một vòng lặp.

// 1 query lấy danh sách bài viết
const posts = await db.query("SELECT * FROM posts LIMIT 100");

for (const post of posts) {
  // 1 query cho mỗi bài viết để lấy tác giả
  const author = await db.query(
    `SELECT * FROM users WHERE id = ${post.author_id}`
  );
  console.log(post.title, author.name);
}

Đây là 101 câu SQL để render 100 bài viết. Với 500 bài: 501 câu. Con số scale tuyến tính theo data.

Từng câu query tìm theo primary key — cực nhanh. Vấn đề không nằm ở DB mà ở network round trip: mỗi câu query là một chuyến đi-về giữa app server và DB server. Nếu latency là 2ms:

501 queries × 2ms = ~1 giây chỉ cho network overhead

DB monitor nhìn vào không thấy vấn đề gì — từng query đều fast. Nhưng API thì chết.

ORM làm cho lỗi này tàng hình

Với raw SQL, vòng lặp query thường dễ nhận ra. Với ORM thì khác:

// Prisma — trông sạch, nhưng là N+1
const posts = await prisma.post.findMany({ take: 100 });

for (const post of posts) {
  const author = await prisma.user.findUnique({
    where: { id: post.authorId }
  });
  // ...
}
# Django ORM — một dòng, nhưng N+1 ẩn bên trong template
posts = Post.objects.all()[:100]
for post in posts:
    print(post.author.name)  # mỗi .author là 1 query

ORM tự động hóa phần query nên lập trình viên không nhìn thấy SQL đang chạy. Đây là lý do N+1 tồn tại rất lâu trong production trước khi bị phát hiện.

Cách phát hiện

Bật query logging và đếm số câu SQL trong một request:

// Prisma
const prisma = new PrismaClient({
  log: ['query'],
});
# Django
import logging
logging.getLogger('django.db.backends').setLevel(logging.DEBUG)

Nếu thấy hàng chục câu SELECT * FROM users WHERE id = ? với giá trị khác nhau xuất hiện liên tiếp — đó là N+1.

Django Debug ToolbarPrisma query logging đều hiển thị tổng số queries per request. Con số đó tăng tỉ lệ thuận với data là dấu hiệu chắc chắn.

Giải pháp 1: Eager Loading trong ORM

Báo cho ORM biết trước là sẽ cần dữ liệu liên quan — nó sẽ tự gom lại thành ít query nhất có thể.

// Prisma
const posts = await prisma.post.findMany({
  take: 100,
  include: { author: true },
});
# Django — select_related cho ForeignKey
posts = Post.objects.select_related('author')[:100]

# Django — prefetch_related cho ManyToMany hoặc reverse FK
posts = Post.objects.prefetch_related('tags')[:100]
// TypeORM
const posts = await postRepository.find({
  relations: ['author'],
  take: 100,
});
// GORM
db.Preload("Author").Limit(100).Find(&posts)
// Laravel Eloquent
$posts = Post::with('author')->limit(100)->get();

Tất cả các cách trên đều giảm xuống còn 2 queries: một lấy posts, một lấy tất cả authors cần thiết bằng WHERE id IN (...).

Giải pháp 2: Raw SQL với IN (...)

Nếu không dùng ORM hoặc ORM không đủ linh hoạt cho trường hợp cụ thể:

// Query 1: lấy danh sách posts
const posts = await db.query("SELECT * FROM posts LIMIT 100");

// Gom tất cả author_id
const authorIds = [...new Set(posts.map(p => p.author_id))];

// Query 2: lấy tất cả authors trong một lần
const authors = await db.query(
  `SELECT * FROM users WHERE id IN (${authorIds.join(',')})`
);

// Map trên RAM — O(1) lookup
const authorMap = Object.fromEntries(authors.map(a => [a.id, a]));

const result = posts.map(post => ({
  title: post.title,
  author: authorMap[post.author_id],
}));

Luôn là 2 queries bất kể N. Phần mapping diễn ra trên RAM, không tốn round trip.

Lưu ý: câu IN (...) với danh sách ID người dùng nhận từ input cần dùng parameterized query để tránh SQL injection:

const placeholders = authorIds.map((_, i) => `$${i + 1}`).join(',');
const authors = await db.query(
  `SELECT * FROM users WHERE id IN (${placeholders})`,
  authorIds
);

Giải pháp 3: JOIN

Gộp mọi thứ vào một câu SQL duy nhất:

SELECT posts.id, posts.title, users.name AS author_name
FROM posts
INNER JOIN users ON posts.author_id = users.id
LIMIT 100;

1 query, DB tự ghép dữ liệu trước khi trả về.

JOIN tốt cho quan hệ đơn giản và khi cần dữ liệu từ cả hai bảng trong cùng một lần sort hay filter. Nhưng với quan hệ nhiều tầng (posts → authors → author_profile → ...) hoặc quan hệ one-to-many (posts → comments), JOIN tạo ra Cartesian product làm phình kết quả và code mapping phía application phức tạp hơn đáng kể.

Nhiều team dùng IN (...) thay vì JOIN cho các trường hợp này vì:

  • Kết quả trả về rõ ràng, không bị duplicate rows
  • Dễ cache từng bảng riêng trong Redis
  • Dễ scale khi schema thay đổi

Dấu hiệu nhận biết trong code review

Pattern nguy hiểm nhất trông như thế này:

const items = await fetchList();        // 1 query

const result = await Promise.all(       // N queries chạy song song
  items.map(item => fetchDetail(item.id))
);

Promise.all không giải quyết N+1 — nó chỉ chạy N queries song song thay vì tuần tự. Với N nhỏ (< 20) thì chấp nhận được. Với N lớn, bạn đang bắn đồng loạt N requests vào DB cùng lúc, có thể làm cạn kiệt connection pool.

Rule đơn giản để review: bất kỳ đoạn code nào gọi DB bên trong for, map, forEach, hoặc Promise.all trên một mảng dữ liệu đều là ứng viên N+1.

Tóm tắt

Cách xử lý Số queries Phù hợp
Lazy loading trong vòng lặp 1 + N Không dùng
Promise.all N queries N (song song) Chỉ khi N nhỏ, biết trước
Eager loading (ORM) 2 Quan hệ đơn giản
IN (...) thủ công 2 Mọi trường hợp, dễ cache
JOIN 1 Quan hệ đơn, cần filter/sort chung

N+1 không làm crash hệ thống ngay — nó âm thầm làm API chậm dần theo data. Dev environment ít data nên không thấy. Production nhiều data thì thấy khi đã muộn. Bật query logging từ sớm, đếm số queries per request, và không để DB call nằm trong vòng lặp.