Có một lần mình nhận ticket "trang đơn hàng load chậm". Ở máy local, seed 10 đơn, API trả về trong 80ms. Lên production với 2.000 đơn của một khách quen, nó mất 12 giây. Mở log database ra thì thấy 2.001 câu query cho đúng một request.
Đó là N+1 — không phải ORM hỏng, mà là cách mình viết vòng lặp.
N+1 nhìn từ phía database
Bài toán rất đơn giản: lấy 2.000 đơn, mỗi đơn cần danh sách sản phẩm. Cách "tự nhiên" là lấy đơn trước, rồi hỏi từng đơn một:
// ❌ 1 + N query
const orders = await prisma.order.findMany({ where: { userId } })
for (const order of orders) {
order.items = await prisma.orderItem.findMany({
where: { orderId: order.id },
})
}
1 query lấy orders, rồi 2.000 query lấy items. Mỗi query riêng lẻ chỉ tốn 0,5ms, nên nhìn từng câu thì thấy ổn. Cộng lại là 1 giây chỉ riêng thời gian round-trip, chưa tính connection pool bị chiếm, chưa tính mạng.
Mặt tệ nhất của N+1 là nó scale theo dữ liệu. Local 10 đơn thì không ai thấy gì. Khách nào nhiều dữ liệu thì dính trước.
Vì sao Prisma dễ dính hơn bạn tưởng
Nhiều người nghĩ Prisma "an toàn" vì nó không lazy-load relation như Hibernate hay TypeORM — muốn lấy items thì phải nói rõ. Điều đó đúng, và nó chặn được một nửa ca N+1.
Nửa còn lại đến từ chính code của bạn: một for, một map kèm await, một hàm enrich() gọi trong vòng lặp. Prisma không thấy vòng lặp đó, nó chỉ thấy từng findMany rời rạc và chạy đúng như được yêu cầu.
Cách sửa số một: include
// ✅ 2 query, join ở tầng ứng dụng
const orders = await prisma.order.findMany({
where: { userId },
include: {
items: { select: { id: true, qty: true, price: true } },
},
})
Prisma không sinh ra một câu JOIN khổng lồ. Nó chạy một query cho order, một query cho order_item với WHERE order_id IN (...) rồi ghép trong bộ nhớ. Với quan hệ một-nhiều, đây là chiến lược hợp lý: tránh nhân dòng kiểu tích Descartes khi bạn include hai quan hệ to-many cùng lúc.
Cái giá phải trả là bộ nhớ. Bạn nạp hết items của 2.000 đơn vào RAM cùng một lúc. Nếu mỗi đơn có 50 sản phẩm, đó là 100.000 object trong heap của Node. Lúc đó vấn đề không còn là N+1 mà là GC.
Chỉ lấy cột cần thiết
include mặc định kéo hết cột. Gặp bảng có content, metadata jsonb, raw_payload thì payload phình vô ích. Dùng select ở cả hai tầng:
const orders = await prisma.order.findMany({
where: { userId },
select: {
id: true,
total: true,
createdAt: true,
items: { select: { id: true, qty: true } },
},
})
Quy tắc mình giữ: select là mặc định, include chỉ dùng khi thật sự cần gần hết cột.
Phát hiện N+1 trước khi khách phát hiện
Đừng chờ ai đó báo chậm. Bật log query và đếm:
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
})
prisma.$on('query', (e) => {
if (e.duration > 50) {
console.warn(`[slow] ${e.duration}ms ${e.query.slice(0, 120)}`)
}
})
Cách này bắt được query chậm, nhưng chưa bắt được "2.000 query nhanh". Muốn thấy N+1 thì đếm số query theo từng request. Mình dùng AsyncLocalStorage để giữ một biến đếm xuyên suốt request, rồi log tổng số query khi request kết thúc:
import { AsyncLocalStorage } from 'node:async_hooks'
const counter = new AsyncLocalStorage<{ n: number }>()
prisma.$on('query', () => {
const store = counter.getStore()
if (store) store.n++
})
// cuối mỗi request
const ctx = counter.getStore()
if (ctx && ctx.n > 20) {
console.warn(`[n+1?] ${ctx.n} query cho ${req.method} ${req.url}`)
}
Ngưỡng 20 là tuỳ hệ thống. Cái quan trọng là có con số, và con số đó đi kèm log của request để bạn biết endpoint nào đang bắn query.
Khi include không đủ: gom id rồi query một lần
Có trường hợp dữ liệu cần không phải quan hệ khai báo trong schema — ví dụ đếm số lần đổi trạng thái, hoặc lấy bản ghi từ bảng ngoài. Lúc đó gom id:
const orders = await prisma.order.findMany({
where: { userId },
select: { id: true },
})
const ids = orders.map((o) => o.id)
const items = await prisma.orderItem.findMany({
where: { orderId: { in: ids } },
})
// Node 22+: Map.groupBy
const itemsByOrder = Map.groupBy(items, (i) => i.orderId)
Hai query, và bạn toàn quyền kiểm soát hình dạng dữ liệu trả về. Nhớ mệnh đề in có giới hạn: gửi 50.000 id trong một câu IN là cách nhanh nhất để Postgres chọn sai plan. Cần số lượng lớn thì chia lô 500–1.000 id một lần.
Vài cái bẫy mình từng sập
findUniquetrong vòng lặp vẫn là N+1. Cái tên "unique" không cứu được gì.- Bọc cả vòng lặp trong interactive transaction (
$transaction(async (tx) => ...)) làm N+1 tệ hơn: transaction giữ lock lâu, và càng nhiều query thì càng dễ deadlock. - Đừng "tối ưu" bằng một câu join 6 bảng. Sáu query đánh index tốt thường nhanh và dễ chẩn đoán hơn một câu join khổng lồ mà planner chọn sai plan. Prisma không thoát khỏi quy luật này, Drizzle hay raw SQL cũng vậy — chỉ có điều viết raw thì bạn thấy N+1 ngay từ lúc gõ, còn ORM thì che đi.
Từ sau ca 12 giây đó, mình thêm một bước vào checklist review: mọi endpoint trả về danh sách đều phải có số query ổn định, không phụ thuộc số bản ghi. Cách kiểm tra nhanh là seed 1 bản ghi và seed 1.000 bản ghi, xem số query có bằng nhau không.
Bạn đang đếm query theo request bằng cách nào — bật log thủ công, hay đã có metric đẩy vào dashboard?
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| N+1 query | Anti-pattern: 1 query lấy danh sách rồi N query lấy dữ liệu liên quan cho từng phần tử |
include |
Cách Prisma nạp kèm relation; chạy query phụ với IN (...) rồi ghép trong bộ nhớ |
select |
Chỉ định cột cần lấy, giảm payload và bộ nhớ so với include |
| Lazy loading | Cơ chế tự động nạp relation khi truy cập; Prisma không có, TypeORM/Hibernate có |
AsyncLocalStorage |
API của Node giữ context xuyên suốt chuỗi async, dùng để đếm query theo request |
Map.groupBy |
Gom phần tử theo khoá trả về Map, có từ Node 22 |
$on('query') |
Sự kiện Prisma phát ra mỗi câu query kèm thời gian chạy, dùng để log và đo |