上一章你已經會讀計畫了。這一章要處理的是讀完之後最常出現的那個問題:
索引明明就在那裡,
EXPLAIN為什麼不用它?
而更讓人不安的版本是:它昨天還在用,今天就不用了。程式沒改、索引沒動、資料只是多了一些。
答案是:優化器從來就不是在找「最快的計畫」,它是在找「估算成本最低的計畫」。這兩件事在資料分佈平均、統計資訊新鮮的時候幾乎等價;一旦統計資訊失準,它就會用非常合理的邏輯,推導出一個非常糟糕的結論——而且完全不會告訴你。
這一章要拆開的是:
cardinality 從哪來、什麼時候自動更新、為什麼 20 頁的採樣會騙人。status 這種只有五個值的欄位,加了索引也常常用不到——而這是對的。ORDER BY ... LIMIT 換索引:最經典的一個陷阱——加了 LIMIT 20 反而慢一百倍。FORCE INDEX 為什麼是最後手段,以及 8.0 的 hint 好在哪裡。這一章的主線是這一軌的第一條:優化器看的是估算,你該看的是實測。上一章教你怎麼看到估算;這一章要教你怎麼判斷那個估算是不是在騙你,以及騙你的時候該怎麼辦。
「優化器選錯索引」這句話其實不太準確。優化器沒有選錯——它非常忠實地執行了一個成本計算,只是那個計算的輸入是錯的。把這件事想清楚,後面所有的處理方式才有邏輯。
因為那就跟真的執行一樣貴了。優化本身必須遠比執行便宜,否則優化就沒有意義——一句 SQL 花 3 毫秒選計畫、5 毫秒執行是划算的;花 200 毫秒選計畫就荒謬了。
估算的不精確不是實作缺陷,是這個設計的必然代價。你要做的不是期待它變準,而是知道它什麼時候會不準、以及不準的時候怎麼辦。
可行計畫的數量會隨著 JOIN 的表數量爆炸性成長(n 張表的排列有 n! 種)。所以優化器有搜尋深度上限(optimizer_search_depth),表一多就改用貪婪搜尋,只找「夠好」的計畫。
-- 這兩個旋鈕平常不要動,但知道它們存在,才知道「表很多的 JOIN 為什麼計畫怪怪的」
SELECT @@optimizer_search_depth; -- 預設 62(超過就用啟發式)
SELECT @@optimizer_prune_level; -- 預設 1(提早剪枝)所以在多表 JOIN 的場景,「優化器沒選到那個明顯更好的順序」是有可能的,而且它連考慮都沒考慮過。
優化器算出來的成本是一個沒有單位的相對分數,不是毫秒。它只用來比大小。而它的組成很單純,就兩類:
MySQL 8.0 把成本常數放在兩張系統表裡,不是寫死在程式碼裡:
SELECT cost_name, default_value FROM mysql.server_cost;
-- row_evaluate_cost 0.1 每比對一列的 CPU 成本
-- memory_temptable_create_cost 1.0
-- disk_temptable_create_cost 20.0 磁碟暫存表貴 20 倍(ch04 會用到)
SELECT cost_name, default_value FROM mysql.engine_cost;
-- memory_block_read_cost 0.25 從 Buffer Pool 讀一頁
-- io_block_read_cost 1.0 從磁碟讀一頁EXPLAIN FORMAT=JSON 會把成本攤開來:
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE status = 1\G
"cost_info": {
"query_cost": "203847.25"
},
"table": {
"access_type": "ALL",
"rows_examined_per_scan": 1998432,
"filtered": "10.00",
"cost_info": {
"read_cost": "3987.25",
"eval_cost": "199860.00", ← 1998432 × 0.1
"prefix_cost": "203847.25"
}
}這份輸出的用法不是「看它幾分」,而是拿兩個計畫的分數比大小:想知道優化器為什麼不選你以為的那個索引,就用 FORCE INDEX 逼它選一次,再比較兩邊的 query_cost。差距在哪一項(read_cost 還是 eval_cost)通常一眼就看得出來。
它算得再仔細,前提也只有這幾條,而每一條都可能與現實不符:
io_block_read_cost,但那是全域影響,動之前要有量測。)WHERE city = '台北' AND zipcode = '110',優化器會把兩個條件的選擇度相乘,但這兩個欄位其實高度相關,實際命中列數會遠大於估算。成本估算的輸入是統計資訊,而統計資訊的核心是每個索引的 cardinality:這個索引裡有幾個不重複的值。
InnoDB 不會為了統計去掃全表,它隨機抓幾頁索引頁,數一數不重複值,再依比例放大到整張表:
SELECT @@innodb_stats_persistent_sample_pages; -- 預設 208.0 預設是持久化統計(innodb_stats_persistent=ON),存在系統表裡,重啟不會消失:
SELECT index_name, stat_name, stat_value
FROM mysql.innodb_index_stats
WHERE table_name = 'orders';
-- idx_merchant_status n_diff_pfx01 8213 ← 第 1 個欄位有幾個不重複值
-- idx_merchant_status n_diff_pfx02 16401 ← 前 2 個欄位的組合有幾個
-- idx_merchant_status n_leaf_pages 40125
SHOW INDEX FROM orders; -- Cardinality 欄位就是同一份資料n_diff_pfx01、pfx02 這種前綴統計,正是優化器判斷「複合索引用到第幾個欄位還有多少過濾力」的依據。
innodb_stats_auto_recalc=ON(預設),當表變動的列數超過總列數的 10% 時,背景執行緒會重算。ANALYZE TABLE orders;ALTER TABLE、加索引、SHOW TABLE STATUS 之類的操作也可能帶動更新。-- 針對特定大表提高採樣頁數(只影響這張表)
ALTER TABLE orders STATS_SAMPLE_PAGES = 200;
ANALYZE TABLE orders;代價是每次重算要讀更多頁,時間變長。對「資料分佈極不平均、又常出問題」的大表值得調高;不要全庫一起調——這是第二條主線「代價守恆」的第一次現身:你買到的每一分準確度,都在別的地方付了錢。
統計資訊最後會被換算成一個很簡單的比值——選擇度(selectivity):
選擇度 = 不重複值的數量 / 總列數
user_id(200 萬個不同值 / 2000 萬列) = 0.1 高
created_at(幾乎每列不同) ≈ 1.0 最高
status(5 個值 / 2000 萬列) = 0.00000025 極低
is_deleted(2 個值) ≈ 0 最低假設 status=1 佔了全表的 40%,也就是 800 萬列。走 idx_status 的話:
掃索引葉節點找出 800 萬個主鍵 → 讀約 12,000 頁
拿每個主鍵回表 → 800 萬次隨機 I/O
合計 ≈ 8,012,000 次 I/O
全表順序掃描 → 65,000 頁順序讀(還有預讀加持)差了兩個數量級,而且是索引比較慢。優化器選全表掃描是完全正確的判斷。
type=ALL 就急著加索引」是錯的直覺——要先問「這個條件過濾掉多少資料」。可以,但要建在對的地方——放進複合索引,而且不是放第一個:
-- ❌ 幾乎沒用
KEY idx_status (status)
-- ✅ 有用:高選擇度的欄位在前,先把範圍縮小到幾十列,
-- status 只是在這幾十列裡再篩一次
KEY idx_m_s_c (merchant_id, status, created_at)還有一種例外值得記住:資料極度傾斜的欄位。例如 status=9(處理失敗)只佔全部的 0.01%,那查 status=9 走索引是非常划算的——問題是同一個索引,查 status=1 該用全表掃描,查 status=9 該走索引。這正是「同一句 SQL 換個參數值就該換計畫」的來源,也是下一節直方圖要解決的事。
cardinality 只回答了「有幾個不重複值」,它假設每個值出現的次數一樣多。現實中幾乎從來不是這樣:訂單狀態裡「已完成」可能佔 95%,「待退款」佔 0.01%。
MySQL 8.0 引進直方圖(histogram)來補這一塊:它記錄的是值的實際分佈。
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, order_type WITH 32 BUCKETS;
-- 看內容
SELECT histogram FROM information_schema.column_statistics
WHERE table_name = 'orders' AND column_name = 'status'\G
-- 不要了
ANALYZE TABLE orders DROP HISTOGRAM ON status;桶數(buckets)預設 100、最大 1024。不重複值少於桶數時會用 singleton(每個值一個桶,最精確);否則用 equi-height(每桶列數相近)。
這是最需要弄清楚的一點。回到 ch01 的欄位定義:rows 是這一步要掃幾列,filtered 是掃完之後估計剩幾成。直方圖影響的是 filtered。
-- 沒有直方圖:優化器假設均勻分佈,5 個狀態 → filtered ≈ 20.00
EXPLAIN SELECT * FROM orders WHERE status = 9;
-- rows=1998432 filtered=20.00 → 估計 40 萬列
-- 有直方圖:它知道 status=9 只佔 0.01%
-- rows=1998432 filtered=0.01 → 估計 200 列估算從 40 萬變成 200,優化器對後續的每一個決策都會跟著改——要不要走索引、JOIN 的驅動表選誰、要不要建暫存表。這在 JOIN 場景的影響尤其大(ch03)。
innodb_stats_auto_recalc 也管不到它。一份三個月前建的直方圖,會用三個月前的分佈去指導今天的查詢——它從一個幫手變成一個穩定的錯誤來源。所以建直方圖的同時就要決定「誰負責重建」:跟著批次流程走、或排一個週期性維護。不維護的直方圖比沒有直方圖更危險,因為沒有直方圖時優化器至少知道自己在猜。
這是實務上最常見、也最反直覺的一個計畫翻車場景。先看現象:
-- 這句:8 秒
SELECT * FROM orders WHERE merchant_id = 8821 AND status = 1
ORDER BY created_at DESC LIMIT 20;
-- 把 LIMIT 拿掉或改大:0.03 秒
SELECT * FROM orders WHERE merchant_id = 8821 AND status = 1
ORDER BY created_at DESC;它不是隨機犯錯的,它的推理其實很有道理:
ORDER BY created_at DESC,而 idx_created_at 本身就是有序的。idx_merchant_status 再排序更低。第 3 步的「應該很快湊滿」建立在一個假設上:符合條件的資料在時間軸上是均勻分佈的。
merchant_id = 8821,掃過 1,400 萬列才湊到 20 筆。LIMIT 20 不但沒有省事,還正是它掉進坑裡的原因——因為 LIMIT 讓「走排序索引」的估算成本變得極低。EXPLAIN 顯示 key 是排序欄位的索引而不是過濾欄位的索引,Extra 沒有 Using filesort(它「成功」避開了排序)。LIMIT 拿掉,計畫換回過濾索引,而且變快——這個對照實驗基本上就能定案。EXPLAIN ANALYZE 看那個節點的 actual rows:估算幾百,實際上千萬。-- ① 治本:一個索引同時滿足過濾與排序(WHERE 等值在前,ORDER BY 欄位在後)
ALTER TABLE orders ADD KEY idx_m_s_c (merchant_id, status, created_at);
-- 代價:多一個索引 → 寫入變慢、空間變大(代價守恆)
-- ② 明確告訴優化器不要為了排序換索引(8.0.21+)
SET SESSION optimizer_switch = 'prefer_ordering_index=off';
-- ③ 針對單句下 hint(8.0)
SELECT /*+ NO_INDEX(orders idx_created_at) */ ...優先做 ①。②③ 是還沒辦法改結構時的止血手段——它們讓這一句好了,但同一張表其他查詢的同類問題還在。
商家後台的訂單列表 API,平常穩定在 30 毫秒。某天早上九點開始,監控顯示 p99 衝到 8 秒,客服電話進來了。
照 ch01 的順序走。先拿線上真正慢的那組參數去看計畫(這點很關鍵,不同參數值可以走出不同計畫):
-- 快的商家
EXPLAIN SELECT * FROM orders WHERE merchant_id = 101 AND status = 1
ORDER BY created_at DESC LIMIT 20;
-- key=idx_merchant_status type=ref rows=180 Extra=Using where; Using filesort
-- 慢的商家(同一句 SQL,只換參數)
EXPLAIN SELECT * FROM orders WHERE merchant_id = 8821 AND status = 1
ORDER BY created_at DESC LIMIT 20;
-- key=idx_created_at type=index rows=214 Extra=Using where兩件事同時發生了:換了索引,而且估算只有 214 列。用 EXPLAIN ANALYZE 對照實測:
-> Limit: 20 row(s) (cost=1250 rows=20) (actual time=8402..8402 rows=20 loops=1)
-> Filter: (merchant_id = 8821 and status = 1)
(cost=1250 rows=214) (actual time=8402..8402 rows=20 loops=1)
-> Index scan on orders using idx_created_at (reverse)
(rows=214) (actual rows=14203871 loops=1) ← 估 214,實掃 1,420 萬innodb_stats_auto_recalc 背景重算統計。這就是「什麼都沒動卻自己變了」的那個時刻。created_at 的舊資料變得非常密集,而且集中在少數幾個商家。20 頁的採樣正好採到那批新資料,merchant_id 的 cardinality 被嚴重高估——優化器因此以為「每個商家的資料都很少」。ORDER BY created_at DESC LIMIT 20(上一節那個陷阱),走排序索引的估算成本被壓到極低。於是它換了索引,而那個商家最近沒訂單,反向掃了 1,420 萬列。ANALYZE TABLE orders; 重算統計。這在很多案例裡當場就恢復了——但它不保證,因為採樣仍然可能不具代表性。idx_m_s_c (merchant_id, status, created_at),讓過濾與排序由同一個索引完成。這樣就沒有「兩個索引可以選」的問題了——最好的介入是讓優化器沒得選錯。ANALYZE TABLE 排進批次流程的最後一步;對這張大表調高 STATS_SAMPLE_PAGES;並對關鍵查詢加上計畫監控(見下面)。-- 這句 SQL 最近用過哪些索引、各自跑了多久
SELECT * FROM sys.statements_with_full_table_scans WHERE db = 'shop'\G
-- 每支查詢的平均延遲與呼叫次數(ch07 主講)
SELECT digest_text, count_star, avg_timer_wait/1e9 AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY avg_timer_wait DESC LIMIT 10;更直接的一招:把關鍵查詢的執行計畫寫進整合測試,斷言 key 必須等於預期的索引。計畫是可以被測試的資產,不是只能事後追查的黑盒。
知道優化器會選錯之後,最直覺的反應是:那我直接叫它用哪個索引不就好了。可以,但這是一筆有利息的交易。
-- 建議(優化器仍可能不理)
SELECT * FROM orders USE INDEX (idx_m_s_c) WHERE ...;
-- 排除某個索引,其餘讓它自己選 —— 副作用最小的一種
SELECT * FROM orders IGNORE INDEX (idx_created_at) WHERE ...;
-- 強制(幾乎等同於「不准全表掃描」)
SELECT * FROM orders FORCE INDEX (idx_m_s_c) WHERE ...;
-- MySQL 8.0 的 optimizer hint,寫在註解裡
SELECT /*+ INDEX(orders idx_m_s_c) */ ...;
SELECT /*+ NO_INDEX(orders idx_created_at) */ ...;
SELECT /*+ JOIN_ORDER(o, u) */ ...;
SELECT /*+ MAX_EXECUTION_TIME(2000) */ ...;FORCE INDEX 綁在表名後面。FORCE INDEX 指定的索引不存在時是 ERROR 1176 (42000): Key 'idx_xxx' doesn't exist in table——不是退回自動選擇,是整句失敗。有人清理「沒用到的索引」時就會踩到(因為它在監控上看起來確實沒被用到多少)。nativeQuery 的失敗場景——那正是這樣來的)。它是止血手段,不是解法。合理的使用情境只有這幾種:
這一章給了你一整排旋鈕:採樣頁數、直方圖、optimizer_switch、hint、成本常數。而這一節要說的是什麼時候不要碰它們。
io_block_read_cost、關 optimizer_switch 的某個開關,影響的是整個實例上的每一句查詢。為了一句 SQL 讓其他一千句的計畫重新洗牌,是把一個局部問題換成一個全域風險。ANALYZE TABLE 通常很快(它只採樣不掃全表),但它需要取得 metadata lock。如果當下有長交易占著這張表,ANALYZE 會排隊等待——而排在它後面的所有查詢也會跟著卡住。線上執行前先看一眼 SHOW PROCESSLIST 有沒有長交易。STATS_SAMPLE_PAGES 會讓每次重算讀更多頁;對超大表要衡量重算頻率。ANALYZE 之後,要記得回頭確認關鍵查詢的計畫。有些問題不管統計資訊多準都不會變好,硬要調優化器只是浪費時間:
LIMIT 1000000, 10 的深分頁。無論走哪個索引都得先跳過一百萬列(ch04)。Using join buffer 就是這種訊號(ch03)。下一章往上走一層:當查詢牽涉到兩張以上的表,這裡的每一個估算誤差都會被乘上被驅動表的查詢次數——測試環境 20 毫秒的 JOIN,正式環境跑 40 秒就是這樣來的。
| 統計來源 | 回答的問題 | 怎麼更新 | 誰維護 |
|---|---|---|---|
| 索引 cardinality | 這個索引有幾個不重複值(過濾力多強) | 隨機採樣 20 頁推算(STATS_SAMPLE_PAGES) | 自動:變動超過 10% 觸發重算 |
| 表列數估計 | 全表大概多少列(全表掃描的成本) | 同上,採樣估算 | 自動;與 COUNT(*) 對不上是正常的 |
| Histogram | 每個值實際出現多少次(分佈是否傾斜) | ANALYZE TABLE ... UPDATE HISTOGRAM | 完全手動——不會自動更新 |
| 成本常數 | 讀一頁 / 比一列各值多少分 | mysql.server_cost、mysql.engine_cost | 手動,且影響全域——沒有量測不要動 |
| 症狀 | 根因 | 怎麼確認 | 第一順位解法 |
|---|---|---|---|
| 什麼都沒改,突然變慢 | 寫入越過 10% 門檻,統計資訊被背景重算 | EXPLAIN 的 key 換了;rows 與實際 COUNT 差一個數量級 | ANALYZE TABLE 止血 → 補複合索引治本 |
| 加了 LIMIT 反而慢 | 為了免費排序而改走 ORDER BY 欄位的索引 | 拿掉 LIMIT 就變快;Extra 沒有 Using filesort | 建 (過濾欄位…, 排序欄位) 複合索引;急用 prefer_ordering_index=off |
| 換個參數值就慢 | 資料分佈傾斜,均勻假設不成立 | 用線上真正慢的參數值 EXPLAIN,與快的那組對照 | 對該欄位建 histogram |
| 多條件查詢估算離譜 | 優化器假設欄位互相獨立,把選擇度相乘 | EXPLAIN ANALYZE 看 actual rows 與估算的差距 | 改成能覆蓋這些條件的複合索引 |
| 手段 | 強度 | 主要代價 | 什麼時候用 |
|---|---|---|---|
| ANALYZE TABLE | 無(只修輸入) | 需要 metadata lock,遇長交易會排隊 | 第一順位;估算與實際差很多時 |
| 調整索引結構 | 從根本消除選項 | 寫入變慢、空間變大(代價守恆) | 治本首選——讓優化器沒得選錯 |
| Histogram | 只改估算,不強制 | 不會自動更新,需要人維護 | 資料傾斜、且該欄位不值得建索引 |
| optimizer hint | 指定單句 | 會過期;ORM 難維護;掩蓋根因 | 線上救火止血,事後補治本 |
| FORCE INDEX | 最強硬 | 索引被刪就整句報錯 1176 | 同上,但優先用 hint 取代它 |
| 改成本常數 / optimizer_switch | 全域 | 波及實例上每一句查詢 | 有完整量測資料時才動 |
點擊卡片翻面查看答案,共 13 張。