資料庫效能與維運 ch02 優化器為什麼選錯索引:它不是壞掉,它是在算一筆錯的帳
下一章→
CH 02 估算的本質

優化器為什麼選錯索引:它不是壞掉,它是在算一筆錯的帳

成本模型統計資訊怎麼來與怎麼過期選擇度直方圖ORDER BY LIMIT 換索引陷阱ANALYZE TABLEFORCE INDEX 與 optimizer hints 的代價

上一章你已經會讀計畫了。這一章要處理的是讀完之後最常出現的那個問題:

索引明明就在那裡,EXPLAIN 為什麼不用它?

而更讓人不安的版本是:它昨天還在用,今天就不用了。程式沒改、索引沒動、資料只是多了一些。

答案是:優化器從來就不是在找「最快的計畫」,它是在找「估算成本最低的計畫」。這兩件事在資料分佈平均、統計資訊新鮮的時候幾乎等價;一旦統計資訊失準,它就會用非常合理的邏輯,推導出一個非常糟糕的結論——而且完全不會告訴你。

這一章要拆開的是:

  • 成本模型:它到底在算什麼?那些成本常數放在哪,可以看得到嗎?
  • 統計資訊:cardinality 從哪來、什麼時候自動更新、為什麼 20 頁的採樣會騙人。
  • 選擇度:為什麼 status 這種只有五個值的欄位,加了索引也常常用不到——而這是對的。
  • 直方圖:MySQL 8.0 補上的分佈資訊,它能救什麼、不能救什麼、以及它不會自動更新。
  • ORDER BY ... LIMIT 換索引:最經典的一個陷阱——加了 LIMIT 20 反而慢一百倍。
  • 一個真實場景:每晚跑的批次結束後,同一支查詢從 30 毫秒變成 8 秒。
  • 介入手段的代價:FORCE INDEX 為什麼是最後手段,以及 8.0 的 hint 好在哪裡。

這一章的主線是這一軌的第一條:優化器看的是估算,你該看的是實測。上一章教你怎麼看到估算;這一章要教你怎麼判斷那個估算是不是在騙你,以及騙你的時候該怎麼辦。

「優化器選錯索引」這句話其實不太準確。優化器沒有選錯——它非常忠實地執行了一個成本計算,只是那個計算的輸入是錯的。把這件事想清楚,後面所有的處理方式才有邏輯。

它實際在做的事

1
列舉:這句 SQL 有哪些可行的執行方式(用哪個索引、JOIN 誰先誰後、要不要排序)。
2
估算:每一種方式各要讀幾頁、比對幾列——這一步靠統計資訊。
3
換算:把「讀幾頁、比幾列」乘上成本常數,得到一個沒有單位的分數。
4
挑最小的那個——然後就這麼跑了,不回頭檢查。
整條鏈裡只有第 2 步是猜的,而後面三步都建立在它上面。所以「優化器選錯索引」的正確說法是:統計資訊失準,導致成本估算失準,導致它挑了一個真實成本很高的計畫。

為什麼它不乾脆去實測一次

因為那就跟真的執行一樣貴了。優化本身必須遠比執行便宜,否則優化就沒有意義——一句 SQL 花 3 毫秒選計畫、5 毫秒執行是划算的;花 200 毫秒選計畫就荒謬了。

估算的不精確不是實作缺陷,是這個設計的必然代價。你要做的不是期待它變準,而是知道它什麼時候會不準、以及不準的時候怎麼辦。

它也不保證挑到全域最佳

可行計畫的數量會隨著 JOIN 的表數量爆炸性成長(n 張表的排列有 n! 種)。所以優化器有搜尋深度上限(optimizer_search_depth),表一多就改用貪婪搜尋,只找「夠好」的計畫。

-- 這兩個旋鈕平常不要動,但知道它們存在,才知道「表很多的 JOIN 為什麼計畫怪怪的」
SELECT @@optimizer_search_depth;   -- 預設 62(超過就用啟發式)
SELECT @@optimizer_prune_level;    -- 預設 1(提早剪枝)

所以在多表 JOIN 的場景,「優化器沒選到那個明顯更好的順序」是有可能的,而且它連考慮都沒考慮過。

