資料庫效能與維運 ch01 執行計畫判讀:在猜為什麼慢之前,先看它打算怎麼做
下一章→
CH 01 診斷起點

執行計畫判讀:在猜為什麼慢之前,先看它打算怎麼做

EXPLAIN 十二個欄位type 存取等級key_len 看你用到第幾個欄位rows 與 filtered 的估算本質Extra 才是重點EXPLAIN ANALYZE 實測兩句幾乎一樣的 SQL

線上有支查詢慢了。你打開程式碼看了三分鐘,開始猜:是不是資料量變大了?是不是該加索引?是不是要加快取?

這一整套流程從第一步就錯了。你在猜「它為什麼慢」,但你根本還不知道它做了什麼。同一句 SQL,可以走索引只讀三頁,也可以全表掃描讀六十五萬頁——這兩件事在程式碼裡長得一模一樣,在 EXPLAIN 裡差了五個數量級。

執行計畫就是資料庫在動手之前寫下的作戰計畫:打算用哪個索引、打算讀幾列、打算怎麼排序。它是整條診斷鏈的第一站,也是這一整軌反覆會用到的工具。這一章要把它讀懂:

  • EXPLAIN 的每個欄位在講什麼——不是背表格,是知道每個欄位回答了哪個問題。
  • type:存取方式的等級。從 const 到 ALL,這一欄決定了數量級。
  • key 與 key_len:不只是「有沒有用索引」,而是複合索引用到了第幾個欄位。
  • rows 與 filtered:這兩個數字是估算,不是實測。知道它從哪來,才知道它什麼時候會騙你。
  • Extra:新手最容易略過、但資訊密度最高的一欄。Using filesort、Using temporary 都在這裡。
  • EXPLAIN ANALYZE:MySQL 8.0.18 之後,你終於可以看到實際跑了多久、實際讀了幾列。
  • 一個真實場景:兩句幾乎一模一樣的 SQL,一句 2 毫秒,一句 8 秒——差別在 Java 那邊的一個型別。

這一章以 MySQL 8.0 / InnoDB 為準。索引的結構(B+Tree、聚簇索引、回表、最左前綴)在 Java 後端底層 ch01 已經講過,這裡假設你知道那些,專心處理「怎麼看出它到底有沒有照你想的方式走」。

這一軌有三條會反覆出現的主線,第一條從這章就開始:優化器看的是估算,你該看的是實測。

線上查詢變慢的時候,最常見的處理方式是這樣的:看程式碼 → 覺得某個條件沒索引 → 加索引 → 上線 → 有時候好了,有時候沒好。

「有時候沒好」才是重點。因為這個流程裡沒有任何一步是在確認事實,全部都是推測。

同一句 SQL 有很多種跑法

你寫的是「要什麼」,資料庫決定的是「怎麼拿」。一句 SELECT ... WHERE a = ? AND b = ?,優化器至少有這些選擇:

  • 走 idx_a,找到候選列之後回表逐列檢查 b
  • 走 idx_b,反過來做
  • 兩個索引都走,取交集(index merge)
  • 哪個都不走,直接全表掃描——當它估算符合的列很多時,這反而是對的選擇

這幾種跑法的成本可以差上萬倍。而你在程式碼裡看不出它選了哪一種。

執行計畫(execution plan)就是優化器在動手之前寫下的作戰計畫:打算用哪個索引、打算掃幾列、打算怎麼排序、打算怎麼做 JOIN。看它,你就從「猜」變成「讀」。

這一軌的第三條主線:先量再調

後面每一章都會遇到同一個誘惑——聽到某個症狀,直接跳到某個解法(慢就加索引、鎖就降隔離等級、CPU 高就調參數)。這一軌會反覆把你拉回同一個順序:

症狀 → 執行計畫(它打算做什麼)
     → 實測(它實際做了什麼)
     → 根因(為什麼會這樣做)
     → 才輪到改法

執行計畫是這條鏈的第一站。這章之後的每一章——優化器選錯索引、JOIN 變慢、深分頁、鎖等待、ORM 發出的 SQL——都會回到 EXPLAIN 這張表上找證據。

三種用法先記起來

EXPLAIN SELECT ...;                  -- 看計畫,不執行
EXPLAIN ANALYZE SELECT ...;          -- 真的跑一次,計畫 + 實測(8.0.18+)
EXPLAIN FOR CONNECTION 12345;        -- 看「正在跑」的那句在做什麼

最後一個在線上救火時特別有用:SHOW PROCESSLIST 看到某個連線卡了 40 秒,直接用它的連線 id 看那句 SQL 當下的計畫,不必自己重現。

不要把 EXPLAIN 當成一張要背的表。每個欄位其實都在回答一個很具體的問題,照著問題記就不會忘。

mysql> EXPLAIN SELECT o.id, o.amount, u.name
    -> FROM orders o JOIN users u ON u.id = o.user_id
    -> WHERE o.user_id = 42 AND o.status = 1
    -> ORDER BY o.created_at DESC LIMIT 20;

