前端進階
-N + 1 問題是什麼?後端查詢的必要知識
this.web

什麼是 N+1 問題?
N+1 問題是後端領域中非常常見、也非常重要的效能議題。
假設我們現在要查 10 篇貼文的內容以及前 5 則留言。
你會怎麼做呢?
如果我們先查一次主資料
SELECT * FROM posts LIMIT 10;然後再用迴圈跑 10 次,每次再查留言:
SELECT * FROM comments WHERE post_id = ? LIMIT 5;這樣總共會執行 11 次查詢(1 次查主資料,外加 10 次查留言)。
當資料量很小時,看起來好像還可以接受,但如果查詢量變大,例如需要查詢 10,000 篇貼文,那就會造成 10,001 次查詢,效能會呈線性成長,甚至造成資料庫負載暴增。
而且現實場景通常更複雜,例如我們還要查每個留言的作者,又需要再跑一次迴圈:
SELECT * FROM users WHERE id = ?;總共會經歷:
- 1 次查貼文
- 10 次查留言
- 50 次查留言的作者
一不小心就會變成 N x M 問題了。
而且這很容易不小心出現在有使用 ORM 的情況,因爲 ORM 的設計理念就是讓我們像操作物件一樣的去操作資料,所以會把查詢包裝得像普通函式呼叫,大家就會比較容易忽略查詢帶來的花費。
比如上面的例子,如果用 Prisma ORM 來寫就會類似這樣:
const posts = await prisma.post.findMany();
for (const post of posts) {
const comments = await prisma.comment.findMany({
where: { postId: post.id }
});
for (const comment of comments) {
const user = await prisma.user.findUnique({
where: { id: comment.userId }
});
}
}
如何解決 N + 1 問題?
核心方法其實非常單純,就是將分散的查詢合併為少數幾次查詢。
我們可以藉由以下方式做到:
- 透過資料庫的 JOIN
- 使用 IN 查詢一次帶回所有需要的資料
- 在 ORM 中使用 eager loading(例如 Prisma 的 include 或 select)
透過資料庫的 JOIN
當需要同時查詢多張資料表時,可以用 JOIN 一次把所有資料組合起來,避免一筆筆查詢關聯資料。
SELECT
p.*,
c.*
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.id IN (SELECT id FROM posts LIMIT 10);這樣只需要一次查詢,就能取得貼文與留言的完整資料。
使用 IN 查詢的最佳化
如果只需要查某一張表(例如留言),可以利用 IN 一次查回所有相關資料,而不是為每個 post 個別執行查詢。
SELECT * FROM comments WHERE post_id IN (SELECT id FROM posts LIMIT 10);簡單來說:
- JOIN:需要同時查詢多張資料表的內容
- WHERE IN:只需要查單一資料表,但需要以一組條件批次查詢
在 ORM 中使用 eager loading
eager loading 指的是在查詢主資料時,同時主動載入關聯資料,避免在迴圈中對 ORM 呼叫關聯屬性時才額外觸發查詢。
許多 ORM 預設採用 Lazy Loading,也就是遇到關聯資料時才會在背後發出查詢,這往往是造成 N+1 問題的主因。
以 Prisma 為例,可以利用 include 同步取回所有需要的資料:
const posts = await prisma.post.findMany({
take: 10,
include: {
comments: true
}
});這樣 ORM 會自動批次查詢,避免每筆資料都產生額外的查詢。
總結
最後總結一下,如果我們在關聯資料查詢中,採用以下模式:
- 先查一次主資料
- 再針對主資料的每一筆記錄,逐筆查詢其關聯資料
- 最終形成 N+1 次查詢,而這些查詢其實可以整併為少數幾次
就會產生典型的 N+1 問題。
這類問題在資料量變大時,就會照成效能上的問題。
解法的核心,是避免在迴圈中查資料,改用 JOIN、IN 查詢或 ORM 的 eager loading,做查詢批次化,直接把資料一次帶回。