優化器算出來的成本是一個沒有單位的相對分數,不是毫秒。它只用來比大小。而它的組成很單純,就兩類:

  • I/O 成本:要讀幾個頁(block)
  • CPU 成本:要比對幾列

成本常數是可以查的

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     從磁碟讀一頁
看這兩個數字就知道優化器的世界觀:讀一頁磁碟 ≈ 比對 10 列。所以它會非常樂意「多比對一些列」來換「少讀幾頁」——這就是它有時候寧願全表順序掃描也不走索引回表的原因(回扣 ch01 的 type=index 推導)。

看到實際的成本數字

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)通常一眼就看得出來。

成本模型的三個內建假設

它算得再仔細,前提也只有這幾條,而每一條都可能與現實不符:

  • 假設資料不在記憶體——8.0 之後雖然會參考 Buffer Pool 的命中狀況,但仍不精確。一張早就整個被快取住的表,實際讀取成本遠低於估算。
  • 假設隨機 I/O 與順序 I/O 的比例是固定的——這在機械硬碟時代是對的,在 NVMe SSD 上差距小很多。(SSD 環境可以考慮調低 io_block_read_cost,但那是全域影響,動之前要有量測。)
  • 假設欄位之間互相獨立——這條是最常出問題的。WHERE city = '台北' AND zipcode = '110',優化器會把兩個條件的選擇度相乘,但這兩個欄位其實高度相關,實際命中列數會遠大於估算。

成本估算的輸入是統計資訊,而統計資訊的核心是每個索引的 cardinality:這個索引裡有幾個不重複的值。

它是採樣出來的

InnoDB 不會為了統計去掃全表,它隨機抓幾頁索引頁,數一數不重複值,再依比例放大到整張表:

SELECT @@innodb_stats_persistent_sample_pages;   -- 預設 20
一張兩千萬列的表,優化器是靠隨機 20 頁的採樣,在決定每一句 SQL 該怎麼跑。資料分佈越不平均,這 20 頁越不具代表性。這是所有「優化器選錯索引」問題的源頭。

統計資訊存在哪、看得到嗎

8.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 之類的操作也可能帶動更新。
「10% 自動重算」正好造就了最難查的那種故障:它是在你沒有部署、沒有改程式的某個時刻,因為累積寫入剛好越過門檻,而在背景把計畫換掉的。從應用層看,就是「什麼都沒動,它自己變慢了」。

採樣越多越準,但不是免費的

-- 針對特定大表提高採樣頁數(只影響這張表)
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 頁順序讀(還有預讀加持)

差了兩個數量級,而且是索引比較慢。優化器選全表掃描是完全正確的判斷。

經驗門檻:當一個條件命中的比例超過大約 20~30%,索引就開始不划算了。所以「看到 type=ALL 就急著加索引」是錯的直覺——要先問「這個條件過濾掉多少資料」。

那 status 這種欄位就不能建索引嗎

可以,但要建在對的地方——放進複合索引,而且不是放第一個:

-- ❌ 幾乎沒用
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(每桶列數相近)。

它改變的是 filtered,不是 rows

這是最需要弄清楚的一點。回到 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)。

直方圖對「沒有索引的欄位」幫助最大。有索引的欄位,優化器本來就能從索引統計拿到更精確的資訊;直方圖真正補的是那些「不值得建索引、但會出現在 WHERE 裡影響估算」的欄位。

代價:它不會自己更新

直方圖是靜態快照,資料變了它不會跟著變,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;

優化器的推理過程

它不是隨機犯錯的,它的推理其實很有道理:

1
有 ORDER BY created_at DESC,而 idx_created_at 本身就是有序的。
2
如果走這個索引,排序完全免費——省下一次 filesort。
3
而且只要 20 列。從最新的訂單往回掃,「應該」很快就湊滿了。
4
估算:命中率 × 掃描列數 → 掃個幾百列就夠 → 成本比走 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 秒,客服電話進來了。

  • 程式碼沒有部署,索引沒有異動,DB 參數沒有改。
  • 只有一件事發生過:凌晨三點的批次匯入了 300 萬列歷史訂單(表原本 1,700 萬列)。
  • 更怪的是——某些商家很快,某些商家很慢,而慢的那些用同一個 API、同一句 SQL。

診斷

照 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 萬

根因(三件事疊在一起)

