Learning Objectives
- 在查詢前面加上 EXPLAIN,讀懂 type、possible_keys、key、key_len、rows 與 Extra
- 比較建立索引前後的執行計畫,判斷查詢有沒有用上索引、是不是覆蓋索引
- 辨認讓索引失效的寫法:對欄位套用函式、開頭是 % 的 LIKE、型別不一致,並改寫成能用索引的條件
- 開啟慢查詢日誌,設定 long_query_time,用 mysqldumpslow 彙整結果
- 說明 BENCHMARK、EXPLAIN FORMAT=TREE 與 EXPLAIN ANALYZE 的用途與限制
找出慢的查詢,再看它怎麼執行
上一課從設計著手:建立索引、選擇資料型別。但索引建了,查詢不一定用得上。查詢最佳化的流程是:
- 找出慢的查詢:開啟慢查詢日誌,記錄執行超過某個秒數的查詢
- 看它怎麼執行:在查詢前面加上
EXPLAIN,MySQL 會說明它打算怎麼找資料(執行計畫),但不真的執行 - 調整:建立或修改索引、改寫查詢,再用 EXPLAIN 確認
EXPLAIN 可以放在 SELECT、INSERT、UPDATE、DELETE 前面:
EXPLAIN SELECT * FROM employee WHERE name = 'Ben Smith';
關於播放器裡的 EXPLAIN:本課的直譯器以簡化的規則模擬 MySQL 優化器。type、possible_keys、key 的判斷接近 MySQL 8.0,
但 rows(估計要讀幾列)與 filtered(估計條件過濾後剩下的百分比)是估計值,本來就依伺服器的統計資訊而定,播放器的數字只是示意。
真實的優化器還會考慮資料量與成本,表格很小時可能選擇和大表格不同的方式。
學完之後,請在自己的 MySQL 8 上建立同樣的表格執行,比較結果。本課有幾處直接列出在真的 MySQL 8.0.44 上執行的結果,會特別標明。
EXPLAIN 的欄位
EXPLAIN 的結果每一列代表查詢裡的一張表格(JOIN 兩張表格就有兩列),重要的欄位:
| 欄位 | 意義 |
|---|---|
table |
這一列說的是哪張表格(有別名時顯示別名) |
type |
存取方式,最重要的欄位,見下表 |
possible_keys |
可能用得上的索引 |
key |
實際選用的索引,NULL 表示沒有用索引 |
key_len |
用到的索引長度(位元組),複合索引可以從這裡看出用到幾個欄位 |
ref |
拿什麼和索引比對:const 是常數,資料庫.表格.欄位 是另一張表格的欄位 |
rows |
估計要讀幾列 |
filtered |
估計讀到的列有百分之幾符合條件 |
Extra |
補充說明,見下表 |
type 由好到差:
| type | 意思 | 例子 |
|---|---|---|
const |
用主鍵或唯一索引比對常數,最多一列 | WHERE employeeID = 7513 |
eq_ref |
JOIN 時,對前一張表格的每一列,用主鍵或唯一索引在這張表格找到剛好一列 | 員工連接部門,用部門的主鍵 |
ref |
用一般索引(或唯一索引的一部分)比對,可能找到多列 | WHERE departmentID = 128 |
range |
用索引找一段範圍 | BETWEEN、>、<、IN、LIKE 'B%' |
index |
從頭到尾讀完整個索引(比讀表格小) | 只需要索引欄位的查詢 |
ALL |
全表掃描,從頭到尾讀完整張表格 | 沒有可用的索引 |
看到 ALL,而且 rows 很大,通常就是要改善的地方。Extra 常見的值:
| Extra | 意思 |
|---|---|
Using where |
讀出資料後,還要用 WHERE 條件再過濾 |
Using index |
覆蓋索引:只讀索引就能回答,不必回表格取資料 |
Using index condition |
索引條件下推:先用索引裡的欄位過濾,再回表格取資料 |
Using filesort |
無法依索引的順序取出,要另外排序 |
Using temporary |
要建立暫存表格(常見於 GROUP BY、DISTINCT) |
建立索引前後的執行計畫
同一個查詢在建立索引前後各 EXPLAIN 一次,比較 type、key、rows。接著只查 name,觀察 Extra 的 Using index;最後用主鍵查詢,看 const。(rows 與 filtered 是估計值,以下說明以 type、key、Extra 為主。)
原始課程的例子:ename_did
原始課程用這個連接查詢示範 EXPLAIN:
EXPLAIN
SELECT e.name, d.name
FROM employee e, department d
WHERE e.departmentID = d.departmentID;
結果有兩列:e 是 ALL(每位員工都要讀),d 是 eq_ref(對每位員工,用部門的主鍵找到剛好一個部門)。
這其實已經是合理的計畫:員工本來就要全部列出,部門則每次只找一列。下一個範例會在播放器裡看到這兩列。
課程接著建立複合索引,再 EXPLAIN 一次:
CREATE INDEX ename_did ON employee (name, departmentID);
在真的 MySQL 8.0.44 上,建立索引前後的結果是(只列出重要的欄位):
| 時機 | table | type | possible_keys | key | key_len | ref | Extra |
|---|---|---|---|---|---|---|---|
| 建立前 | e | ALL |
departmentID | NULL | NULL | NULL | NULL |
| 建立前 | d | eq_ref |
PRIMARY | PRIMARY | 4 | employee.e.departmentID | NULL |
| 建立後 | e | index |
departmentID | ename_did | 327 | NULL | Using index |
| 建立後 | d | eq_ref |
PRIMARY | PRIMARY | 4 | employee.e.departmentID | NULL |
建立後 e 改成 index,用的是 ename_did。注意這不是「用索引快速找到某位員工」,查詢沒有任何篩選員工的條件,
仍然要讀完所有員工;變快的原因是 e 需要的兩個欄位 name、departmentID 都在 ename_did 裡,
MySQL 改讀比較小的索引(覆蓋索引,Using index),不必讀整張表格。
各種 type:eq_ref、ref、range 與 filesort
第一個是原始課程的連接查詢,結果有兩列。接著用外鍵索引 departmentID 比對常數(ref)、用主鍵找一段範圍(range),最後依沒有索引的 job 排序(Using filesort)。
驗證最左前綴:看 key_len
上一課的 student 表格有複合索引 idx_dept_grade (dept, grade),dept 是 CHAR(4) NOT NULL、grade 是 TINYINT NOT NULL。
在真的 MySQL 8.0.44 上 EXPLAIN 的結果(12 位學生,只列出重要的欄位):
| 條件 | type | key | key_len | ref | Extra |
|---|---|---|---|---|---|
WHERE dept = 'CS' AND grade = 2 |
ref |
idx_dept_grade | 17 | const,const | Using index condition |
WHERE dept = 'CS' |
ref |
idx_dept_grade | 16 | const | Using index condition |
WHERE grade = 2 |
ALL |
NULL | NULL | NULL | Using where |
SELECT dept, grade … WHERE grade = 2 |
index |
idx_dept_grade | 17 | NULL | Using where; Using index |
key_len16 是dept的長度(CHAR(4),utf8mb4 每字最多 4 位元組),17 是再加上grade(TINYINT 1 位元組), 所以從 key_len 可以看出複合索引用到了幾個欄位- 只給
grade時key是 NULL,印證了最左前綴規則 - 最後一列很有意思:只需要
dept、grade兩個欄位時,雖然不能在索引裡「查找」,MySQL 還是選擇讀完整個索引(index), 因為索引比表格小
想一想:讀懂 EXPLAIN
一個查詢的 EXPLAIN 結果是 type: ALL、key: NULL、rows: 2500000。這代表什麼?
EXPLAIN 的 Extra 出現 Using index,表示什麼?
下面的連接查詢裡,department 那一列的 type 最可能是哪一個?
EXPLAIN SELECT e.name, d.name
FROM employee e JOIN department d ON e.departmentID = d.departmentID;
(d.departmentID 是 department 的主鍵)
讓索引失效的寫法
索引存的是「欄位原本的值,排好序」。條件的寫法如果讓 MySQL 無法利用這個順序,索引就用不上:
1. 對欄位套用函式或運算
WHERE UPPER(name) = 'BEN SMITH':索引裡存的是 name,不是 UPPER(name),MySQL 只能逐列算出 UPPER 再比較。
同理 WHERE YEAR(workdate) = 2003、WHERE employeeID + 1 = 7514 都會讓索引失效。改寫的方法是讓欄位保持原樣,把運算移到另一邊:
-- 失效:YEAR(workdate) = 2003
WHERE workdate >= '2003-01-01' AND workdate < '2004-01-01' -- workdate 有索引時就用得上
MySQL 預設的定序不分大小寫,所以 WHERE name = 'BEN SMITH' 本來就找得到 Ben Smith,根本不需要 UPPER。
2. 開頭是 % 的 LIKE
LIKE 'B%' 是「B 開頭」,在排好序的索引裡是一段連續的範圍(range);LIKE '%Smith' 是「Smith 結尾」,
可能散落在索引的任何位置,只能全部讀過。需要找文章中間的字詞,請用全文索引(第 09 課)。
3. 型別不一致
client 的 contactNumber 是 CHAR(12)。替它建立索引 idx_phone 後,在真的 MySQL 8.0.44 上比較兩種寫法:
| 條件 | type | possible_keys | key | Extra |
|---|---|---|---|---|
WHERE contactNumber = 95551234(數字) |
ALL |
idx_phone | NULL | Using where |
WHERE contactNumber = '95551234'(字串) |
ref |
idx_phone | idx_phone | Using index condition |
字串欄位和數字比較時,MySQL 要把每一列的字串轉成數字再比('095551234'、'95551234 ' 都等於 95551234),
索引的排序就派不上用場。執行 SHOW WARNINGS 會看到:
Cannot use ref access on index 'idx_phone' due to type or collation conversion on field 'contactNumber'。
反過來,整數欄位和字串常數比較(WHERE employeeID = '7513')仍然可以用索引,因為只要把常數轉換一次。
原則是:常數的型別和欄位一致。
型別不一致的例子播放器會顯示成 ref,和真實 MySQL 不同,請以上表為準。
同一個索引,不同的寫法
四個查詢都想找 Ben Smith,name 也都有索引。比較哪些寫法讓 key 變成 NULL。UPPER 那一個查詢的 filtered,真的 MySQL 8.0.44 顯示 100.00,播放器的估計是 25.00,請以 type 與 key 為主。
BENCHMARK、EXPLAIN FORMAT=TREE 與 EXPLAIN ANALYZE
BENCHMARK(次數, 運算式) 把一個運算式重複計算很多次,結果一律傳回 0,重點是 mysql 用戶端顯示的執行時間:
SELECT BENCHMARK(1000000, 6*9); -- 6*9 算 100 萬次,看 (0.01 sec) 這類時間
它適合比較兩個運算式或函式的速度,例如兩種字串處理寫法。原始課程把整個 SELECT 寫成字串放進 BENCHMARK:
BENCHMARK(10000000, 'select employee.name … '),這只是重複計算一個字串常數,並不會執行那個查詢,量不到查詢的速度。
要比較查詢,直接執行它看時間、用下面的 EXPLAIN ANALYZE,或看慢查詢日誌。
播放器的直譯器不實際計時,BENCHMARK 傳回 0,時間一律顯示 0.00 sec。
EXPLAIN FORMAT=TREE(MySQL 8.0.16 起)用樹狀文字顯示執行計畫,每一步附上優化器估計的成本(cost)與列數。 EXPLAIN ANALYZE(MySQL 8.0.18 起)會真的執行查詢,在估計值旁邊加上實際花費的時間(毫秒)、實際列數與執行次數(loops), 可以檢查估計準不準。因為會真的執行,慢的查詢也會一樣慢。
下面是在一台測試用的 MySQL 8.0.44 上,employee 有 idx_name 索引時的輸出(成本與時間每台機器都不同;敘述以 \G 結尾時,mysql 用戶端把結果改成一欄一行的直式顯示,長的文字比較好讀):
mysql> EXPLAIN FORMAT=TREE SELECT * FROM employee WHERE name = 'Ben Smith'\G
*************************** 1. row ***************************
EXPLAIN: -> Index lookup on employee using idx_name (name='Ben Smith') (cost=0.35 rows=1)
mysql> EXPLAIN ANALYZE SELECT * FROM employee WHERE name = 'Ben Smith'\G
*************************** 1. row ***************************
EXPLAIN: -> Index lookup on employee using idx_name (name='Ben Smith') (cost=0.35 rows=1) (actual time=0.0196..0.0207 rows=1 loops=1)
mysql> EXPLAIN FORMAT=TREE SELECT e.name, d.name
-> FROM employee e JOIN department d ON e.departmentID = d.departmentID\G
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join (cost=2.05 rows=4)
-> Table scan on e (cost=0.65 rows=4)
-> Single-row index lookup on d using PRIMARY (departmentID=e.departmentID) (cost=0.275 rows=1)
樹狀格式從最內層讀起:先 Table scan on e(全表掃描員工),對每一位員工 Single-row index lookup on d(用主鍵找部門,就是 eq_ref),
再由 Nested loop inner join 把兩邊接起來。播放器可以執行這兩種寫法,但只輸出簡化的近似結果,請在真的伺服器上觀察。
慢查詢日誌
EXPLAIN 要先知道「哪個查詢慢」。網站上線後,查詢來自程式的各個角落,慢查詢日誌(slow query log)
會把執行時間超過 long_query_time 秒的查詢記到一個檔案裡,這些查詢就是最值得最佳化的對象。
| 系統變數 | 意思 | 預設值 |
|---|---|---|
slow_query_log |
是否開啟慢查詢日誌 | OFF |
slow_query_log_file |
日誌檔案的位置 | 資料目錄下的 主機名稱-slow.log |
long_query_time |
超過幾秒算慢,可以有小數 | 10 |
log_queries_not_using_indexes |
沒有用索引的查詢也記錄 | OFF |
原始課程使用的 log-slow-queries=檔名 選項是 MySQL 5.1 以前的寫法,已經移除,MySQL 8 改用上表的變數。
Windows 的 MySQL 安裝程式通常預設就開啟慢查詢日誌;Ubuntu 的 mysql-server 套件預設關閉。下面的導覽在 Ubuntu 上操作。
導覽:開啟慢查詢日誌並找出慢的查詢
以下在 Ubuntu 24.04 的終端機執行,主機名稱是 web-01。sudo mysql -e "…" 用 root 執行一段 SQL 後就離開,也可以先 sudo mysql 登入,再輸入引號裡的 SQL。日誌內容裡的時間與數字是示意。
-
1
查看目前的設定
Ubuntu 預設沒有開啟,
long_query_time是 10 秒。系統變數的值可以用SHOW VARIABLES LIKE查詢,%是萬用字元。 -
2
寫進設定檔,重新啟動 MySQL
用編輯器開啟 MySQL 伺服器的設定檔,在
[mysqld]段落加入下面三行(檔案裡如果已經有被#註解的相同設定,拿掉#再修改數值):[mysqld] slow_query_log = 1 slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 2存檔後重新啟動 MySQL,再查一次確認。設定檔的寫法見第 12 課;Windows 的設定檔是
my.ini,寫法相同。 -
3
不重新啟動也能開啟
正在服務的伺服器不方便重新啟動時,可以用
SET GLOBAL立即開啟。注意兩件事:SET GLOBAL long_query_time只對之後才連線的工作階段生效;而且重新啟動 MySQL 後設定就消失,要保留請寫進設定檔,或改用SET PERSIST(第 12 課)。 -
4
製造一個慢查詢,查看日誌
SLEEP(3)讓查詢停 3 秒,超過 2 秒的門檻就會被記錄。日誌裡每一筆有:時間、哪個帳號從哪裡連線、Query_time(執行秒數)、Lock_time(等待鎖定的秒數)、Rows_sent(傳回列數)、Rows_examined(讀了幾列),以及查詢本身。Rows_examined遠大於Rows_sent的查詢,通常就是缺索引。 -
5
用 mysqldumpslow 彙整
日誌累積久了會很長。MySQL 附帶的
mysqldumpslow會把只差在數值的查詢歸成同一類(數字換成N、字串換成S),統計次數與時間;-s t依總時間排序(日誌很長時可以再加-t 10,只列前 10 名)。下面是又執行了一次SELECT SLEEP(2)之後的結果:兩次合計 2 次,平均 2.50 秒、總共 5 秒。找出排名前面的查詢,再用 EXPLAIN 分析。
想一想:讓索引發揮作用
employee 的 name 有索引。下面哪一個條件可以利用這個索引?
下面的查詢會傳回什麼?
SELECT BENCHMARK(1000000, 6*9);
member 表格的 phone 是 VARCHAR(20),而且有索引。下面的查詢在真的 MySQL 上會怎樣?
SELECT * FROM member WHERE phone = 912345678;
動手寫:讓查詢用上覆蓋索引
公司常查詢某種職稱有哪些員工編號。請替 employee 的 job 建立索引 idx_job,再用 EXPLAIN 分析 SELECT employeeID, job FROM employee WHERE job = 'Programmer'。執行後觀察:type 是什麼?為什麼 Extra 是 Using index(提示:次要索引帶著主鍵)?key_len 123 是怎麼算的?
重點整理
| 主題 | 重點 |
|---|---|
| EXPLAIN | 加在查詢前面,說明執行計畫,不真的執行 |
| type | const > eq_ref > ref > range > index > ALL;ALL 是全表掃描 |
| key、key_len | 實際用的索引與長度;複合索引看 key_len 知道用到幾個欄位 |
| rows、filtered | 估計值(播放器裡是示意) |
| Extra | Using index 覆蓋索引;Using where 再過濾;Using filesort 另外排序 |
| 讓索引失效 | 對欄位套用函式、開頭是 % 的 LIKE、型別不一致 |
| 慢查詢日誌 | slow_query_log、long_query_time、slow_query_log_file;mysqldumpslow 彙整 |
| BENCHMARK | 測運算式的速度,結果一律 0;不能用字串測查詢 |
| EXPLAIN ANALYZE | 真的執行,比較估計與實際 |
流程:慢查詢日誌找出慢的查詢 → EXPLAIN 看計畫 → 建立索引或改寫 → 再 EXPLAIN 確認。 下一課介紹檢視表:把常用的查詢存成一個名稱,像表格一樣使用。