Learning Objectives
- 用「書的索引」說明索引為什麼能加快查詢,說出 InnoDB 索引是 B-tree 結構
- 判斷哪些欄位適合建立索引,說出索引的代價(寫入變慢、佔用空間)
- 建立一般、唯一與複合索引,用 SHOW INDEX 觀察,並說明複合索引的最左前綴規則
- 說明 InnoDB 依主鍵排列資料(叢集索引),以及覆蓋索引會改變沒有 ORDER BY 時列的順序
- 依資料內容選擇合適的資料型別:整數大小、CHAR 與 VARCHAR、DECIMAL 與 FLOAT
- 說明正規化與反正規化的取捨,使用 ANALYZE TABLE 與 OPTIMIZE TABLE 維護表格
查詢變慢的常見原因
表格只有幾十列時,怎麼查都很快;資料累積到幾十萬、幾百萬列,同一個查詢可能要好幾秒。常見的原因有:
| 原因 | 說明 | 本課與下一課的做法 |
|---|---|---|
| 沒有合適的索引 | 每次查詢都要從第一列讀到最後一列 | 本課:建立索引;下一課:用 EXPLAIN 確認查詢有沒有用上 |
| 資料型別太大 | 每一列佔的空間大,同樣的記憶體與磁碟能放的列變少 | 本課:選擇合適的資料型別 |
| 表格設計 | 要查一筆資料得連接很多張表格,或同一份資料存了很多份 | 本課:正規化與反正規化 |
| 查詢寫法 | 寫法讓索引無法使用 | 下一課:查詢最佳化 |
| 網路與硬體 | 傳回太多資料、伺服器記憶體不足 | 只取需要的欄位與列;調整伺服器設定(不在本課範圍) |
這一課從資料庫的「設計」著手:索引、資料型別、表格結構與維護。下一課再從「查詢」著手。
索引像書後面的索引
一本 500 頁的書,想找「交易」出現在哪幾頁,有兩種方法:從第一頁翻到最後一頁,或翻到書後面的索引, 索引依筆畫或字母排好,很快就找到「交易:第 128、305 頁」,再直接翻過去。
資料庫的索引(index)是同一個想法:把某個欄位的值排好序另外存一份,每個值旁邊記著「這筆資料在哪裡」。
沒有索引時,WHERE name = 'Ben Smith' 只能逐列檢查整張表格,稱為全表掃描(full table scan);
有了 name 的索引,MySQL 在排好序的索引裡很快找到 Ben Smith,再去取那一列。
MySQL 8 預設的儲存引擎 InnoDB,索引是 B-tree(精確地說是 B+tree)結構:
- 樹的每個節點存放一段排好序的值,從樹根往下比較,每一層都能排除大部分的資料
- 資料量增加很多倍,樹也只會多一兩層,所以就算表格很大,找一個值也只需要讀少數幾個節點
- 最底層的節點依序串在一起,所以範圍查詢(
BETWEEN、>、LIKE 'B%')與排序也能利用索引
索引的種類:
| 種類 | 建立方式 | 特性 |
|---|---|---|
主鍵 PRIMARY KEY |
建表時 PRIMARY KEY (欄位) |
值不可重複、不可以是 NULL;一張表格只能有一個 |
唯一索引 UNIQUE |
CREATE UNIQUE INDEX 名稱 ON 表格 (欄位) |
值不可重複,可以有多個 NULL |
一般索引 INDEX |
CREATE INDEX 名稱 ON 表格 (欄位) |
沒有限制,只為了加快查詢 |
| 複合索引 | CREATE INDEX 名稱 ON 表格 (欄位1, 欄位2, …) |
一個索引包含多個欄位 |
全文索引 FULLTEXT |
CREATE FULLTEXT INDEX 名稱 ON 表格 (欄位) |
用在文章內容的關鍵字搜尋(第 09 課) |
CREATE INDEX 名稱 ON 表格 (欄位) 和 ALTER TABLE 表格 ADD INDEX 名稱 (欄位) 效果相同。
外鍵欄位如果還沒有索引,MySQL 會自動替它建立一個;下面的範例裡,employee 的 departmentID 索引就是這樣來的。第 04 課建表時已經用過 PRIMARY KEY、UNIQUE 與 INDEX,這一課說明它們的原理與取捨。
建立索引,用 SHOW INDEX 觀察
SHOW INDEX FROM 表格 列出表格的所有索引,每個欄位一列。注意 Key_name(索引名稱)、Non_unique(0 表示值不可重複)、Seq_in_index(欄位在索引裡的順序)、Column_name。最後兩個查詢都沒有寫 ORDER BY,請比較它們列出的順序。
叢集索引與覆蓋索引
上面最後兩個查詢的順序不同,原因在 InnoDB 存放資料的方式:
- 叢集索引(clustered index):InnoDB 的資料列本身就依主鍵排列,存在主鍵的 B-tree 裡。
所以
SELECT * FROM employee讀出來的列,依employeeID由小到大。播放器右邊的表格也依主鍵排序,就是這個原因 - 次要索引(secondary index,主鍵以外的索引):存的是「索引欄位的值+那一列的主鍵」。
用
idx_name找到Ben Smith後,拿到主鍵 9842,再回主鍵的 B-tree 取出整列 - 覆蓋索引(covering index):查詢需要的欄位全部都在某個索引裡(次要索引本來就帶著主鍵),MySQL 只讀索引就能回答, 不必回表格取資料,比較快。這時列的順序就是索引的順序
表格沒有主鍵時,InnoDB 會改用第一個所有欄位都是 NOT NULL 的唯一索引當叢集索引;連這種索引都沒有,就自動產生一個看不到的列編號。 所以每張表格都應該定義主鍵,而且主鍵最好短:每個次要索引都帶著一份主鍵,主鍵越長,所有索引都跟著變大。
第 06 課說過「沒有 ORDER BY 時列的順序不保證」,這裡就是實例:多建一個索引,同一個查詢的順序就可能改變。 程式需要固定順序時,一定要寫 ORDER BY。
複合索引與最左前綴
複合索引把幾個欄位依序組成一個索引,例如 (dept, grade):先依 dept 排序,dept 相同時再依 grade 排序,
就像電話簿先依姓排、同姓再依名排。
電話簿可以很快找到「姓陳的」,也可以很快找到「姓陳、名叫小明的」,但沒辦法直接找到「所有名叫小明的人」, 因為名字只在同一個姓之內才是排好的。複合索引也一樣,這叫做最左前綴(leftmost prefix)規則:
| WHERE 條件 | 能不能用 (dept, grade) 索引 |
|---|---|
dept = 'CS' |
可以,用到第一個欄位 |
dept = 'CS' AND grade = 2 |
可以,兩個欄位都用到 |
grade = 2 AND dept = 'CS' |
可以,條件的書寫順序不重要,MySQL 會自己對應 |
grade = 2 |
不能用來查找,因為跳過了最左邊的 dept |
所以設計複合索引時,最常單獨出現在條件裡的欄位放最前面。有了 (dept, grade),就不需要另外建 (dept) 的索引。
下一課會用 EXPLAIN 實際驗證這張表。
唯一索引除了加快查詢,也是一種資料規則:值重複時 INSERT 會失敗(ERROR 1062)。
學號、帳號、身分證字號這類不可重複的欄位,除了主鍵以外,都可以用唯一索引保護。
複合索引與唯一索引
執行前已建立 school 資料庫與 12 位學生的 student 表格(主鍵 studentID)。SHOW INDEX 裡,複合索引 idx_dept_grade 佔兩列,Seq_in_index 是 1 和 2。第二個查詢只要 dept、grade、studentID,三個都在 idx_dept_grade 裡(studentID 是主鍵,次要索引本來就帶著),所以列依 dept、再依 grade、再依 studentID 排列。
哪些欄位適合建索引
索引不是越多越好。每個索引都要另外佔空間,而且每次 INSERT、DELETE,以及修改到索引欄位的 UPDATE, MySQL 都要同時更新每一個相關的索引,索引越多,寫入越慢。
適合建立索引:
- 經常出現在
WHERE條件裡的欄位 - 多表查詢中用來連接的欄位、外鍵(MySQL 會自動替外鍵建立索引)
- 經常用來排序(
ORDER BY)、分組(GROUP BY)的欄位 - 不同的值很多的欄位,例如帳號、學號、姓名。索引能排除的資料越多,效果越好
不適合建立索引:
- 很少出現在查詢條件裡的欄位
- 不同的值很少的欄位,例如只有 0 和 1 的狀態欄位,一個值就對應一半的資料,用索引不比全表掃描快多少
- 表格只有幾十、幾百列,全表掃描就很快
- 非常頻繁更新的欄位,或大量新增、刪除的表格,要考慮維護索引的成本
建立索引之後,要用 EXPLAIN 確認查詢真的用上了(下一課)。用不到的索引只會拖慢寫入,應該刪除:
DROP INDEX idx_name ON employee;
ALTER TABLE employee DROP INDEX idx_name; -- 效果相同
索引的欄位建好之後不能修改,要換欄位就刪除後重建。MySQL 8 可以改名,也可以先把索引設為看不見(invisible): 優化器不再使用它,但索引仍然存在、仍會被更新,觀察一陣子確定沒有查詢變慢再刪除;需要時隨時改回 VISIBLE,不必重建:
ALTER TABLE employee RENAME INDEX idx_name TO idx_employee_name;
ALTER TABLE employee ALTER INDEX idx_employee_name INVISIBLE;
ALTER TABLE employee ALTER INDEX idx_employee_name VISIBLE;
(播放器的直譯器沒有支援看不見的索引,這兩行請在真的 MySQL 8 上練習;SHOW INDEX 的 Visible 欄位會變成 NO。)
想一想:索引
替 employee 的 name 建立索引之後,執行下面的查詢(沒有 ORDER BY),會得到哪一位?
CREATE INDEX idx_name ON employee (name);
SELECT name FROM employee LIMIT 1 OFFSET 1;
(員工依 employeeID 排列是 6651 Ajay Patel、7513 Nora Edwards、9006 Candy Burnett、9842 Ben Smith)
student 表格有複合索引 (dept, grade)。下面哪一個條件不能利用這個索引查找?
下面哪一個欄位最不值得單獨建立索引?
選擇合適的資料型別
資料型別越小,每一列越短,同樣大小的記憶體與磁碟頁(InnoDB 預設一頁 16 KB)就能放更多列,索引也越小。 原則是:選擇能正確存放資料的最小型別。
整數:依數值範圍選擇
| 型別 | 位元組 | 範圍(有正負號) | UNSIGNED 範圍 |
|---|---|---|---|
TINYINT |
1 | -128~127 | 0~255 |
SMALLINT |
2 | -32768~32767 | 0~65535 |
MEDIUMINT |
3 | 約 ±838 萬 | 0~16777215 |
INT |
4 | 約 ±21 億 | 0~4294967295 |
BIGINT |
8 | 約 ±9.2×10¹⁸ | 0~約 1.8×10¹⁹ |
例如 1 到 10 的評分用 TINYINT UNSIGNED。超出範圍時,MySQL 8 預設的嚴格模式會直接拒絕(ERROR 1264),不會偷偷改成最大值。
數字型的代碼也要想清楚:學號、員工編號用 INT 比用 VARCHAR 小,比較也快;
但電話號碼、郵遞區號可能以 0 開頭,也不會拿來做加減,應該用字串。
CHAR 與 VARCHAR
CHAR(n):固定長度,存入時在右邊補空白,取出時把尾端空白去掉VARCHAR(n):存多長就是多長,另外用 1~2 個位元組記錄長度,尾端空白會保留
原始課程建議「選固定長度的 CHAR,不用 VARCHAR、TEXT、BLOB」,這是 MyISAM 時代的做法:
MyISAM 表格所有欄位都是固定長度時,每一列一樣長,讀寫比較快。MySQL 8 預設的 InnoDB 不靠這個加速,
而且 utf8mb4 的 CHAR 在 InnoDB 裡也不一定佔固定長度。現在的原則是:長度固定的代碼(州別 VA、國家代碼 TW)用 CHAR,
長度會變的文字(姓名、地址)用 VARCHAR,長度上限設成合理的值就好。
DECIMAL 與 FLOAT
DECIMAL(p, s):精確的十進位數,DECIMAL(8,2)是共 8 位數、小數 2 位。金額一定用 DECIMALFLOAT(4 位元組)、DOUBLE(8 位元組):近似值,0.1 這種數在二進位裡無法精確表示,加總與比較會出現誤差。 適合科學量測這類本來就有誤差的資料
資料型別的差異
執行前已建立空的 shop 資料庫。先比較 FLOAT 與 DECIMAL:同樣存三個 0.1,加總與比較的結果不同。再看 TINYINT 的範圍與 CHAR、VARCHAR 對尾端空白的處理(用中括號把值框起來,才看得到空白)。
正規化與反正規化
第 03 課的正規化把資料拆成多張表格,每項事實只存一次:員工表格只存 departmentID,部門名稱存在部門表格。
好處是修改部門名稱只要改一列,不會出現同一個部門有兩種名稱的矛盾;表格也比較窄,通常有助於效能。
但查詢時要用 JOIN 把資料接回來,表格拆得越細(4NF、BCNF、5NF),一個查詢要連接的表格越多。 如果某個查詢非常頻繁、又要連接很多張表格,可以考慮反正規化(denormalization):刻意保留一些重複的資料,換取查詢速度。常見的做法:
| 做法 | 例子 | 代價 |
|---|---|---|
| 多存一個欄位 | 訂單表格多存「下單時的會員名稱」,列表頁不必連接會員表格 | 會員改名時,舊訂單的名稱要不要跟著改,要事先決定 |
| 存統計結果 | 文章表格多一個 reply_count,不必每次 COUNT(*) 回覆表格 |
新增、刪除回覆時要同步更新(程式裡處理,或用觸發程序 trigger) |
| 拆開很寬的表格 | 上百個欄位的表格,把很少用的欄位移到另一張表格 | 用到那些欄位時要多一次 JOIN |
反正規化是先量測、確定瓶頸之後才做的取捨,不是設計的起點:先做好正規化與索引,用 EXPLAIN 和慢查詢日誌找到真正慢的查詢,再決定。
維護表格:ANALYZE 與 OPTIMIZE
MySQL 決定要不要用某個索引時,依據的是統計資訊,例如 SHOW INDEX 的 Cardinality(不同值個數的估計)。
InnoDB 不是每次都數過所有資料,而是抽樣幾個資料頁估計;資料大量變動後,統計資訊可能跟實際情況差很多,導致 MySQL 選錯執行方式。
| 指令 | 作用 | 什麼時候用 |
|---|---|---|
ANALYZE TABLE 表格; |
重新抽樣,更新索引的統計資訊 | 大量匯入或刪除資料之後;EXPLAIN 的估計列數明顯不合理時 |
OPTIMIZE TABLE 表格; |
重建表格,回收刪除資料後留下的空間、整理索引 | 刪除了大量資料、表格檔案明顯比資料大時 |
InnoDB 預設會在一張表格大約一成的資料變動後自動重算統計資訊,所以平常不需要手動執行。
對 InnoDB 表格執行 OPTIMIZE TABLE 時,MySQL 會回應一則 note:Table does not support optimize, doing recreate + analyze instead,
意思是 InnoDB 用「重建表格再分析」的方式完成,這是正常的,最後一列的 status OK 表示成功。
重建大表格要花時間、佔用額外的磁碟空間,請在離峰時段執行。
執行 ANALYZE TABLE 與 OPTIMIZE TABLE
兩個指令都以表格形式回報結果:Table 是哪張表格,Op 是哪個操作,Msg_type 與 Msg_text 是訊息。最後建立再刪除一個索引,練習 DROP INDEX 的寫法。
想一想:資料型別與維護
p 表格的欄位 f 是 FLOAT,存了三列 0.1。下面的查詢會得到什麼?
SELECT SUM(f) FROM p;
欄位 a 是 TINYINT,在 MySQL 8 預設設定下執行 INSERT INTO p VALUES (300); 會怎樣?
一張 InnoDB 表格剛刪除了一半的資料。關於 OPTIMIZE TABLE,哪一個說法正確?
動手寫:建立複合索引
公司常查詢「某個部門裡某種職稱的員工」,也常只依職稱查詢。請替 employee 建立複合索引 idx_job_dept,欄位是 job 和 departmentID(想一想哪個欄位放前面),再用 SHOW INDEX 列出 employee 的所有索引。
重點整理
| 主題 | 重點 |
|---|---|
| 索引 | 排好序的 B-tree,避免全表掃描;CREATE INDEX、DROP INDEX、SHOW INDEX |
| 適合建索引 | 常用在 WHERE、JOIN、ORDER BY、GROUP BY,而且不同值多的欄位 |
| 索引的代價 | 佔空間;每次寫入都要更新所有索引 |
| 複合索引 | 最左前綴:(a, b) 可以查 a、a 和 b,不能只查 b |
| 叢集索引 | InnoDB 的資料依主鍵排列;次要索引帶著主鍵,所以主鍵要短 |
| 覆蓋索引 | 需要的欄位都在索引裡,只讀索引;沒有 ORDER BY 時順序會跟著索引改變 |
| 資料型別 | 能正確存放的最小型別;固定長度代碼用 CHAR;金額用 DECIMAL,不用 FLOAT |
| 表格設計 | 先正規化,量測後才針對瓶頸反正規化 |
| 維護 | ANALYZE TABLE 更新統計資訊;OPTIMIZE TABLE 重建表格回收空間 |
下一課用 EXPLAIN 看 MySQL 實際怎麼執行一個查詢,確認索引有沒有用上。