Họ đang đo gì
Bạn có gắn được hiệu năng với số round-trip mạng không, và có thói quen đo hay chỉ đoán.
Trả lời ngắn~30 giây
Một truy vấn lấy N hàng cha, rồi vòng lặp phát sinh thêm N truy vấn con — tổng cộng N+1 lần đi mạng. Mỗi truy vấn có thể chỉ 0,5ms nên profiler không báo gì, nhưng 200 hàng × (0,5ms + RTT) là vài trăm mili giây. Cách phát hiện đáng tin nhất là đếm số truy vấn mỗi request và đặt ngưỡng cảnh báo, chứ không nhìn thời gian từng truy vấn.
Cùng một màn hình: lấy 200 bài viết kèm tác giả. RTT tới database là 2ms.
Giải thích sâu
Điều làm N+1 khó thấy là nó không xuất hiện ở đâu như một điểm nóng. Mỗi truy vấn đều rẻ, log chậm không bắt được, và trên máy dev với database cùng máy thì RTT gần bằng không nên trang vẫn nhanh. Nó chỉ lộ ra ở production, nơi database nằm sau mạng và độ trễ mỗi lượt là 1–2ms — lúc đó 200 truy vấn thành 400ms mà chẳng truy vấn nào “chậm”.
Có ba cách sửa và chúng không tương đương. Một là JOIN: lấy tất cả trong một truy vấn, nhưng cột của cha bị lặp lại cho mỗi con, nên với quan hệ một-nhiều rộng bạn kéo về nhiều dữ liệu thừa. Hai là truy vấn gộp: một truy vấn cha, một truy vấn WHERE parent_id IN (…), rồi ghép trong bộ nhớ — đây là điều include/selectinload của các ORM làm, và thường là lựa chọn tốt nhất. Ba là bỏ hẳn dữ liệu con nếu giao diện không hiển thị nó, cách này ít được nghĩ tới nhất mà lại rẻ nhất.
Về phát hiện, cách hiệu quả nhất mình dùng là đếm truy vấn theo request rồi cho test thất bại khi vượt ngưỡng. Nó biến N+1 từ vấn đề hiệu năng mơ hồ thành lỗi build cụ thể, và quan trọng hơn, nó chặn được lần TÁI phát — vì N+1 gần như luôn quay lại sau vài lần refactor.
Cùng một màn hình, 201 truy vấn và 2 truy vấn
// N+1: 1 truy vấn cha + 200 truy vấn con
const posts = await db.post.findMany({ take: 200 });
for (const post of posts) {
post.author = await db.user.findUnique({ where: { id: post.authorId } });
}
// Gộp: 2 truy vấn, bất kể 200 hay 20.000 hàng
const posts = await db.post.findMany({ take: 200, include: { author: true } });Câu hỏi tiếp theo họ sẽ hỏi
?JOIN có phải lúc nào cũng tốt hơn truy vấn gộp?
Không. Với một cha có 50 con, JOIN lặp mọi cột của cha 50 lần; nếu cha có cột text lớn thì lượng byte truyền đi tăng vọt. Truy vấn gộp gửi mỗi hàng đúng một lần, đổi lại thêm một round-trip. Ngưỡng phụ thuộc kích thước hàng, nên đo là cách duy nhất.
?GraphQL có làm N+1 tệ hơn không?
Có, vì client quyết định hình dạng truy vấn nên resolver lồng nhau sinh ra N+1 rất tự nhiên. Đó chính là lý do DataLoader tồn tại: gom các lời gọi trong cùng một tick rồi phát một truy vấn IN (…) duy nhất.
Trả lời thế này là mất điểm
- Trả lời “thêm cache là xong”. Cache giấu N+1 khi trúng và giữ nguyên nó khi trượt — cùng lúc thêm một lớp cần vô hiệu hoá.
- Chỉ nói
include/joinmà không nhắc tới cách phát hiện. Nửa sau mới là câu hỏi thật.
Nguồn
- Postgres — `EXPLAIN (ANALYZE)` để so chi phí JOIN với truy vấn gộp
- Đếm truy vấn theo request trong test là cách chặn tái phát; hầu hết ORM đều có hook logger để làm việc này.