1
批次寫入越過了 10% 門檻(300 萬 / 1,700 萬 ≈ 17.6%),觸發 innodb_stats_auto_recalc 背景重算統計。這就是「什麼都沒動卻自己變了」的那個時刻。
2
匯入的是歷史訂單,資料分佈被改變了:created_at 的舊資料變得非常密集,而且集中在少數幾個商家。20 頁的採樣正好採到那批新資料,merchant_id 的 cardinality 被嚴重高估——優化器因此以為「每個商家的資料都很少」。
3
加上 ORDER BY created_at DESC LIMIT 20(上一節那個陷阱),走排序索引的估算成本被壓到極低。於是它換了索引,而那個商家最近沒訂單,反向掃了 1,420 萬列。
沒有任何一步是錯的,錯的是輸入。統計資訊失準 → 估算失準 → 計畫翻車。這就是這一章開頭那句話的完整長相。

修法(依序)

1
止血(5 分鐘內):ANALYZE TABLE orders; 重算統計。這在很多案例裡當場就恢復了——但它不保證,因為採樣仍然可能不具代表性。
2
治本(當天):建 idx_m_s_c (merchant_id, status, created_at),讓過濾與排序由同一個索引完成。這樣就沒有「兩個索引可以選」的問題了——最好的介入是讓優化器沒得選錯。
3
防再犯(這週):把 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) */ ...;

hint 為什麼比 FORCE INDEX 好一點

  • 不支援時被當成註解忽略,不會讓語句直接失敗——版本升級、換 DB 時比較安全。
  • 粒度更細:可以只干預 JOIN 順序、只排除某個索引、只設定這一句的逾時。
  • 可以指定作用在哪張表、哪個查詢區塊,不像 FORCE INDEX 綁在表名後面。

代價(這是重點)

① 它會過期,而且悄悄地過期。你今天寫死的索引,是根據今天的資料分佈選的。半年後資料長大、分佈改變,那個索引可能已經不是最好的選擇了——但它被寫死在 SQL 裡,永遠不會被重新評估。你把一個會自我修正的機制,換成了一個不會的。
  • ② 索引改名或刪除,查詢直接報錯。FORCE INDEX 指定的索引不存在時是 ERROR 1176 (42000): Key 'idx_xxx' doesn't exist in table——不是退回自動選擇,是整句失敗。有人清理「沒用到的索引」時就會踩到(因為它在監控上看起來確實沒被用到多少)。
  • ③ 它會掩蓋真正的問題。統計資訊過期是可以修的;加了 hint 之後症狀消失,那個根因就留在系統裡,換一句 SQL 再爆一次。
  • ④ 在 ORM 裡很難維護。要用 hint 通常得改寫成 native query,於是就失去了型別安全與可組合性(回扣 ch01 那個 nativeQuery 的失敗場景——那正是這樣來的)。

什麼時候該用

它是止血手段,不是解法。合理的使用情境只有這幾種:

  • 線上正在燒,需要五分鐘內恢復——先加 hint,事後補治本。
  • 已經確認資料分佈就是極度傾斜,而且短期內不會改變,統計資訊再怎麼調都救不了。
  • 加了 hint 的同時,在程式碼裡留下註解寫清楚「為什麼、什麼條件下可以拿掉」。沒有這行註解,三個月後沒有人敢動它。
優先順序:修統計資訊 → 改索引結構讓它沒得選錯 → 改寫 SQL → 最後才是 hint。每往後一步,你就多背一份未來的維護成本。

這一章給了你一整排旋鈕:採樣頁數、直方圖、optimizer_switch、hint、成本常數。而這一節要說的是什麼時候不要碰它們。

三個「先不要動」

  • 只有一句 SQL 慢的時候,不要動全域參數。調 io_block_read_cost、關 optimizer_switch 的某個開關,影響的是整個實例上的每一句查詢。為了一句 SQL 讓其他一千句的計畫重新洗牌,是把一個局部問題換成一個全域風險。
  • 沒有量測就不要調成本常數。「我們用 SSD,所以隨機讀沒那麼貴」聽起來合理,但你要能拿出改前改後的關鍵查詢延遲對照,否則就只是換一組沒有根據的猜測。
  • 建了直方圖就要有人維護。它不會自動更新(上面說過)。沒有維護計畫的直方圖,是一個會慢慢腐爛的錯誤來源。

