什麼是資料庫查詢 N + 1 問題? 如何有效避免?

2026年1月25日

💎 加入 E+ 成長計畫 與超過 900+ 位工程師一同在社群成長,並獲得更多深度的軟體前後端學習資源

在做後端的資料庫查詢時 (特別是使用 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_id5 的發文者發的,所以資料庫在回傳資料時,這部分的資料就回傳了兩次。假如今天的貼文都是少數幾位作者發的,這等於資料庫會回傳大量重複的資料,佔用頻寬也讓傳輸時間多了額外成本 (備註:從 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 問題。透過簡單的方式重構,查詢效能會有很顯著的改善。

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們