+--+-----------+-----+------+-----------------+---------+-------+-------------+----+--------+-----------+
|id|select_type|table|type  |possible_keys    |key      |key_len|ref          |rows|filtered|Extra      |
+--+-----------+-----+------+-----------------+---------+-------+-------------+----+--------+-----------+
| 1|SIMPLE     |o    |ref   |idx_uid_status,..|idx_uid_s|      9|const,const  | 214|  100.00|Using where|
| 1|SIMPLE     |u    |eq_ref|PRIMARY          |PRIMARY  |      8|shop.o.user_i|   1|  100.00|NULL       |
+--+-----------+-----+------+-----------------+---------+-------+-------------+----+--------+-----------+

逐欄對應的問題

  • id:誰先誰後?數字大的先執行;數字相同的由上而下。看巢狀子查詢時最有用。
  • select_type:這一列是哪種查詢?SIMPLE(無子查詢)、PRIMARY(最外層)、SUBQUERY、DERIVED(衍生表)、UNION。看到 DEPENDENT SUBQUERY 要警覺——那代表子查詢會被外層每一列驅動一次。
  • table:這一步在讀哪張表?可能是 <derived2> 這種衍生表代號。
  • partitions:命中哪些分區(沒分區就是 NULL)。
  • type:用什麼方式存取這張表?——數量級由它決定,下一節專講。
  • possible_keys:優化器「考慮過」哪些索引?注意是考慮過,不是用了。
  • key:最後真的用了哪個?possible_keys 有東西但 key 是 NULL——代表優化器評估後認為走索引更貴,這是 ch02 的主題。
  • key_len:索引用到了第幾個欄位?複合索引的關鍵指標,第四節專講。
  • ref:索引是拿什麼去比對的?const(常數)、另一張表的欄位、或 func(比對前經過運算——常常是壞消息)。
  • rows:估計要掃幾列?估計,不是實際。
  • filtered:掃出來之後,估計有幾成會留下?單位是百分比。
  • Extra:還做了哪些額外的事?資訊密度最高的一欄,第六節專講。
新手最常犯的兩個誤讀:①看到 possible_keys 有值就以為「有走索引」——要看 key;②看到 key 有值就放心——複合索引可能只用到第一個欄位,要看 key_len。

還有兩種輸出格式

EXPLAIN FORMAT=JSON SELECT ...;   -- 多出 cost 數字,看得到優化器算的成本
EXPLAIN FORMAT=TREE SELECT ...;   -- 樹狀,執行順序一目了然(8.0.16+)

表格式看單表最快;JOIN 一多、巢狀一深,FORMAT=TREE 比表格好讀非常多,因為表格式把樹狀結構壓成了平面。

如果只能看一個欄位,看 type。它描述「用什麼方式存取這張表」,而不同方式之間的差距是數量級的。

從好到壞

  • system / const:最多一列。用主鍵或唯一索引配常數,例如 WHERE id = 42。優化器甚至會在準備階段就把它讀出來當常數用。
  • eq_ref:JOIN 時,被驅動表用主鍵/唯一索引比對,每次比對最多一列。JOIN 能拿到的最好結果。
  • ref:用非唯一索引比對,每次可能回傳多列。日常查詢最常見的健康狀態。
  • range:索引上的範圍掃描——BETWEEN、>、IN (...)。健康,但要注意範圍多寬。
  • index:掃「整棵索引樹」。名字看起來像好事,其實是全掃,只是掃的是索引不是資料表。搭配 Using index(覆蓋索引)時還可以接受,因為索引比資料表小很多;沒有覆蓋還要回表的話,往往比 ALL 更慘。
  • ALL:全表掃描。小表無所謂,大表就是災難。
經驗門檻:線上查詢至少要做到 range,JOIN 的被驅動表至少要 ref。看到 ALL 出現在大表上,先不要問「要不要加索引」,先問「為什麼它不用現有的索引」。

index 為什麼會比 ALL 慢

這是最反直覺的一點,值得單獨推導一次。假設一張 500 萬列的表,走 type=index 掃 idx_status:

掃索引樹葉節點:讀約 8,000 頁(索引小,很快)
每命中一列 → 拿主鍵回表 → 一次隨機 I/O
如果命中 200 萬列 → 200 萬次隨機 I/O

而全表掃描是順序讀 65,000 頁。順序 I/O 對隨機 I/O 的差距,加上 InnoDB 的預讀(read ahead),結果就是:當命中比例夠高時,全表掃描真的比較快。

優化器知道這件事,所以它有時候會「明明有索引卻不用」。那通常不是它壞掉,是它在算帳。它算錯的情形留到 ch02。

還有幾個會遇到的值

  • index_merge:同時用了兩個以上的索引再合併結果。看到它通常代表該建一個複合索引了。
  • ref_or_null:條件是 col = ? OR col IS NULL,比 ref 多一段掃描。
  • NULL:連表都不用讀。例如 SELECT MIN(id) FROM t 直接從索引取得,或 WHERE 1 = 0 被判定為不可能。

key 有值只代表「有用到這個索引」,不代表「用好了」。複合索引 (user_id, status, created_at) 只用到第一個欄位,和三個都用到,效果差很多——而兩者的 key 欄位長得一模一樣。

唯一能分辨的是 key_len:它是這次查詢實際用到的索引前綴,總共佔幾個 byte。

怎麼算

  • 固定長度型別:TINYINT 1、INT 4、BIGINT 8、DATETIME 5、DATE 3
  • 可為 NULL 的欄位:再加 1 個 byte(記錄是否為 NULL)
  • 變長型別:再加 2 個 byte(記錄實際長度)
  • VARCHAR(n) 在 utf8mb4 下:n × 4(每字元最多 4 bytes)