統計資訊維護本身的代價

  • ANALYZE TABLE 通常很快(它只採樣不掃全表),但它需要取得 metadata lock。如果當下有長交易占著這張表,ANALYZE 會排隊等待——而排在它後面的所有查詢也會跟著卡住。線上執行前先看一眼 SHOW PROCESSLIST 有沒有長交易。
  • 提高 STATS_SAMPLE_PAGES 會讓每次重算讀更多頁;對超大表要衡量重算頻率。
  • 統計資訊更新之後,計畫可能立刻改變——通常是變好,但也可能是換一種翻車。所以在正式環境跑 ANALYZE 之後,要記得回頭確認關鍵查詢的計畫。

優化器完全處理不了的三件事

有些問題不管統計資訊多準都不會變好,硬要調優化器只是浪費時間:

  • SQL 本身的寫法就要求了昂貴的操作——例如 LIMIT 1000000, 10 的深分頁。無論走哪個索引都得先跳過一百萬列(ch04)。
  • 索引根本不存在。優化器只能從現有的索引裡挑,它不會幫你變出一個。Using join buffer 就是這種訊號(ch03)。
  • 問題不在單句 SQL。鎖等待、連線池排隊、N+1、複本延遲——計畫再完美也沒用(ch05、ch07、ch08)。

把這一章收成三句

① 優化器沒有壞,它是在用一份採樣出來的統計資訊算成本,所以它的錯永遠是「估算錯」而不是「邏輯錯」。
② 最好的介入不是叫它用哪個索引,而是讓它沒得選錯——用一個同時滿足過濾與排序的複合索引。
③ 每一個介入手段都有利息:hint 會過期、直方圖要維護、採樣要成本、全域參數會波及所有查詢。

下一章往上走一層:當查詢牽涉到兩張以上的表,這裡的每一個估算誤差都會被乘上被驅動表的查詢次數——測試環境 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全域波及實例上每一句查詢有完整量測資料時才動

練習題 點選選項查看解析

