列表頁在測試機順到不行,一上線資料一多,ORM 的 N+1 直接把 DB 打爆
Backend Engineering·2026年7月6日·7 分鐘閱讀

列表頁在測試機順到不行,一上線資料一多,ORM 的 N+1 直接把 DB 打爆

L
Leo Wu

Leo Wu,務實派後端工程師,主力 Go,做過金流與高併發系統,信奉「看得穿工具幫你發了幾條 SQL」。

那天早上,DB 的告警比鬧鐘還早響

我做的是一個後台訂單管理系統,其中有一頁是「訂單列表」,一頁 50 筆,帶著客戶名稱、負責業務、最近一筆付款狀態這些欄位。開發時我在本機測,順到我還很得意——點下去大概 80 毫秒就回來了,我甚至覺得自己 code 寫得很乾淨。demo 給 PM 看的時候,我還特別按了幾下重新整理,說「你看,很快吧」。

上線第一週相安無事。第二週開始,客服群組陸續有人抱怨後台「卡卡的」,我一開始沒當一回事,想說可能是他們網路。直到某天早上九點多,DB 的連線數告警噴出來,max_connections 快被吃滿,那頁列表要跑到 4 秒才回得來。我打開後台自己點一下,轉圈圈轉到我開始冒汗。

我一開始想錯的方向

說來丟臉,我第一個反應是「資料變多了,那就是要加索引吧」。我跑去看那張 orders 表,該有的索引都有,created_atcustomer_id 都建好了。我又懷疑是不是分頁沒做好,結果分頁的 LIMIT 50 OFFSET ... 也很正常。

接著我懷疑是連線池設太小,把 pool size 調大——結果更慘,DB 的 CPU 直接飆起來,因為我等於是放更多的請求進去一起打它。這時候我才意識到,問題根本不是「單一查詢太慢」,而是「一個請求打了太多次查詢」。方向從一開始就錯了。

列表 API 的 P95 延遲:ORM 的 N+1 讓一次請求發出 51 條 SQL,延遲隨資料量成長飆到 4 秒,改用 preload 批次載入後回到 100ms 以內
列表 API 的 P95 延遲:ORM 的 N+1 讓一次請求發出 51 條 SQL,延遲隨資料量成長飆到 4 秒,改用 preload 批次載入後回到 100ms 以內

真正的根因:ORM 的 lazy loading 幫我偷發了 50 條 SQL

我把 ORM 的 SQL log 打開,重新整理一次那頁列表,然後我人就傻了。慢查詢日誌裡塞滿了成排、長得幾乎一模一樣、只有 WHERE id = ? 的參數不同的小查詢:

sql
SELECT * FROM customers WHERE id = 1017
SELECT * FROM customers WHERE id = 1018
SELECT * FROM customers WHERE id = 1019
... (重複 50 次)

數一數,一頁 50 筆,總共發了 51 條 SQL。這就是經典的 N+1:我先查一次列表(那個 1),拿回 50 筆訂單;然後我的樣板在迴圈裡對每一筆去讀 order.Customer.Name,而這個關聯是 lazy loading——你不碰它不查,你一碰它,ORM 就默默替你補一條 `SELECT`。50 筆就是 50 條,加上最初那一條列表查詢,剛好 51。

真正致命的是,這些查詢每一條單看都超快,索引命中、零點幾毫秒就回來。所以慢查詢的門檻抓不到它、單一 SQL 的 profiler 也看不出異常。它不是一條慢查詢,它是五十條都很快、加起來卻要命的查詢

為什麼本機和測試機完全看不出來

覆盤到這裡,我才想通為什麼 demo 時那麼順。兩個原因疊在一起把我騙了:

  • 資料量少:本機的種子資料只有二三十筆訂單,而且很多都指向同一個測試客戶。N 小的時候,N+1 跟 1 沒什麼差別,你根本感覺不到。
  • 往返延遲低:本機的 app 和 DB 都在同一台機器上,一次查詢的網路往返幾乎是零。但上線後 app 和 DB 分在不同機器、跨了網段,一次往返就算只有 1、2 毫秒,乘以 50 次、再加上連線排隊,累積起來就是好幾秒。

換句話說,N+1 這個 bug 在測試環境是「隱性」的,它需要「資料夠多」加「往返夠遠」兩個條件同時成立才會現形——而這兩個條件,只有正式環境才會同時滿足。

修法:讓 ORM 一次把關聯撈回來

方向對了,修起來其實不難。核心就是把 lazy loading 換成 eager loading,讓 ORM 在查列表的同時,用一條額外的查詢把所有關聯一次撈回來。大多數 ORM 都有 preloadwith 之類的機制:

  • preload(分兩條、用 `IN` 批次撈):先查 50 筆訂單,收集它們的 customer_id,然後發一條 SELECT * FROM customers WHERE id IN (...) 把 50 個客戶一次拿回來,在記憶體裡對起來。原本的 51 條變成 2 條
  • JOIN(一條搞定):直接用 JOIN 把 orders 和 customers 接起來,一條查詢回全部。

我這次選的是 preload 的做法,因為我要載的關聯不只一個(客戶、業務、最近付款),用 IN 分開撈每個關聯各一條,總數還是個位數,也不會有下面要講的坑。改完重新整理,SQL log 從 51 條掉到 4 條,那頁從 4 秒回到 100 毫秒以內。

別為了解決 N+1,又跳進另一個坑

這裡要特別提醒自己,因為我差點就犯了。JOIN 看起來最乾淨「一條就好」,但它有兩個陷阱:

  • 笛卡兒積:如果你一次 JOIN 多個「一對多」的關聯(例如一張訂單有多筆付款、又有多個標籤),資料庫會把它們交叉相乘,本來 50 筆會膨脹成幾百上千列,你在應用層還要去重,記憶體和頻寬反而更慘。這種情況 preload 分開撈才是對的。
  • over-fetch:我一開始偷懶用 SELECT * 把整張 customers 撈回來,但我只需要一個 name。欄位一多、資料一大,這也是浪費。後來我明確只挑要用的欄位。

所以沒有一招打天下:一對一或多對一,JOIN 很好;一對多、尤其多個一對多同時要載,preloadIN 分開撈更安全。

小結

這次事故給我最深的一句話是:ORM 很方便,但它的方便,是靠把「這一行 code 其實是一次網路往返」藏起來換來的order.Customer.Name 讀起來就像讀一個記憶體裡的欄位,你很容易忘記它背後是一條 SELECT、一次跨機器的往返。

我留給自己幾條原則:

  • 任何在迴圈裡碰到關聯的地方,先假設它有 N+1,直到我確認它是 eager 撈的。
  • 開發時就把 ORM 的 SQL log 打開,用眼睛數一個請求到底發了幾條 SQL——不要等上線讓 DB 告警替你數。
  • 別只在小資料量下測效能,種子資料要夠多、環境要盡量貼近正式N+1 這種 bug 只在資料多又跨網段時才會露臉。

ORM 沒有錯,錯的是我沒能力看穿它到底幫我發了幾條 SQL。工具可以幫你隱藏成本,但成本不會消失,它只是等著在正式環境、在最忙的那個早上,一次還給你。

#ORM#N+1#資料庫效能#事故覆盤#後端工程#Go

留言討論

有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。

相關文章