對著算一次

CREATE TABLE orders (
  id         BIGINT       NOT NULL AUTO_INCREMENT,
  user_id    BIGINT       NOT NULL,      -- 8
  status     TINYINT      NOT NULL,      -- 1
  created_at DATETIME     NOT NULL,      -- 5
  memo       VARCHAR(50)  NULL,          -- 50*4 + 2 + 1 = 203
  PRIMARY KEY (id),
  KEY idx_u_s_c (user_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
查詢條件key_len意思
user_id = 428只用到第 1 個欄位
user_id = 42 AND status = 19用到前 2 個
user_id = 42 AND status = 1 AND created_at > ?14三個都用到
user_id = 42 AND created_at > ?8只用到第 1 個——中間跳過 status,後面就接不上了
第四列就是「最左前綴」在 EXPLAIN 上的長相。它不會報錯、不會警告,key 照樣顯示 idx_u_s_c,看起來一切正常——只有 key_len 誠實地告訴你,created_at 那個條件根本沒進到索引裡,是回表之後逐列比對出來的。

搭配 ref 一起讀

ref 欄位告訴你「拿什麼去比對索引」:

  • const:拿常數比對——最單純的情況
  • shop.o.user_id:拿另一張表的欄位比對——JOIN 的正常狀態
  • func:比對前先做了運算——例如型別轉換或函數呼叫。這是一個要停下來看的訊號,下一節與第八節都會再遇到它。

範圍條件會截斷後面的欄位

還有一個規則值得記:索引在遇到第一個範圍條件之後就停止繼續縮小範圍了。

-- idx_u_s_c (user_id, status, created_at)
WHERE user_id = 42 AND status > 1 AND created_at = '2026-08-13'
-- key_len = 9:user_id 等值 + status 範圍,到此為止
-- created_at 只能在回表後逐列過濾(Extra 會出現 Using where 或 index condition)

所以複合索引的欄位順序有一條實務規則:等值條件放前面,範圍條件放最後。這也是為什麼同樣三個欄位,順序換一下效能可以差幾十倍。

rows 是這一軌第一條主線的核心:它是估算,不是事實。知道這個數字怎麼來的,才知道它什麼時候會騙你。

它從哪來

InnoDB 不會為了回答「這個條件有幾列」去掃一次表——那樣 EXPLAIN 就跟真的執行一樣貴了。它看的是統計資訊:

  • 每個索引的 cardinality(不重複值的數量),靠隨機採樣幾頁索引頁推算出來
  • 採樣頁數由 innodb_stats_persistent_sample_pages 決定,預設只有 20 頁
  • 表的總列數,同樣是估算值(所以 SHOW TABLE STATUS 的 Rows 和 COUNT(*) 常常對不上,這是正常的)
一張兩千萬列的表,優化器是靠 20 頁的採樣在決定「這句 SQL 該怎麼跑」。資料分佈越不平均,這個估算越容易失準——這正是 ch02 整章要處理的問題。

filtered 是什麼

filtered 是百分比:從儲存引擎撈上來的列,估計有幾成能通過 server 層剩下的條件。真正重要的是這個乘積:

實際往上一層傳的列數 ≈ rows × filtered / 100

在單表查詢裡它只是參考值;在 JOIN 裡它決定一切——因為這個乘積就是「被驅動表要被查幾次」。rows=10000, filtered=1.00 代表優化器認為只會有 100 列進到下一步;如果實際是 10000 列,被驅動表就會被多查 99 倍次數。這正是 ch03 那個「測試環境 20ms、正式環境 40 秒」的來源。

怎麼判斷它有沒有騙你

最快的方法是拿它跟真實數字對照:

-- 優化器怎麼估
EXPLAIN SELECT * FROM orders WHERE status = 1 AND created_at > '2026-08-01';
-- rows = 214, filtered = 11.11

-- 實際是多少
SELECT COUNT(*) FROM orders WHERE status = 1 AND created_at > '2026-08-01';
-- 1,204,338

差一個數量級以上,就別再看它的計畫是否合理了——先去修統計資訊(ANALYZE TABLE,見 ch02)。計畫是根據錯的數字算出來的,再怎麼讀都是讀一份錯誤推論。

一個常見的誤會

rows 不是「這句查詢會回傳幾列」,是「這一步預計要檢查幾列」。一句 LIMIT 10 的查詢,rows 顯示 200 萬是完全可能的——它要掃 200 萬列才能找出那 10 列。會慢的正是這種:回傳的資料很少,但代價很大。只看回傳結果的大小永遠發現不了。

Extra 是最容易被略過的一欄,因為它排在最右邊、內容是英文短句、而且常常是 NULL。但一句查詢「慢在哪裡」的答案,多半就寫在這裡。

好消息

  • Using index:覆蓋索引——要的欄位索引裡都有,不必回表。看到它就是賺到。
  • Using index condition:索引條件下推(ICP)——把原本要回表後才能判斷的條件,下推到索引掃描階段先過濾,減少回表次數。是好事。
  • Using index for group-by:GROUP BY 直接用索引完成,不用排序也不用暫存表。

要看情況

  • Using where:儲存引擎撈上來之後,server 層還要再過濾一次。單獨看不代表壞——但如果同時 type=ALL,意思就是「全表撈上來再逐列篩」,那就是最糟的組合。

壞消息

  • Using filesort:結果需要額外排序,因為 ORDER BY 的順序沒辦法直接由索引提供。名字裡有 file,但不一定寫檔案——資料量小的時候在 sort_buffer_size 裡排完就好;超過就會落到磁碟,那才是災難。(兩種排序演算法、以及怎麼用索引避開,是 ch04 的主題。)
  • Using temporary:建了暫存表,常見於 GROUP BY 的欄位跟排序欄位不同、DISTINCT 加 ORDER BY、UNION。暫存表可能在記憶體,也可能落到磁碟。
  • Using join buffer (hash join):被驅動表沒有可用的索引,只能把驅動表的資料放進 buffer 做 hash join。8.0.18 之後的 hash join 比舊的 Block Nested Loop 快很多,但它出現本身就是一個訊號:那張表少了一個索引。(ch03 主講。)
最該警覺的組合:Using temporary; Using filesort 同時出現,而且 rows 很大。那代表資料庫要先把一大堆列撈出來、堆進暫存表、再整批排序——三件貴事一次做完。後台的統計報表頁面十之八九慢在這裡。

順手一招:看它把你的 SQL 改成了什麼

優化器會重寫你的 SQL(子查詢改寫成 JOIN、消除恆真條件、加上隱式轉換)。EXPLAIN 之後緊接著下這一句,可以看到它眼中的版本:

EXPLAIN SELECT * FROM members WHERE phone = 912345678;
SHOW WARNINGS;

/* select#1 ... where (cast(`shop`.`members`.`phone` as double) = 912345678) */

那個 cast(...) 就是索引失效的兇手——而你寫的 SQL 裡完全看不到它。這一招是第八節那個真實案例的破案關鍵,記起來。

EXPLAIN 給的是計畫與估算。MySQL 8.0.18 之後,EXPLAIN ANALYZE 會真的執行這句 SQL,然後把「估算」和「實際」並排寫給你看。這是這一軌第一條主線最直接的工具。

EXPLAIN ANALYZE
SELECT o.id, u.name FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 1 AND o.created_at > '2026-08-01';

-> Nested loop inner join
   (cost=1250 rows=214) (actual time=0.09..8412 rows=1204338 loops=1)
   -> Filter: (o.status = 1)
      (cost=980 rows=214) (actual time=0.06..2140 rows=1204338 loops=1)
      -> Table scan on o  (cost=980 rows=2000000) (actual time=0.05..1802 rows=2000000 loops=1)
   -> Single-row index lookup on u using PRIMARY (id=o.user_id)
      (cost=1.2 rows=1) (actual time=0.004..0.004 rows=1 loops=1204338)

怎麼讀這份輸出

  • 由內而外執行:縮排最深的最先跑,一層一層往上回傳。
  • cost= / rows=:優化器的估算。
  • actual time=A..B:實測。A 是拿到第一列的時間,B 是拿完全部的時間,單位毫秒。
  • loops=N:這個節點被執行了幾次。
最容易讀錯的一點:actual time 和 rows 都是「每一次 loop 的平均值」,不是總計。上面最後一行看起來只花 0.004 毫秒,但 loops=1204338——真正的成本是 0.004 × 1204338 ≈ 4.8 秒。這種「單次很快、次數爆炸」的節點,是 JOIN 與 ORM N+1 慢查詢最典型的長相。

估算 vs 實測,一眼看出問題

把上面那份輸出的兩組數字對起來看:

估算 rows=214    實際 rows=1,204,338    → 差了 5,600 倍

優化器是因為以為只有 214 列,才選擇了 Nested Loop(外層少少幾列,內層查幾次無所謂)。統計資訊一錯,計畫就跟著錯,而計畫錯的代價是 8.4 秒。這就是「優化器看估算、你看實測」的具體長相。

代價:它是真的會執行

  • 它真的跑那句 SQL。在正式環境對一句重查詢下 EXPLAIN ANALYZE,就是真的再壓一次資料庫。
  • 它不回傳結果集,所以省掉了傳輸成本——但掃描、排序、鎖的取得,一樣都不會少。
  • 8.0 只支援 SELECT;UPDATE/DELETE 只能用一般的 EXPLAIN(或先把它改寫成等價的 SELECT 來看)。
  • 在正式環境上務必配 SET SESSION max_execution_time = 5000;(單位毫秒)當保險,免得手一滑把庫壓垮。

在 Spring 專案裡怎麼拿到要分析的 SQL

ORM 發出去的 SQL 跟你想的往往不一樣(ch08 專講)。要先看到它:

spring:
  jpa:
    properties:
      hibernate.format_sql: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE   # 看得到綁定的參數值

參數值很重要——不同的參數值可以走出完全不同的計畫(範圍寬窄不同、型別不同)。拿沒有參數的 SQL 去 EXPLAIN,等於分析了一句線上根本沒跑過的查詢。

徵狀

會員查詢 API 上線兩年都很正常。某次改版之後,監控出現一件怪事:同一支 API,大部分請求 2 毫秒,但每天有幾百次跑到 8 秒以上。慢的那些請求沒有規律——不是尖峰時段、不是特定會員、重試一次又正常了。DBA 撈慢查詢日誌,看到的是這句:

SELECT * FROM members WHERE phone = 912345678;

而 members.phone 上明明有索引 idx_phone,表有 280 萬列。

診斷

把兩種呼叫方式各 EXPLAIN 一次,差異立刻現形:

EXPLAIN SELECT * FROM members WHERE phone = '0912345678';
-- type=ref   key=idx_phone   key_len=82   ref=const   rows=1        Extra=NULL

EXPLAIN SELECT * FROM members WHERE phone = 912345678;
-- type=ALL   key=NULL        key_len=NULL ref=NULL    rows=2871043  Extra=Using where

再用上一節那招問它為什麼:

SHOW WARNINGS;
/* ... where (cast(`shop`.`members`.`phone` as double) = 912345678) */

兇手是隱式型別轉換。推導一次為什麼會這樣:

1
phone 是 VARCHAR(20),但傳進來的參數是數字。
2
字串與數字比較時,SQL 標準規定兩邊都轉成浮點數再比——所以是欄位被轉換,不是參數。
3
欄位一旦被函數包住,索引就用不上了:索引裡存的是 '0912345678' 的順序,不是 cast(...) 之後的順序,B+Tree 無法用來定位。
4
優化器只好全表掃描,逐列做 cast 再比對——280 萬次。

比慢更嚴重的事

'0912345678' 轉成數字之後是 912345678——前導的 0 不見了。所以這句查詢會同時撈到 '0912345678' 和 '912345678' 兩筆不同的資料。它不只是慢,它會回傳錯的人。這種 bug 不會拋例外、不會進錯誤日誌,只會在某天變成一張客訴單。

為什麼只有一部分請求會中

回到 Java 這邊,來源是這種寫法:

@Query(value = "SELECT * FROM members WHERE phone = ?1", nativeQuery = true)
Optional<Member> findByPhoneRaw(long phone);   // ← 型別寫成了 long

新的內部管理後台呼叫了這個方法(參數從 JSON 的數字欄位進來,被綁成 Long),而原本的 App 端走的是另一個型別正確的方法。兩條路徑打同一張表、同一個條件、看起來同一句 SQL——只有型別不同。

修法

  • 治本:參數型別改回 String,與欄位型別對齊。查詢立刻回到 type=ref、2 毫秒。
  • 不要這樣治標:在資料庫端把 phone 改成 BIGINT——電話號碼有前導 0、有分機、有 +886,那是把一個型別錯誤換成一個資料模型錯誤。
  • 防再犯:對 nativeQuery 的參數型別做 code review 檢查點;在整合測試裡對關鍵查詢斷言 EXPLAIN 的 type 不等於 ALL——執行計畫是可以被自動化測試的。

同一類的其他兩種寫法

只要索引欄位被包住,症狀都一樣:

-- ❌ 函數包住欄位
WHERE DATE(created_at) = '2026-08-13'
-- ✅ 改成範圍,索引可用
WHERE created_at >= '2026-08-13 00:00:00' AND created_at < '2026-08-14 00:00:00'

-- ❌ 欄位上做運算
WHERE amount * 100 > 5000
-- ✅ 把運算移到常數那邊
WHERE amount > 50

還有一種特別難找的:JOIN 兩張表的字元集或 collation 不同(例如舊表是 utf8mb3、新表是 utf8mb4)。MySQL 會對其中一邊做隱式轉換,被驅動表的索引就此失效,Extra 出現 Using join buffer。表面上兩邊都有索引,怎麼看都沒問題。

一句話記住這一節:EXPLAIN 看到 ref=func,或 SHOW WARNINGS 看到 cast(,就是有東西把你的欄位包起來了。

執行計畫是診斷的第一站,不是最後一站。知道它不能回答什麼,跟知道它能回答什麼一樣重要——不然你會盯著一張很漂亮的計畫,找不到那 8 秒去了哪裡。

四件 EXPLAIN 完全看不到的事

  • 等鎖的時間。一句 type=const、只讀一列的 UPDATE,可以卡 40 秒——它在等另一個交易放開行鎖。計畫上完美無缺。(ch05 主講。)
  • 資料在不在記憶體。同樣讀 1000 頁,全部命中 Buffer Pool 是幾毫秒,全部要從磁碟讀是幾百毫秒。rows 一模一樣。(ch07 主講。)
  • 這句被呼叫了幾次。N+1 問題的每一句 SQL 都是 type=const、rows=1,漂亮得不得了——只是它跑了 200 次。單句計畫再完美,也照不出批次層的災難。(ch08 主講。)
  • 結果集傳輸與應用端處理。SELECT * 撈了 200 個欄位、其中三個是 TEXT,資料庫端可能只花 5 毫秒,網路傳輸與 ORM 映射花掉 2 秒。
所以完整的診斷順序是:先看計畫(它打算做什麼)→ 再看實測(實際跑多久)→ 再看它跑了幾次 → 最後才問是不是根本不是 SQL 的問題。ch07 會把這條完整的排查流程走一遍。

EXPLAIN 自己的代價

  • EXPLAIN 不執行查詢,但不是完全免費:它要做語法解析、要向 InnoDB 要統計資訊,含衍生表的複雜查詢在某些情況下還會物化子查詢。日常使用可以忽略,但別寫進迴圈裡當監控。
  • EXPLAIN ANALYZE 是真的執行——正式環境使用前先設 max_execution_time。
  • 不同的參數值可能走出不同的計畫。拿 WHERE status = 1 測出來的結論,套不到 status = 9 上(前者可能佔 60% 資料,後者只有 0.01%)。要用線上真正慢的那組參數去測。
  • 測試環境的計畫不能代表正式環境。資料量不同、資料分佈不同、統計資訊不同——優化器的選擇就會不同。這正是 ch03 那個「測試 20ms、正式 40 秒」的成因。

什麼時候不要從 EXPLAIN 開始

有兩種情況,一上來就 EXPLAIN 是浪費時間:

  • 整個資料庫都變慢,而不是某支查詢變慢。那多半是連線數、鎖等待、Buffer Pool、複本延遲或磁碟層的問題,不是某句 SQL 的計畫。先看全域指標。
  • 慢查詢日誌是空的,但 CPU 100%。代表沒有單句慢——是量的問題(QPS 暴增、N+1、缺快取)。這時候該去看的是呼叫次數,不是單句計畫。

把這一章收成一句

執行計畫回答的是「它打算怎麼做」。它有三個層次的可信度:type/key_len/Extra 描述的是結構,基本可信;rows/filtered 是估算,要拿實測對照;而鎖、快取、呼叫次數則完全不在它的視野裡。

下一章要處理的正是中間那一層:當優化器的估算錯了,它會選錯索引——而且是在某個你毫無察覺的時間點突然選錯的。

type 存取等級:從好到壞,以及看到它該做什麼
type存取方式大概讀多少看到它該做什麼
const / system主鍵或唯一索引 = 常數1 列最理想,不必動
eq_refJOIN 時被驅動表走主鍵/唯一索引每次比對 1 列JOIN 能拿到的最好結果
ref非唯一索引等值比對每次比對數列日常查詢的健康狀態
range索引上的範圍掃描(BETWEEN/>/IN)看範圍多寬可接受,但要確認範圍沒有寬到失去意義
index_merge同時走兩個以上索引再合併數個索引的命中總和訊號:該建一個複合索引了
index掃描整棵索引樹整個索引有 Using index(覆蓋)還行;要回表的話常比 ALL 更慘
ALL全表掃描整張表大表上出現=先問「為什麼不用現有索引」,不是急著加新索引
Extra 關鍵字圖鑑:是好消息還是壞消息
Extra 內容意思好壞怎麼處理
Using index覆蓋索引,不必回表✅ 好不用動;反過來說,這是優化的目標狀態
Using index condition索引條件下推(ICP),提早過濾減少回表✅ 好不用動
Using index for group-byGROUP BY 直接用索引完成✅ 好不用動
Using where引擎撈上來後 server 層再過濾⚠ 看情況配 ref/range 沒事;配 ALL=全表撈上來逐列篩,要處理
Using filesort需要額外排序,索引提供不了順序⚠ 通常壞調索引欄位順序讓 ORDER BY 走索引(ch04)
Using temporary建立暫存表(GROUP BY/DISTINCT/UNION)❌ 壞先確認 GROUP BY 與 ORDER BY 是否可以對齊(ch04)
Using join buffer (hash join)被驅動表沒有可用索引❌ 壞在被驅動表的 JOIN 欄位上補索引(ch03)
Using temporary; Using filesort先堆暫存表再整批排序❌ 最該警覺配上大 rows=後台報表頁最典型的慢法
四種診斷工具:什麼時候用哪一個
工具回答的問題會不會執行什麼時候用
EXPLAIN它打算怎麼做(索引、順序、額外動作)不執行第一站;任何單句慢查詢都從這裡開始
EXPLAIN ANALYZE實際跑了多久、實際幾列、估算差多少真的執行計畫看起來合理但還是慢的時候;正式環境記得配 max_execution_time
EXPLAIN FOR CONNECTION現在卡住的那句在做什麼不執行線上救火,配 SHOW PROCESSLIST 用
慢查詢日誌 / sys schema哪些句子慢、慢幾次、總共吃掉多少時間被動記錄還不知道要查哪一句的時候(ch07 主講)

練習題 點選選項查看解析

0 / 10
01 / 10
EXPLAIN 的 possible_keys 顯示了兩個索引,但 key 是 NULL。這代表什麼?
A 那兩個索引壞掉了,需要重建
B 優化器考慮過這些索引,但估算後認為走它們比全表掃描更貴
C SQL 語法有錯,索引無法解析
D 這張表的統計資訊還沒建立
解析
possible_keys 是「考慮過的」,key 才是「真的用的」。兩者不一致代表優化器算過帳之後放棄了索引——通常是因為它估計命中列數很多,回表的隨機 I/O 會比順序全表掃描更貴。這個判斷有時是對的(命中比例真的高),有時是統計資訊過期造成的誤判(ch02 主題)。
02 / 10
複合索引 idx (user_id, status, created_at),查詢條件是 WHERE user_id = 42 AND created_at > '2026-08-01'。EXPLAIN 顯示 key=idx,key_len=8(user_id 是 BIGINT NOT NULL)。這代表什麼?
A 三個欄位都用到了,索引效率最佳
B 只有 user_id 用到了索引,created_at 的條件是回表後逐列過濾的
C 索引完全沒有生效
D key_len 跟用到幾個欄位無關
解析
key_len=8 正好是一個 BIGINT 的長度,代表索引只用到第一個欄位。中間跳過了 status,最左前綴就此中斷,created_at 沒辦法進到索引定位裡。這是最陰險的情況:key 欄位照樣顯示索引名稱,看起來一切正常,只有 key_len 說了實話。
03 / 10
type=index 和 type=ALL,哪一個一定比較好?
A index 一定比較好,因為它走了索引
B ALL 一定比較好,因為順序 I/O 比較快
C 不一定:index 是掃整棵索引樹,若沒有覆蓋索引還要逐列回表,隨機 I/O 可能比全表順序掃更慘
D 兩者完全等價,只是顯示名稱不同
解析
type=index 的名字容易誤導,它是「全索引掃描」而不是「有效使用索引」。如果 Extra 同時有 Using index(覆蓋索引),因為索引比資料表小,通常還不錯;但若要回表,每一列命中就是一次隨機 I/O,總成本可能遠超過順序讀整張表的 ALL。
04 / 10
EXPLAIN 顯示 rows=214,實際 COUNT(*) 是 120 萬。應該先做什麼?
A 直接照這份計畫調整索引順序
B 先處理統計資訊(例如 ANALYZE TABLE),因為計畫是根據錯誤的估算算出來的
C 把 rows 當成回傳列數,代表查詢只回傳 214 列,沒有問題
D 忽略 rows,它本來就不準,直接看 Extra 就好
解析
rows 差一個數量級以上,代表優化器是在錯誤的前提下做決策——它選 Nested Loop、選某個索引、決定不排序,全都建立在「只有 214 列」這個假設上。這種情況下再怎麼分析計畫都是在讀一份錯誤推論,要先修統計資訊再重看。
05 / 10
phone 欄位是 VARCHAR(20) 且有索引。查詢寫成 WHERE phone = 912345678(傳數字),為什麼索引會失效?
A MySQL 會把數字參數轉成字串,字串比較無法使用索引
B MySQL 依標準把兩邊都轉成浮點數比較,等於在欄位上套了 cast(),B+Tree 的順序就用不上了
C 因為數字沒有加引號,語法解析失敗後退化成全表掃描
D VARCHAR 欄位本來就不能建有效的索引
解析
關鍵在於轉換發生在哪一邊。字串與數字比較時,是欄位被轉成數字(不是參數被轉成字串),欄位一旦被函數包住,索引裡儲存的順序就失去意義。反過來,數字欄位傳字串(WHERE id = '42')則是參數被轉換,索引仍然有效——這個不對稱性是實務上最容易踩的點。
06 / 10
承上題,這個隱式轉換除了慢,還有什麼更嚴重的問題?
A 沒有其他問題,只是慢而已
B 會拋出型別轉換例外,導致 API 500
C '0912345678' 轉成數字後前導 0 消失,可能同時比對到 '912345678',回傳錯誤的資料
D 會導致索引被自動刪除
解析
這是最危險的一類 bug:不報錯、不進錯誤日誌,只是安靜地回傳錯的人。轉成浮點數之後 '0912345678' 與 '912345678' 變成同一個值,兩筆不同的會員資料都會被撈出來。慢還看得見,錯要等客訴才會被發現。
07 / 10
EXPLAIN ANALYZE 的輸出中,某個節點顯示 actual time=0.004..0.004 rows=1 loops=1204338。這個節點的真實成本是多少?
A 0.004 毫秒,非常快,可以忽略
B 約 0.004 × 1204338 ≈ 4.8 秒,因為 actual time 是每次 loop 的平均值
C 1204338 毫秒
D 無法從這些數字推算
解析
actual time 與 rows 都是「每次 loop 的平均」而非總計,這是 EXPLAIN ANALYZE 最容易讀錯的地方。單次極快但 loops 極大的節點,正是 JOIN 被驅動表缺索引、以及 ORM N+1 的典型長相——每一次都很漂亮,加起來是災難。
08 / 10
Extra 出現 Using join buffer (hash join),代表什麼?
A MySQL 用了更快的 hash join,是效能優化的好消息,不用處理
B 被驅動表的 JOIN 欄位上沒有可用索引,只能靠 buffer 做 hash join
C join buffer 設定太小,需要調大參數
D 查詢用到了暫存表
解析
8.0.18 之後的 hash join 確實比舊的 Block Nested Loop 快很多,但它出現這件事本身就是訊號:被驅動表沒有索引可走。正確的處理是補索引,而不是滿足於「反正有 hash join」——調大 join_buffer_size 只是讓錯誤的計畫跑得沒那麼慘。
09 / 10
關於 EXPLAIN 的能力邊界,下列何者正確?
A EXPLAIN 可以看出這句 SQL 等了多久的行鎖
B EXPLAIN 可以看出資料是否命中 Buffer Pool
C EXPLAIN 可以看出這句 SQL 在一個請求裡被呼叫了幾次
D 以上皆非——鎖等待、快取命中、呼叫次數都不在執行計畫的視野裡
解析
執行計畫回答的是「它打算怎麼做」。一句 type=const、rows=1 的 UPDATE 可以卡 40 秒(等鎖)、可以慢 100 倍(沒命中 Buffer Pool)、可以被呼叫 200 次(N+1)——這三件事在計畫上完全看不出來,分別要靠 ch05、ch07、ch08 的方法。
10 / 10
監控顯示資料庫 CPU 100%,但慢查詢日誌幾乎是空的。此時最合理的第一步是什麼?
A 把最近改過的 SQL 逐句 EXPLAIN
B 先看呼叫次數與 QPS——沒有單句慢代表這是「量」的問題(N+1、缺快取、QPS 暴增),逐句看計畫幫不上忙
C 直接調大 innodb_buffer_pool_size
D 重啟資料庫釋放連線
解析
慢查詢日誌空的,代表每一句單看都不慢——問題不在計畫層而在數量層。這時候逐句 EXPLAIN 會看到一堆漂亮的 type=const,什麼也找不到。這正是「先量再調」的意思:症狀決定該用哪個工具,用錯工具比不查更浪費時間(完整流程見 ch07)。

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

QUESTION
為什麼診斷慢查詢的第一步是看執行計畫,而不是加索引?
點擊翻面
ANSWER
同一句 SQL 有很多種跑法(走索引 A、走索引 B、index merge、全表掃描),成本差上萬倍,而你在程式碼裡看不出它選了哪一種。加索引之前先看計畫,是從「猜」變成「讀」。
點擊翻回
QUESTION
possible_keys 有值但 key 是 NULL,代表什麼?
點擊翻面
ANSWER
優化器考慮過那些索引,但估算後認為走它們比全表掃描更貴,所以放棄了。可能是對的(命中比例真的高),也可能是統計資訊過期造成的誤判。
點擊翻回
QUESTION
type 這一欄從好到壞的順序是?
點擊翻面
ANSWER
const/system → eq_ref → ref → range → index → ALL。經驗門檻:線上查詢至少要 range,JOIN 的被驅動表至少要 ref。
點擊翻回
QUESTION
為什麼 type=index 有時候比 type=ALL 更慘?
點擊翻面
ANSWER
type=index 是掃整棵索引樹。若沒有覆蓋索引,每命中一列就要回表做一次隨機 I/O;而 ALL 是順序讀整張表。命中比例高時,順序 I/O 加上預讀會贏過大量隨機 I/O。
點擊翻回
QUESTION
key_len 是什麼?它能看出什麼別的欄位看不出的事?
點擊翻面
ANSWER
這次查詢實際用到的索引前綴共佔幾 bytes。它是唯一能看出「複合索引用到了第幾個欄位」的指標——最左前綴斷掉時,key 照樣顯示索引名稱,只有 key_len 會變短。
點擊翻回
QUESTION
key_len 怎麼算?
點擊翻面
ANSWER
固定型別照長度(TINYINT 1、INT 4、BIGINT 8、DATE 3、DATETIME 5);可為 NULL 再 +1;變長型別再 +2;VARCHAR(n) 在 utf8mb4 下是 n×4。
點擊翻回
QUESTION
為什麼複合索引要「等值條件放前面,範圍條件放最後」?
點擊翻面
ANSWER
索引在遇到第一個範圍條件之後就無法再用後面的欄位縮小範圍了。所以 (a=?, b>?, c=?) 只會用到 a 和 b,c 只能回表後逐列過濾。
點擊翻回
QUESTION
EXPLAIN 的 rows 是怎麼估出來的?為什麼會不準?
點擊翻面
ANSWER
靠 InnoDB 的統計資訊(索引 cardinality),而那是隨機採樣索引頁推算的——預設只採樣 20 頁(innodb_stats_persistent_sample_pages)。資料分佈越不平均越容易失準。
點擊翻回
QUESTION
filtered 是什麼?什麼時候特別重要?
點擊翻面
ANSWER
從引擎撈上來的列估計有幾成能通過剩餘條件(百分比)。rows × filtered / 100 就是往上一層傳的列數——在 JOIN 裡它決定被驅動表要被查幾次,因此關係重大。
點擊翻回
QUESTION
Extra 裡哪個組合最該警覺?
點擊翻面
ANSWER
「Using temporary; Using filesort」同時出現且 rows 很大:先把一大堆列撈出來、堆進暫存表、再整批排序,三件貴事一次做完。後台統計報表頁最典型的慢法。
點擊翻回
QUESTION
EXPLAIN ANALYZE 的 actual time 和 rows 要怎麼讀?
點擊翻面
ANSWER
兩者都是「每一次 loop 的平均值」,不是總計。要乘上 loops 才是真實成本——單次 0.004ms 但 loops=120 萬,實際是 4.8 秒。
點擊翻回
QUESTION
VARCHAR 欄位傳數字為什麼索引失效,數字欄位傳字串卻不會?
點擊翻面
ANSWER
字串與數字比較時兩邊都轉成浮點數,所以字串欄位會被 cast() 包住(索引失效);而數字欄位配字串參數時,被轉換的是參數,欄位本身沒被包住,索引仍可用。
點擊翻回
QUESTION
EXPLAIN 完全看不到哪四件事?
點擊翻面
ANSWER
①等鎖的時間 ②資料在不在 Buffer Pool ③這句被呼叫了幾次(N+1)④結果集傳輸與 ORM 映射成本。所以計畫很漂亮但仍然慢時,要往這四個方向找。
點擊翻回