0 / 10
01 / 10
「優化器選錯索引」最精確的描述是什麼?
A 優化器的演算法有 bug,需要升級版本修正
B 統計資訊失準 → 成本估算失準 → 它挑了一個真實成本很高的計畫;邏輯沒錯,錯的是輸入
C 索引本身損壞,需要重建
D SQL 寫法不標準,導致優化器無法解析
解析
優化器的流程是列舉可行計畫 → 用統計資訊估算成本 → 挑分數最低的。四個步驟裡只有「估算」是猜的,而後面都建立在它上面。所以處理方向永遠是先修輸入(統計資訊、索引結構),而不是直接對抗它的決策。
02 / 10
InnoDB 的索引 cardinality 是怎麼取得的?
A 每次寫入時即時精確維護
B 隨機採樣少量索引頁(預設 20 頁)推算出來
C 掃描全表計算,所以完全精確
D 由 DBA 手動輸入
解析
預設 innodb_stats_persistent_sample_pages = 20。一張兩千萬列的表,優化器是靠 20 頁的採樣在決定所有查詢的計畫。資料分佈越不平均,這個採樣越不具代表性——這是「優化器選錯索引」的根本源頭。
03 / 10
程式沒部署、索引沒改,某支查詢卻在某天早上突然慢一百倍。最可能的原因是什麼?
A MySQL 自動升級了版本
B 累積寫入超過表列數的 10%,觸發 innodb_stats_auto_recalc 背景重算統計,計畫因此改變
C 索引在夜間自動被刪除了
D 查詢快取失效
解析
innodb_stats_auto_recalc 預設開啟,表變動超過 10% 時背景重算統計資訊。這正好造就了最難查的故障類型:沒有任何部署動作,計畫卻在某個時間點被換掉。批次匯入大量資料是最常見的觸發源。
04 / 10
status 欄位只有 5 個值,在 2000 萬列的表上建了 idx_status,但 EXPLAIN 顯示走全表掃描。這是為什麼?
A 索引沒有建成功
B 命中比例太高時,索引掃描加大量回表的隨機 I/O 會比全表順序掃描更貴,優化器的判斷是對的
C MySQL 不支援對 TINYINT 建索引
D 需要用 FORCE INDEX 才能生效
解析
選擇度極低的欄位,單獨建索引幾乎沒有意義。命中 40% 就是 800 萬次回表隨機 I/O,遠貴於順序讀 6.5 萬頁。經驗門檻是命中超過約 20~30% 索引就不划算。正確做法是把 status 放進複合索引,且不放第一個。
05 / 10
MySQL 8.0 的 histogram 主要改變 EXPLAIN 的哪個欄位?
A type
B key_len
C filtered(掃出來之後估計剩幾成)
D Extra
解析
cardinality 只說有幾個不重複值,並假設均勻分佈;histogram 提供實際分佈,讓優化器知道 status=9 只佔 0.01% 而不是 20%。這個資訊反映在 filtered 上,進而改變後續每一個決策(走不走索引、JOIN 誰驅動、要不要暫存表)。
06 / 10
關於 histogram,下列哪一點最需要注意?
A 它會隨資料變動自動更新
B 它不會自動更新——一份舊的直方圖會用過期的分佈指導今天的查詢,比沒有它更危險
C 它會取代索引的 cardinality 統計
D 它只能建在有索引的欄位上
解析
histogram 是靜態快照,innodb_stats_auto_recalc 管不到它。建立它的同時就必須決定誰負責重建(跟著批次流程、或週期性維護)。沒有維護計畫的直方圖是一個會慢慢腐爛的錯誤來源。另外它對「沒有索引的欄位」幫助最大。
07 / 10
SELECT ... WHERE merchant_id = ? AND status = 1 ORDER BY created_at DESC LIMIT 20 很慢,但把 LIMIT 拿掉反而變快。這是什麼問題?
A LIMIT 語法本身有效能問題
B 優化器為了避免 filesort 改走 created_at 索引,並假設很快能湊滿 20 列;但該商家最近沒資料,於是反向掃了上千萬列
C ORDER BY DESC 無法使用索引
D LIMIT 20 觸發了暫存表
解析
LIMIT 讓「走排序索引」的估算成本變得極低,前提是「符合條件的資料在時間軸上均勻分佈」。這個假設一旦不成立就會災難性翻車。確認方法就是拿掉 LIMIT 做對照實驗;治本是建 (merchant_id, status, created_at) 複合索引。
08 / 10
承上題,為什麼「建一個同時滿足過濾與排序的複合索引」比下 hint 更好?
A 因為 hint 的語法太複雜
B 因為它從根本消除了「兩個索引可以選」這件事——最好的介入是讓優化器沒得選錯,而不是每次都去糾正它
C 因為索引不佔空間
D 因為 hint 在 MySQL 8.0 已被移除
解析
hint 是把「會自我修正的機制」換成「不會自我修正的寫死決定」:資料分佈變了它不會跟著變,索引被刪還會讓查詢直接報錯。複合索引則是改變了問題本身。當然它也有代價——寫入變慢、空間變大,這就是「代價守恆」。
09 / 10
FORCE INDEX 指定的索引後來被刪除了,會發生什麼事?
A 自動退回讓優化器選擇
B 查詢直接報錯 ERROR 1176 Key doesn't exist
C 查詢照常執行但變慢
D MySQL 會自動重建該索引
解析
不是降級,是整句失敗。而且這種事很容易發生:清理「看起來沒被用到的索引」時,正好刪掉某句 SQL 寫死的那個。這也是 8.0 的 optimizer hint 較安全的原因之一——不支援或無法套用時只會被當成註解忽略。
10 / 10
在正式環境執行 ANALYZE TABLE 之前,最該先確認什麼?
A 確認磁碟空間足夠
B 確認沒有長交易占著這張表——ANALYZE 需要 metadata lock,會排隊,而排在它後面的查詢也會一起卡住
C 確認已經關閉 innodb_stats_auto_recalc
D 確認先做好完整備份
解析
ANALYZE TABLE 本身很快(只採樣不掃全表),真正的風險是 metadata lock 排隊:它等長交易,而後續查詢等它,於是一個「很快的維護動作」變成連鎖阻塞。執行前先看 SHOW PROCESSLIST。另外執行後要回頭確認關鍵查詢的計畫是否如預期改變。

點擊卡片翻面查看答案,共 13 張。

