先講一個你八成親身經歷過的劇情。

文章列表頁,剛上線的時候快得跟飛一樣。三個月後資料一多,它開始轉圈圈,越來越慢。你打開那段 code,怎麼看都沒問題——就一個乾淨到不行的迴圈:撈出文章、跑一圈、把每篇的作者名字印出來。能有什麼問題?

直到你某天忍不住去翻 SQL log,才發現這個「人畜無害」的頁面,一次打開打了 101 次 query

這就是 N+1。而它之所以陰險,是因為它從你的 code 表面完全看不出來


為什麼 ORM 會害你

先講清楚 ORM 在幹嘛:它把資料庫的 row 包成你熟悉的物件,讓你用 article.author.name 這種寫法,而不用自己手刻 SQL。這是它的賣點,也是它的陷阱——這兩件事是同一件事。

因為當你寫下 article.author.name 的那一刻,你以為只是在讀一個物件的屬性,但 ORM 可能偷偷幫你飛了一趟資料庫去把那個 author 撈回來。你看到的是「讀一個欄位」,實際發生的是「一次 DB 往返」。SQL 被藏起來了,往返也被藏起來了——你舒服,但你也瞎了。


N+1 長什麼樣子

直接看 code(以 Django 為例,但每個 ORM 都一樣會中):

articles = Article.objects.all()      # ① 1 次 query:撈出 100 篇文章
for a in articles:
    print(a.author.name)              # ② 每一圈各打 1 次 query 去撈 author

數一下:①撈文章本體是 1 次,②迴圈裡每碰一次 a.author 就補 1 次,100 篇就是 100 次。加起來 1 + N = 101 次

這就是名字的由來——1 次撈主體、N 次補關聯。本來一個 JOIN 一次就能拿完的東西,被拆成了 101 趟。資料少的時候你完全沒感覺(dev 環境就 5 筆假資料嘛),等 production 資料漲到幾千筆、頁面又被狂刷,DB 就開始喘了。N+1 幾乎都是「到 scale 才爆」,這也是它最難在開發階段被抓到的原因。


重點來了:你要怎麼自己揪出它

N+1 不會跳出來跟你說「嗨我在這」,你得主動去看。核心就一句話——去看你的 ORM 到底打了幾次 query、有沒有同一條 query 重複跑 N 次

工具上每個生態都有現成的:

框架怎麼看它打了幾次 query
Djangodjango-debug-toolbar(直接顯示這個 request 幾次 query、哪些重複);或設 LOGGING 把 SQL 印出來
LaravelTelescope / Laravel Debugbar;或 DB::listen() 自己攔每條 SQL
SQLAlchemy建 engine 時 echo=True,每條 SQL 直接吐到 console
Railsdevelopment.log,N+1 會排成一整排一模一樣的 SELECT;或上 bullet gem

如果你懶得裝東西,還有一招土法但超有效:自己壓一個「query 計數器」中介層,每個 request 結束就印出「這次總共打了幾次 DB」。平常一個頁面合理就是個位數,哪天看到某個頁面跳到 100 多次——抓到了,就是它。

心法:N+1 是「看 log」看出來的,不是「讀 code」讀出來的。 因為它在 code 表面長得跟正常迴圈一模一樣,你唯一的破綻是那串重複的 SQL。


怎麼解:先把關聯一起撈進來

解法的本質也一句話——提早告訴 ORM「我等下會用到關聯,拜託一起撈」,讓它用一個 JOIN(或一次批次查詢)搞定,而不是邊跑迴圈邊零零落落地補:

# 改成這樣,101 次 → 變 1~2 次
articles = Article.objects.select_related('author').all()
for a in articles:
    print(a.author.name)              # author 早就一起撈回來了,不再打 DB

各家的咒語不一樣,但做的是同一件事:

框架eager loading 寫法
Djangoselect_related(一對一 / 外鍵,走 JOIN)、prefetch_related(多對多 / 反向,走第二次批次查)
LaravelModel::with('author')
SQLAlchemyjoinedload() / selectinload()

別矯枉過正。看到 N+1 就把所有關聯全部 eager load,下場是你撈一堆根本用不到的資料回來,記憶體跟頻寬又是另一種浪費。原則是:這個畫面實際會用到的關聯,才一起撈;用不到的就讓它躺著。

個人經驗:我自己最有感的就是後台——常常撈個資料慢得莫名其妙,你還查不出為什麼,因為 code 看起來都正常。真正抓到「喔原來是 N+1」,往往是壓測的時候盯著 log,才反過來發現這東西其實超常發生;不然就是某天上了個新功能,被人反映「這頁怎麼這麼慢」,去翻才發現又是它。

我們那時候是走全 ORM 的,理由很實際——review 快,大家寫法一致、看 code 省事。但這個選擇的代價,就是 N+1 這種問題「發生的當下你看不出來」,得等它真的慢給你看、再回頭挖 log 才現形。全 ORM 還是全手寫 SQL 本來就各有取捨,但如果你跟我們一樣選了 ORM,那「定期掀 log 看一眼」這個習慣就不是 optional,是你欠自己的保險。


接下來往哪走

這篇只想讓你對 N+1 有「警覺心」+ 會「自己抓」。再深的:


反思

ORM 不是壞東西,它幫你省掉一堆手刻 SQL 的時間。但它省下來的東西裡,藏了一個你遲早要還的:你對「自己到底打了幾次資料庫」失去了感覺。

所以用 ORM 的人,要養成一個習慣——偶爾掀開蓋子看一眼 SQL log。尤其是任何「列表」「一對多」的畫面,上線前順手看一下它打了幾次 query。

問自己兩題就好:

  1. 這個畫面,我看過它的 SQL log 嗎?還是只讀過 code 覺得「應該還好」?
  2. 它在 production 的資料量下會打幾次?dev 只有 5 筆看不出來的東西,上線後是 5000 筆。

第一題答「沒看過」的頁面,就是你下一個 N+1 的藏身處。