National Taiwan University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

2

Local AI

--

Uedu Code

--

台灣大學 AQI 70 24°C PM2.5 17
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 17

查詢最佳化

用 EXPLAIN 看執行計畫,讓查詢用上索引
Interaction實作導覽 25 minute(s)

Learning Objectives

  • 在查詢前面加上 EXPLAIN,讀懂 type、possible_keys、key、key_len、rows 與 Extra
  • 比較建立索引前後的執行計畫,判斷查詢有沒有用上索引、是不是覆蓋索引
  • 辨認讓索引失效的寫法:對欄位套用函式、開頭是 % 的 LIKE、型別不一致,並改寫成能用索引的條件
  • 開啟慢查詢日誌,設定 long_query_time,用 mysqldumpslow 彙整結果
  • 說明 BENCHMARK、EXPLAIN FORMAT=TREE 與 EXPLAIN ANALYZE 的用途與限制

找出慢的查詢,再看它怎麼執行

上一課從設計著手:建立索引、選擇資料型別。但索引建了,查詢不一定用得上。查詢最佳化的流程是:

  1. 找出慢的查詢:開啟慢查詢日誌,記錄執行超過某個秒數的查詢
  2. 看它怎麼執行:在查詢前面加上 EXPLAIN,MySQL 會說明它打算怎麼找資料(執行計畫),但不真的執行
  3. 調整:建立或修改索引、改寫查詢,再用 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_len 16 是 dept 的長度(CHAR(4),utf8mb4 每字最多 4 位元組),17 是再加上 grade(TINYINT 1 位元組), 所以從 key_len 可以看出複合索引用到了幾個欄位
  • 只給 grade 時 key 是 NULL,印證了最左前綴規則
  • 最後一列很有意思:只需要 dept、grade 兩個欄位時,雖然不能在索引裡「查找」,MySQL 還是選擇讀完整個索引(index), 因為索引比表格小

想一想:讀懂 EXPLAIN

1.

一個查詢的 EXPLAIN 結果是 type: ALL、key: NULL、rows: 2500000。這代表什麼?

2.

EXPLAIN 的 Extra 出現 Using index,表示什麼?

3.

下面的連接查詢裡,department 那一列的 type 最可能是哪一個?

EXPLAIN SELECT e.name, d.name
FROM employee e JOIN department d ON e.departmentID = d.departmentID;

(d.departmentID 是 department 的主鍵)

第 1 次作答

讓索引失效的寫法

索引存的是「欄位原本的值,排好序」。條件的寫法如果讓 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. 1

    查看目前的設定

    Ubuntu 預設沒有開啟,long_query_time 是 10 秒。系統變數的值可以用 SHOW VARIABLES LIKE 查詢,% 是萬用字元。

  2. 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. 3

    不重新啟動也能開啟

    正在服務的伺服器不方便重新啟動時,可以用 SET GLOBAL 立即開啟。注意兩件事:SET GLOBAL long_query_time 只對之後才連線的工作階段生效;而且重新啟動 MySQL 後設定就消失,要保留請寫進設定檔,或改用 SET PERSIST(第 12 課)。

  4. 4

    製造一個慢查詢,查看日誌

    SLEEP(3) 讓查詢停 3 秒,超過 2 秒的門檻就會被記錄。日誌裡每一筆有:時間、哪個帳號從哪裡連線、Query_time(執行秒數)、Lock_time(等待鎖定的秒數)、Rows_sent(傳回列數)、Rows_examined(讀了幾列),以及查詢本身。Rows_examined 遠大於 Rows_sent 的查詢,通常就是缺索引。

  5. 5

    用 mysqldumpslow 彙整

    日誌累積久了會很長。MySQL 附帶的 mysqldumpslow 會把只差在數值的查詢歸成同一類(數字換成 N、字串換成 S),統計次數與時間;-s t 依總時間排序(日誌很長時可以再加 -t 10,只列前 10 名)。下面是又執行了一次 SELECT SLEEP(2) 之後的結果:兩次合計 2 次,平均 2.50 秒、總共 5 秒。找出排名前面的查詢,再用 EXPLAIN 分析。

這段導覽完成了

想一想:讓索引發揮作用

1.

employee 的 name 有索引。下面哪一個條件可以利用這個索引?

2.

下面的查詢會傳回什麼?

SELECT BENCHMARK(1000000, 6*9);
3.

member 表格的 phone 是 VARCHAR(20),而且有索引。下面的查詢在真的 MySQL 上會怎樣?

SELECT * FROM member WHERE phone = 912345678;
第 1 次作答

動手寫:讓查詢用上覆蓋索引

公司常查詢某種職稱有哪些員工編號。請替 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 確認。 下一課介紹檢視表:把常用的查詢存成一個名稱,像表格一樣使用。

這一課完成了!
Next lesson:檢視表
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。