資料庫效能的排查工作,通常從有人提議換一台更大的執行個體開始,又通常以這樣一個發現收場:每次載入頁面時,有一條查詢都在對 400 萬列做循序掃描。瓶頸從來就不是硬體,瓶頸是執行計畫。
這個模式重複得夠穩定,值得當成預設假設寫下來。當應用很慢而資料庫又很忙時,原因幾乎都是少數幾條特定的查詢,而不是整體容量不足;把機器換大,只能把問題掩蓋到資料表再次長大的那一刻為止。
動手改之前,先量。 憑猜測去最佳化一條查詢,正是團隊花掉整整一週新增索引、結果寫入變慢而讀取一點也沒變快的原因。任何資料庫都能告訴你哪些語句消耗的總時間最多。從那裡開始,修掉排在最前面的那一條,然後再量一次。這樣反覆兩三輪,事故通常就結束了。
資料庫效能始於找出那條查詢
總時間比最壞單次更重要。一條耗時兩秒、每天只跑兩次的查詢無關緊要。一條耗時四十毫秒、每分鐘卻跑 8,000 次的查詢才是你的問題,而且它永遠不會出現在門檻值設為一秒的慢查詢紀錄裡。
在 Postgres 中,pg_stat_statements 擴充功能統計的正是這些:依正規化後的語句給出呼叫次數、總時間與平均時間。依總時間排序,罪魁禍首通常就落在前三行。MySQL 則透過 performance schema 提供類似的彙總能力。
在斷定問題出在查詢本身之前,有兩件事值得先確認。它是每次都慢,還是只在某些時段慢?後者指向資源競用,而不是執行計畫。它是單獨執行也慢,還是只有在並行量上來時才慢?後者指向鎖或連線數上限。
讀執行計畫,而不是靠猜
拿到語句之後,去問資料庫它打算怎麼執行。Postgres 透過 EXPLAIN 把這些攤開來
,其中重要的變體是 EXPLAIN ANALYZE,它會真正執行這條查詢,回報的是實測耗時而不是估算值。
在那份輸出裡,承載了大部分訊號的是三樣東西。
大資料表上的循序掃描。 資料庫正在讀取每一列。在小資料表上,這既正確又快。在大資料表上,它代表你寫的那個條件沒有可用的索引,或者規劃器認為用索引並不划算。
估算列數與實際列數之間的巨大落差。 規劃器是依據統計資訊挑選策略的,所以當它的估算偏差達到數量級時,它做出的糟糕選擇跟你的查詢本身毫無關係。統計資訊過期是常見原因,而且很容易修正。
時間集中在某一個節點上。 執行計畫是一棵樹,該修的地方就是消耗掉時間的那個節點。最佳化其他任何位置都不會帶來改變。
一看到循序掃描就想加索引,這種直覺往往是對的,但仍值得忍住三十秒,因為索引沒有被使用的原因,有時比索引不存在這件事更重要。
為什麼索引幫不上忙
存在的索引,未必是被用上的索引。
條件無法走索引。 把欄位包進函式裡,或者對欄位做算術運算,通常會讓該欄位上的索引用不起來,因為索引存的是欄位的原始值,而不是轉換之後的值。把條件改寫成讓欄位保持原樣,通常就能讓索引重新派上用場。
複合索引的欄位順序錯了。 複合索引服務的是使用其前導欄位的查詢。一個先依某欄位、再依另一欄位建立的索引,幫不了只依第二個欄位過濾的查詢,而大家在這裡反覆栽跟頭。
規劃器認為整表掃描更便宜。 如果一條查詢要回傳資料表中很大一部分資料,循序讀取確實比在索引裡跳來跳去更快。這是正確行為,該修的是讓它回傳得更少。
統計資訊過期。 在批次匯入或大量刪除之後,規劃器對資料的認知可能嚴重失真,直到統計資訊被重新收集為止。
而且每個索引都有代價。寫入必須維護它,它還占用本來可以拿去快取資料的記憶體。一張掛著十五個索引的資料表,通常有好幾個是沒人需要的,而每一個都會讓每次插入變得更慢。
N+1 問題仍然是最大的單一原因
應用變慢,來自這裡的比來自任何執行計畫問題的都多,而且它永遠不會以慢查詢的形式現身,因為單條查詢本身都很快。
它的形態很熟悉。取回一份 100 筆記錄的清單,然後逐一走訪,為每一筆再去取關聯資料。結果就是本來一兩次就夠的地方,產生了 101 次往返。每條查詢三毫秒就回來,頁面卻仍然要花半秒,因為成本落在往返次數上,而不在實際的工作量上。
物件關聯對映器讓人很容易在不知不覺中寫出這種程式,因為讀取關聯資料看起來像是存取一個屬性,而不像一次資料庫呼叫。修法是把關聯資料和父集合一起用一條查詢載入,成熟的 ORM 都支援這麼做,而大多數在預設情況下偏偏不這麼做。
偵測起來並不複雜:數一數每個請求發出多少條查詢。如果一個頁面發出的查詢數量與頁面上呈現的項目數成正比,那就找到了。當應用在邊緣節點上變慢時,這也是最值得優先做的一項檢查,正如我們的 Cloudflare Hyperdrive 指南所說的,因為距離愈遠,每一次往返的代價就愈高。
連線與競用
有兩個問題看起來像慢,其實不是。
連線耗盡。 每個資料庫對同時連線數都有上限,而每條連線都要占記憶體。當應用開啟的連線超過連線池允許的數量時,請求就會排隊等待可用的連線,於是資料庫明明閒著,應用卻顯得很慢。症狀是應用端延遲很高而資料庫 CPU 很低,修法是使用連線池,而不是換一台更大的機器。
鎖競用。 一個長交易握著鎖不放,會把排在它後面的一切都堵住。常見原因是交易跨越了根本不需要資料庫的工作而一直開著,例如中間還夾著一次對另一個服務的 HTTP 呼叫。請把交易保持得短,並且只圈住真正操作資料庫的那一段。
這兩種情況都值得儘早排除,因為兩者都很容易被誤讀成查詢問題,而且都不是加索引能解決的。
該按什麼順序動手
先找出消耗總時間最多的那些語句。對其中最糟的一條跑 EXPLAIN ANALYZE,讀清楚時間到底跑去哪裡。在最佳化任何東西之前,先看每個請求的查詢條數,把 N+1 排除掉。加索引之前先重新收集統計資訊,因為有時候這就是全部的修復動作。接著再加上能服務該條件的最窄索引,然後重新測量。
Mecanik 把這件事當成我們軟體開發 工作的一部分,而結果幾乎都一樣:責任落在兩三條查詢身上,修復很小,那台沒人買過的更大執行個體,自始至終都沒有必要。
相關文章: API 版本管理:何時該破壞相容,以及如何不破壞 、2026年如何建構Web應用:英國開發者指南 、密碼儲存:2026年該用什麼 、英國客製化軟體開發:買家完整指南 。
常見問題
我要怎麼找出是哪一條查詢拖慢了應用? 依總時間排序,而不是依最壞單次排序。一條四十毫秒、每分鐘跑 8,000 次的查詢,代價遠高於一條兩秒、每天跑兩次的查詢,而且它永遠不會出現在門檻值設為一秒的慢查詢紀錄裡。在 Postgres 中,pg_stat_statements 依語句彙總呼叫次數與總時間;MySQL 透過 performance schema 提供同樣的能力。
EXPLAIN ANALYZE 的輸出裡該看什麼? 有三樣東西承載了大部分訊號:大資料表上的循序掃描,代表沒有可用索引;估算列數與實際列數落差很大,代表規劃器正在用糟糕的統計資訊做事;以及時間集中在計畫樹的某一個節點上,那裡正是該修的地方。最佳化其他任何節點都不會帶來改變。
我的索引為什麼沒被用上? 通常是四個原因之一。條件把欄位包進了函式或算術運算裡,索引因此不再匹配。複合索引的欄位順序不支援這條查詢。查詢回傳的資料表比例夠大,以致整表掃描確實更便宜。或者在批次匯入或刪除之後,統計資訊已經過期。
什麼是 N+1 查詢問題? 先取回一份記錄清單,然後為其中每一筆單獨發一次查詢去取關聯資料,於是在本來一兩次就夠的地方產生了 101 次往返。它不會出現在慢查詢紀錄裡,因為每條查詢都很快,成本落在往返上。偵測方法是數每個請求的查詢條數,看它是否與呈現的項目數成正比。
換一台更大的資料庫伺服器能解決慢查詢嗎? 很少能,而且只是暫時的。當應用很慢而資料庫又很忙時,原因幾乎都是少數幾條特定的查詢,而不是容量不足,所以升級規格只能把問題掩蓋到資料表再次長大為止。應用端延遲高而資料庫 CPU 低,通常反而說明連線池已經耗盡,而這不是更多硬體能解決的。
評論