先講一個你八成親身經歷過的劇情。
文章列表頁,剛上線的時候快得跟飛一樣。三個月後資料一多,它開始轉圈圈,越來越慢。你打開那段 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 |
|---|---|
| Django | django-debug-toolbar(直接顯示這個 request 幾次 query、哪些重複);或設 LOGGING 把 SQL 印出來 |
| Laravel | Telescope / Laravel Debugbar;或 DB::listen() 自己攔每條 SQL |
| SQLAlchemy | 建 engine 時 echo=True,每條 SQL 直接吐到 console |
| Rails | 看 development.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 寫法 |
|---|---|
| Django | select_related(一對一 / 外鍵,走 JOIN)、prefetch_related(多對多 / 反向,走第二次批次查) |
| Laravel | Model::with('author') |
| SQLAlchemy | joinedload() / selectinload() |
但別矯枉過正。看到 N+1 就把所有關聯全部 eager load,下場是你撈一堆根本用不到的資料回來,記憶體跟頻寬又是另一種浪費。原則是:這個畫面實際會用到的關聯,才一起撈;用不到的就讓它躺著。
個人經驗:我自己最有感的就是後台——常常撈個資料慢得莫名其妙,你還查不出為什麼,因為 code 看起來都正常。真正抓到「喔原來是 N+1」,往往是壓測的時候盯著 log,才反過來發現這東西其實超常發生;不然就是某天上了個新功能,被人反映「這頁怎麼這麼慢」,去翻才發現又是它。
我們那時候是走全 ORM 的,理由很實際——review 快,大家寫法一致、看 code 省事。但這個選擇的代價,就是 N+1 這種問題「發生的當下你看不出來」,得等它真的慢給你看、再回頭挖 log 才現形。全 ORM 還是全手寫 SQL 本來就各有取捨,但如果你跟我們一樣選了 ORM,那「定期掀 log 看一眼」這個習慣就不是 optional,是你欠自己的保險。
接下來往哪走
這篇只想讓你對 N+1 有「警覺心」+ 會「自己抓」。再深的:
- N+1 的更多型態與對策(DataLoader / batching) → 本章
33-n-plus-1(規劃中) - 為什麼有時候撈得慢不是 N+1,是少了 index → 沒加 index 到底差多少
- 既有可以先嗑的實戰:
反思
ORM 不是壞東西,它幫你省掉一堆手刻 SQL 的時間。但它省下來的東西裡,藏了一個你遲早要還的:你對「自己到底打了幾次資料庫」失去了感覺。
所以用 ORM 的人,要養成一個習慣——偶爾掀開蓋子看一眼 SQL log。尤其是任何「列表」「一對多」的畫面,上線前順手看一下它打了幾次 query。
問自己兩題就好:
- 這個畫面,我看過它的 SQL log 嗎?還是只讀過 code 覺得「應該還好」?
- 它在 production 的資料量下會打幾次?dev 只有 5 筆看不出來的東西,上線後是 5000 筆。
第一題答「沒看過」的頁面,就是你下一個 N+1 的藏身處。