QUESTION
優化器選的是什麼?為什麼會「選錯」?
點擊翻面
ANSWER
它選的是估算成本最低的計畫,不是實際最快的計畫。列舉→估算→換算→挑最小,四步裡只有估算是猜的(靠統計資訊),所以它的錯永遠是「輸入錯」而非「邏輯錯」。
點擊翻回
QUESTION
MySQL 8.0 的成本常數放在哪?兩個關鍵值是多少?
點擊翻面
ANSWER
mysql.server_cost 與 mysql.engine_cost。row_evaluate_cost=0.1(比對一列)、io_block_read_cost=1.0(從磁碟讀一頁)——所以在優化器眼中,讀一頁磁碟約等於比對 10 列。
點擊翻回
QUESTION
索引的 cardinality 怎麼來的?預設採樣多少?
點擊翻面
ANSWER
隨機採樣索引頁推算,預設 innodb_stats_persistent_sample_pages = 20 頁。持久化存在 mysql.innodb_index_stats,SHOW INDEX 看到的 Cardinality 就是它。
點擊翻回
QUESTION
統計資訊什麼時候會自動重算?為什麼這件事很重要?
點擊翻面
ANSWER
innodb_stats_auto_recalc 預設開啟,表變動超過總列數的 10% 時背景重算。它造就了「程式沒部署、索引沒改,某天卻自己變慢」這類最難查的故障。
點擊翻回
QUESTION
選擇度怎麼算?什麼時候索引就不划算了?
點擊翻面
ANSWER
不重複值數量 / 總列數。經驗門檻:一個條件命中超過約 20~30% 的資料時,索引掃描加回表的隨機 I/O 就會輸給全表順序掃描,優化器放棄索引是正確的。
點擊翻回
QUESTION
status 這種低選擇度的欄位該怎麼建索引?
點擊翻面
ANSWER
不要單獨建,放進複合索引而且不放第一個:先用高選擇度欄位把範圍縮到幾十列,再用 status 篩一次。例如 (merchant_id, status, created_at)。
點擊翻回
QUESTION
Histogram 補的是什麼資訊?它影響 EXPLAIN 的哪一欄?
點擊翻面
ANSWER
補「值的實際分佈」(cardinality 只知道有幾個不重複值,並假設均勻)。它影響 filtered,讓優化器知道 status=9 只佔 0.01% 而不是 20%。對沒有索引的欄位幫助最大。
點擊翻回
QUESTION
Histogram 最大的代價是什麼?
點擊翻面
ANSWER
它不會自動更新,innodb_stats_auto_recalc 管不到。舊的直方圖會用過期的分佈指導今天的查詢,比沒有它更危險——建立時就要決定誰負責重建。
點擊翻回
QUESTION
為什麼加了 ORDER BY ... LIMIT 20 反而可能慢一百倍?
點擊翻面
ANSWER
優化器為了免費得到排序,改走 ORDER BY 欄位的索引,並假設「很快就能湊滿 20 列」。若符合條件的資料在時間軸上不均勻(例如該商家最近沒資料),就會反向掃上千萬列。
點擊翻回
QUESTION
怎麼快速確認是 ORDER BY + LIMIT 換索引的問題?
點擊翻面
ANSWER
拿掉 LIMIT 做對照實驗——若計畫換回過濾索引且變快,基本定案。另外 EXPLAIN 會顯示 key 是排序欄位的索引,且 Extra 沒有 Using filesort。
點擊翻回
QUESTION
介入優化器的優先順序是什麼?
點擊翻面
ANSWER
① 修統計資訊(ANALYZE TABLE)② 改索引結構讓它沒得選錯 ③ 改寫 SQL ④ 最後才是 hint。每往後一步就多背一份未來的維護成本。
點擊翻回
QUESTION
FORCE INDEX 有哪三個代價?
點擊翻面
ANSWER
①會過期:資料分佈變了它不會重新評估 ②索引被刪除時整句報錯 1176(不是降級)③掩蓋根因,且在 ORM 裡通常得改寫成 native query 才用得了。
點擊翻回
QUESTION
在正式環境跑 ANALYZE TABLE 的風險是什麼?
點擊翻面
ANSWER
它需要 metadata lock。若有長交易占著該表,ANALYZE 會排隊,而排在它後面的查詢也一起卡住——一個很快的維護動作變成連鎖阻塞。執行前先看 SHOW PROCESSLIST。
點擊翻回