N+1 Query Problem

Tran Van Ngoc|

TL;DR

N+1 Query Problem là sự cố hiệu năng phổ biến khi làm việc với ORM (Object-Relational Mapping), xảy ra khi ứng dụng thực thi 11 câu truy vấn ban đầu để lấy danh sách NN bản ghi cha, sau đó tiếp tục phát sinh thêm NN câu truy vấn con lặp đi lặp lại để lấy dữ liệu quan hệ liên quan. Tác hại trực tiếp là gây bùng nổ số lượng truy vấn đến CSDL, tăng độ trễ (latency), tiêu tốn CPU và cạn kiệt Connection Pool. Các giải pháp khắc phục triệt để bao gồm Eager Loading (JOIN / Batch IN clause), DataLoader Pattern (Batching & Caching ở tầng ứng dụng), hoặc bật chế độ Strict Loading.


Core Concepts & Mechanics

1. Cơ chế phát sinh vấn đề

Nguyên nhân gốc rễ của N+1 Query là cơ chế Lazy Loading (Tải lười) được bật mặc định hoặc được lập trình viên sử dụng vô tình trong ORM:

  1. Truy vấn chính (Query 1): Ứng dụng gửi 1 câu lệnh SELECT để lấy NN bản ghi từ bảng cha (ví dụ: lấy 100 bài viết Post).
  2. Vòng lặp truy vấn phụ (Query N): Khi ứng dụng duyệt qua danh sách NN bài viết để truy cập thuộc tính quan hệ (ví dụ: post.author), ORM tự động phát sinh riêng lẻ 1 câu lệnh SELECT đến bảng con (User) cho từng bài viết.
  3. Tổng số truy vấn: 1+N1 + N (ví dụ: 1+100=1011 + 100 = 101 truy vấn SQL tới Database cho một request duy nhất).

Total Queries=1+N\text{Total Queries} = 1 + N

2. Tác hại đối với Hệ thống

  • Network Latency Overhead: Mỗi câu truy vấn SQL phát sinh thêm thời gian rRTT (Round-Trip Time) giữa App Server và Database Server. Khi NN lớn, độ trễ bị cộng dồn nghiêm trọng.
  • Cạn kiệt Connection Pool: Số lượng truy vấn quá lớn trong thời gian ngắn làm quá tải và chiếm dụng toàn bộ các kết nối trong DB Pool, dẫn đến tình trạng treo request (Timeout).
  • Quá tải CPU & Memory CSDL: Database Engine phải phân tích (parse), lập kế hoạch thực thi (query plan) và mở/đóng bối cảnh truy vấn N+1N+1 lần thay vì chỉ 1-2 lần.

Solutions & Strategies

1. Eager Loading

Khai báo cho ORM biết trước các quan hệ cần lấy cùng lúc với bảng cha trong câu truy vấn ban đầu.

  • Single Query (JOIN Strategy):
    • ORM sinh ra câu truy vấn LEFT JOIN hoặc INNER JOIN gộp dữ liệu cha và con trong 11 câu SQL duy nhất.
    • Phù hợp: Quan hệ 1-1 hoặc 1-N có quy mô dữ liệu con vừa phải.
    • Lưu ý: Tránh JOIN quá nhiều bảng N-N gây ra hiện tượng Cartesian Product (bùng nổ số dòng trùng lặp ở kết quả trả về).
  • Batch Query (Preload / IN Clause Strategy):
    • ORM tách thành 22 câu truy vấn: câu truy vấn 1 lấy NN bản ghi cha, thu thập tất cả danh sách ID con; câu truy vấn 2 sử dụng WHERE id IN (id_1, id_2, ..., id_N) để nạp toàn bộ bản ghi con trong 1 lần.
    • Phù hợp: Quan hệ 1-N lớn hoặc Many-to-Many.

2. DataLoader Pattern

Đặc biệt phổ biến và chuẩn mực trong kiến trúc GraphQL Resolvers hoặc Microservices:

  • Cách hoạt động: Gom (batch) tất cả các yêu cầu đọc dữ liệu lẻ tẻ phát sinh trong cùng một vòng lặp sự kiện (Event Loop Tick) của ứng dụng thành một truy vấn mảng duy nhất (IN (...)), đồng thời cache kết quả trong phạm vi vòng đời request (Request Context).
  • Ưu điểm: Giúp giữ cho các hàm resolver/service độc lập hoàn toàn mà vẫn loại bỏ triệt để N+1 query bên dưới tầng CSDL.

3. Strict Loading

  • Thiết lập ORM ném ra lỗi (Exception) ngay khi phát hiện có mã nguồn đang cố truy cập thuộc tính quan hệ chưa được Eager Load (ví dụ: strict_loading trong Ruby on Rails, lazy='noload' trong SQLAlchemy).
  • Giúp phát hiện sớm lỗi N+1 Query ngay từ môi trường Development và Integration Testing.

Concrete Code Examples

1. Anti-Pattern: Mã nguồn bị N+1 Query

//  LỖI N+1 QUERY:
// Step 1: Chạy 1 câu query lấy 10 bài viết (1 Query)
const posts = await db.post.findMany({ take: 10 });

// Step 2: Duyệt qua 10 bài viết, mỗi lần đọc post.authorId lại gọi DB (10 Queries)
for (const post of posts) {
  // Phát sinh: SELECT * FROM users WHERE id = post.authorId; (Chạy 10 lần!)
  const author = await db.user.findUnique({ where: { id: post.authorId } });
  console.log(`${post.title} được viết bởi ${author?.name}`);
}
//  TỔNG CỘNG: 1 + 10 = 11 Queries!

2. Solution 1: Sử dụng Eager Loading

//  TỐI ƯU BẰNG EAGER LOADING:
// ORM tự động phát sinh JOIN hoặc truy vấn IN batching bên dưới
const posts = await db.post.findMany({
  take: 10,
  include: {
    author: true, // Khai báo nạp kèm dữ liệu author ngay từ đầu
  },
});

for (const post of posts) {
  // post.author đã có sẵn dữ liệu trong bộ nhớ, KHÔNG phát sinh thêm câu SQL nào
  console.log(`${post.title} được viết bởi ${post.author.name}`);
}
//  TỔNG CỘNG: Chỉ 1 hoặc 2 Queries!

3. Solution 2: Sử dụng DataLoader Pattern

import DataLoader from "dataloader";

// Khai báo Batch Function: Gom danh sách authorIds thành 1 câu SELECT IN (...)
const authorLoader = new DataLoader(async (authorIds: readonly string[]) => {
  const users = await db.user.findMany({
    where: { id: { in: [...authorIds] } },
  });

  const userMap = new Map(users.map((u) => [u.id, u]));
  return authorIds.map((id) => userMap.get(id) || null);
});

// Trong ứng dụng / GraphQL Resolver:
const posts = await db.post.findMany({ take: 10 });

const postsWithAuthors = await Promise.all(
  posts.map(async (post) => ({
    ...post,
    // authorLoader tự động gom 10 calls .load() trong tick này thành 1 query `IN (...)`
    author: await authorLoader.load(post.authorId),
  })),
);
//  TỔNG CỘNG: 1 query lấy Posts + 1 query Batch lấy Authors = 2 Queries!