什麼是資料庫查詢 N + 1 問題? 如何有效避免?
2026年1月25日
在做後端的資料庫查詢時 (特別是使用 ORM 時),許多人可能會不小心犯了 N + 1 問題。在這篇文章我們會討論什麼是 N + 1 問題、N + 1 問題會造成什麼影響,以及可以如何重構來避免這種問題。
什麼是 N + 1 問題?
N + 1 問題是常見的查詢效能問題,假如在寫資料庫查詢時,發現在資料量大時,查詢花特別多時間、特別慢,N + 1 就會是一個值得優先檢查的方向 (備註:資料庫查詢慢可能有其他原因,N + 1 問題只是其中一個可以檢視的方向,推薦可以多方檢查來排除問題)。
讓我們透過一個具體案例來理解什麼是 N + 1 問題。底下是一個使用 Drizzle ORM 的例子,在這個例子中,我們要跟資料庫拿社群貼文,以及要拿每個貼文對應的發文者,這樣才能同時顯示貼文與發文者。
要同時拿到所有貼文與對應的發文者,一個直觀的做法,是先有一個查詢拿到所有的貼文 allPosts,然後接著迭代過每一則貼文 for (const post of allPosts) ,在迭代時根據貼文的發文者 id,去拿到完整的發文者資訊。
const allPosts = await db.select().from(posts);
for (const post of allPosts) {
const author = await db
.select()
.from(authors)
.where(eq(authors.id, post.authorId))
.limit(1);
}
在上面這段查詢中,1 是第一次拿所有貼文的查詢,N 則是後面針對每則貼文,再去拿作者詳細資訊的查詢。這種典型的 N + 1 問題,會有一個顯而易見的問題,如果今天貼文數量增加,針對發文者的查詢就會線性增加。
在 ORM 背後具體會跑的 SQL 查詢大致如下 (以 5 篇貼文為例),在貼文數少的狀況,這可能不會帶來太多效能成本。不過,如果今天有 10 篇貼文,那麼需要額外再發 10 個查詢去拿這 10 篇貼文的發文者資訊 (n = 10);假如有 1000 篇貼文,就等於要額外發 1000 次查詢去拿貼文發文者的資訊 (n = 1000)。這種狀況下,貼文一多,查詢自然會變慢很多。
Post Load (0.5ms) SELECT "posts".* FROM "posts"
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 5
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 5
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 3
如何重構來避免 N + 1 問題?
之所以會有這種 N + 1 問題,是因為 N + 1 的查詢是很直觀可以想到,且查詢結果本身無誤的方法。因為「簡單又正確」,會選這種方式並不會讓人感到意外。不過身為一名後端工程師,只是寫出正確的查詢還不夠,在正確之餘還要確保查詢的效率高、拿資料的時間短,才不會讓查詢變成效能的瓶頸。
如何辨識 N + 1 問題?
要有效避免 N + 1 問題,首先需要先能辨識出 N + 1 問題。如同上面看到的,通常 N + 1 問題出現在某段迴圈的程式碼中,因此假如你發現程式碼中有某一段迴圈,且該迴圈當中每次迭代都有發送查詢,這種狀況就值得多加留意。
另一個可以看的,是實際發出的 SQL 查詢。如上面有展示的,N + 1 查詢的一個特點是有很多 SELECT 語句搭配 WHERE ,但是 WHERE 大量重複,只有 ID 不同而已,這種狀況也極可能可以透過重構改善。
除了看程式碼本身,從請求的效能監控來看,假如在少量資料時效能沒問題,但當資料量變大時,效能是線性方式變差,這很可能也意味著有 N + 1 問題。
雖然上面這些表徵,不必然代表有 N + 1 問題,但是如果有發現類似問題,還是推薦可以花點時間查看,如果確認是 N + 1 問題,就推薦重構,以下讓我們談幾個可以重構的方式。
透過 JOIN 重構
單看 SQL,假如想要拿貼文以及發文者的資訊,只要透過 JOIN 就能夠用一次查詢做到,例如下面這樣。
SELECT
posts.id,
posts.title,
posts.content,
authors.id as author_id,
authors.name as author_name,
authors.email as author_email
FROM posts
INNER JOIN authors ON posts.author_id = authors.id;
透過 JOIN,我們能夠把查詢次數從 N + 1 降低至 1。不過這個做法可能會有個問題,大家可以觀察下方這個範例結果,覺得假如透過 JOIN 拿到下面這樣的資料,有哪邊可以改進的?
post_id | title | content | author_id | author_name | author_email
--------|-----------------|--------------|-----------|-------------|------------------
1 | "First Post" | "..." | 5 | "Alice" | "alice@..."
2 | "Second Post" | "..." | 5 | "Alice" | "alice@..."
3 | "Third Post" | "..." | 12 | "Bob" | "bob@..."
4 | "Fourth Post" | "..." | 7 | "Charlie" | "charlie@..."
眼尖的讀者可能會發現,由於第一個貼文跟第二個貼文,都是 author_id 是 5 的發文者發的,所以資料庫在回傳資料時,這部分的資料就回傳了兩次。假如今天的貼文都是少數幾位作者發的,這等於資料庫會回傳大量重複的資料,佔用頻寬也讓傳輸時間多了額外成本 (備註:從 SQL 與資料庫的角度來看,JOIN 是合理且常見的做法;這裡討論的問題,主要是站在資料傳輸量與應用層資料處理成本的角度)。
透過 IN 子句重構
假如要避免使用 JOIN 會拿到重複的資料,我們可以改成用 IN 的子句來查詢,在避免 N + 1 問題,拿到獨特不重複的資料。
具體來說可以這麼做:
-- 第一次查詢先拿貼文
SELECT id, title, content, author_id
FROM posts;
-- 根據貼文的發文者 id 來拿發文者資訊
SELECT id, name, email
FROM authors
WHERE id IN (5, 12, 7, 23, 34);
-- 備注:在拿完資料後,應用層需要在程式碼中寫額外的邏輯,把兩次查詢的資料拼裝一起
雖然比起用 JOIN,用 IN 需要兩次查詢,但本質上仍能避免 N + 1 問題,因為不管貼文或發文者數量增加多少,這個做法也都只需要兩次查詢。
雖然上面這兩種做法,都能解決 N + 1 問題,但要選哪一種,需要依照所在情境考量。舉例來說,假如資料量小,用 JOIN 的方法多拿到重複資料,這個成本通常是可以接受的,與此同時 JOIN 的查詢效能往往分兩次查詢更好。
總結
以上,希望透過這篇文章,讀者們對於 N + 1 問題有更具體的認識。如文中提到的,在開發時如果發現查詢異常的慢,可以試著檢查是否有 N + 1 問題。透過簡單的方式重構,查詢效能會有很顯著的改善。