圖解|用好MySQL索引,你需要知道的一些事情
我是蟬沐風。
這一篇文章來聊一聊如何用好MySQL索引。
為了更好地進行解釋,我建立了一個儲存引擎為InnoDB的表user_innodb
,並批量初始化了500W+條資料。包含主鍵id
、姓名欄位(name
)、性別欄位(gender
,用0,1表示不同性別)、手機號欄位(phone
),併為name
和phone
欄位建立了聯合索引。
CREATE TABLE `user_innodb` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `gender` tinyint(1) DEFAULT NULL, `phone` varchar(11) DEFAULT NULL, PRIMARY KEY (`id`), INDEX IDX_NAME_PHONE (name, phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
1. 索引的代價
索引可以非常有效地提升查詢效率,既然這麼好,我給每個欄位都建立一個索引行不行?我勸你不要衝動。
任何事情都有兩面,索引也不例外。過度使用索引,我們在空間和時間上都會付出相應的代價。
1.1 空間上的代價
索引就是一棵B+數,每建立一個索引都需要建立一棵B+樹,每一棵B+樹的節點都是一個數據頁,每一個數據頁預設會佔用16KB的磁碟空間,每一棵B+樹又會包含許許多多的資料頁。所以,大量建立索引,你的磁碟空間會被迅速消耗。
1.2 時間上的代價
空間上的代價你可以使用“鈔能力”來解決,但時間上的代價我們可能就束手無策了。
連結串列的維護
我以主鍵索引為例舉個例子,主鍵索引的B+樹的每一個節點內的記錄都是按照主鍵值由小到大的順序,採用單向連結串列的方式進行連線的。如下圖所示:
如果我現在要刪除主鍵id
為1的記錄,會破壞3個數據頁內的記錄排序,需要對這3個數據頁內的記錄進行重排列,插入和修改操作也是同理。
注:這裡給大家提一嘴,其實刪除操作並不會立即進行資料頁內記錄的重排列,而是會給被刪除的記錄打上一個刪除的標識,等到合適的時候,再把記錄從連結串列中移除,但是總歸需要涉及到排序的維護,勢必要消耗效能。
假如這張表有12個欄位,我們為這張表的12個欄位都設定了索引,我們刪除1條記錄,需要涉及到12棵B+樹的N個數據頁內記錄的排序維護。
更糟糕的是,你增刪改記錄的時候,還可能會觸發資料頁的回收和分裂。還是以上圖為例,假如我刪除了id
為13的記錄,那麼資料頁124
就沒有存在的必要了,會被InnoDB儲存引擎回收;我插入一條id
資料頁32
的空間不足以儲存該記錄,InnoDB又需要進行頁面分裂。我們不需要知道頁面回收和頁面分裂的細節,但是能夠想象到這個操作會有多複雜。
如果每個欄位都建立索引,所有這些索引的維護操作帶來的效能損耗,你能想象了吧。
查詢計劃
執行查詢語句之前,MySQL查詢優化器會基於cost成本對一條查詢語句進行優化,並生成一個執行計劃。如果建立的索引太多,優化器會計算每個索引的搜尋成本,導致在分析過程中耗時太多,最終影響查詢語句的執行效率。
2. 回表的代價
2.1 什麼是回表
我再囉嗦一遍什麼是回表,我們可以通過二級索引找到B+樹中的葉子結點,但是二級索引的葉子節點的內容並不全,只有索引列的值和主鍵值。我們需要拿著主鍵值再去聚簇索引(主鍵索引)的葉子節點中去拿到完整的使用者記錄,這個過程叫做回表。
上圖中我以name
二級索引為例,並且只畫出了二級索引的葉子節點和聚簇索引的葉子節點,省略了兩棵B+樹的非葉子節點。
從二級索引的葉子節點延伸出的3條線表示的就是回表操作。
2.2 回表的代價
我們根據name
欄位查詢二級索引的葉子節點的代價還是比較小的,原因有二:
- 葉子節點所在的頁通過雙向連結串列進行關聯,遍歷的速度比較快;
- MySQL會盡量讓同一個索引的葉子節點的資料頁在磁碟空間中相鄰,盡力避免隨機IO。
但是二級索引葉子節點中的主鍵id的排布就沒有任何規律了,畢竟name
索引是對name
欄位進行排序的。進行回表的時候,極有可能出現主鍵id
所在的記錄在聚簇索引葉子節點中反覆橫跳的情況(正如上圖中回表的3條線表示的那樣),也就是隨機IO。如果目標資料頁恰好在記憶體中的話效果倒也不會太差,但如果不在記憶體中,還要從磁碟中載入一個數據頁的內容(16KB)到記憶體中,這個速度可就太慢了。
是不是說完了回表的代價之後,我會給出一種更高效的搜尋方式?不是,回表已經是一種比較高效的搜尋方式了,我們需要做的就是儘量地減少回表操作帶來的損耗,總結起來就是兩點:
- 能不回表就不回;
- 必須回表就減少回表的次數。
接下來先給大家介紹兩個與回表相關的重要概念,這兩個概念涉及到的方法也是索引使用原則的一部分,因為比較重要,在這裡我把這兩個概念先解釋給大家聽。
3. 索引覆蓋、索引下推
3.1 索引覆蓋
想一下,如果非聚簇索引的葉子節點上有你想要的所有資料,是不是就不需要回表了呢?比如我為name
和phone
欄位建立了一個聯合索引,如下圖:
如果我們恰好只想搜尋name
、phone
以及主鍵欄位,
SELECT id, name, phone FROM user_innodb WHERE name = "蟬沐風";
可以直接從葉子節點獲取所有資料,根本不需要回表操作。
我們把索引中已經包含了所有需要讀取的列資料的查詢方式稱為覆蓋索引(或索引覆蓋)。
3.2 索引下推
3.2.1 概念
還是拿name
和phone
的聯合索引為例,我們要查詢所有name
為「蟬沐風」,並且手機尾號為6606的記錄,查詢SQL如下:
SELECT * FROM user_innodb WHERE name = "蟬沐風" AND phone LIKE "%6606";
由於聯合索引的葉子節點的記錄是先按照name
欄位排序,name
欄位相同的情況下再按照phone
欄位排序,因此把%
加在phone
欄位前面的時候,是無法利用索引的順序性來進行快速比較的,也就是說這條查詢語句中只有name
欄位可以使用索引進行快速比較和過濾。正常情況下查詢過程是這個樣子的:
-
InnoDB使用聯合索引查出所有
name
為蟬沐風的二級索引資料,得到3個主鍵值:3485,78921,423476; -
拿到主鍵索引進行回表,到聚簇索引中拿到這三條完整的使用者記錄;
-
InnoDB把這3條完整的使用者記錄返回給MySQL的Server層,在Server層過濾出尾號為6606的使用者。
如下面兩幅圖所示,第一幅圖表示InnoDB通過3次回表拿到3條完整的使用者記錄,交給Server層;第二幅圖表示Server層經過phone LIKE "%6606"
條件的過濾之後找到符合搜尋條件的記錄,返給客戶端。
值得我們關注的是,索引的使用是在儲存引擎中進行的,而資料記錄的比較是在Server層中進行的。現在我們把上述搜尋考慮地極端一點,假如資料表中10萬條記錄都符合name='蟬沐風'
的條件,而只有1條符合phone LIKE "%6606"
條件,這就意味著,InnoDB需要將99999條無效的記錄傳輸給Server層讓其自己篩選,更嚴重的是,這99999條資料都是通過回表搜尋出來的啊!關於回表的代價你已經知道了。
現在引入索引下推。準確來說,應該叫做索引條件下推(Index Condition Pushdown,ICP),就是過濾的動作由下層的儲存引擎層通過使用索引來完成,而不需要上推到Server層進行處理。ICP是在MySQL5.6之後完善的功能。
再回顧一下,我們第一步已經通過name = "蟬沐風"
在聯合索引的葉子節點中找到了符合條件的3條記錄,而且phone
欄位也恰好在聯合索引的葉子節點的記錄中。這個時候可以直接在聯合索引的葉子節點中進行遍歷,篩選出尾號為6606的記錄,找到主鍵值為78921的記錄,最後只需要進行1次回表操作即可找到符合全部條件的1條記錄,返回給Server層。
很明顯,使用ICP的方式能有效減少回表的次數。
另外,ICP是預設開啟的,對於二級索引,只要能把條件甩給下面的儲存引擎,儲存引擎就會進行過濾,不需要我們干預。
3.2.2 演示
檢視一下當前ICP的狀態:
SHOW VARIABLES LIKE 'optimizer_switch';
執行以下SQL語句,並用EXPLAIN
檢視一下執行計劃,此時的執行計劃是Using index condition
EXPLAIN SELECT * FROM user_innodb WHERE name = "蟬沐風" AND phone LIKE "%6606";
然後關閉ICP
SET optimizer_switch="index_condition_pushdown=off";
再檢視一下ICP的狀態
再次執行查詢語句,並用EXPLAIN檢視一下執行計劃,此時的執行計劃是Using where
EXPLAIN SELECT * FROM user_innodb WHERE name = "蟬沐風" AND phone LIKE "%6606";
注:即使滿足索引下推的使用條件,查詢優化器也未必會使用索引下推,因為可能存在更高效的方式。
由於之前我給
name
欄位建立了索引,導致一直沒有使用索引下推,EXPLAIN
語句顯示使用了name
索引,而不是name
和phone
的聯合索引;刪除name
索引之後,才獲得上述截圖的效果。大家做實驗的時候需要注意。
到目前為止大家應該清楚了索引和回錶帶來的效能問題,講這些自然不是為了恐嚇大家讓大家遠離索引,相反,我們要以正確的方式積極擁抱索引,最大限度降低其帶來的負面影響,放大其優勢。如何用好索引,從兩個方面考慮:
- 高效發揮已經建立的索引的作用(避免索引失效)
- 為合適的列建立合適的索引(索引建立原則)
4. 什麼時候索引會失效?
4.1 違反最左字首原則
拿我們文章開始建立的聯合索引為例,該聯合索引的B+樹資料頁內的記錄首先按照name
欄位進行排序,name
欄位相同的情況下,再按照phone
欄位進行排序。
所以,如果我們直接使用phone
欄位進行搜尋,無法利用索引的順序性。
EXPLAIN SELECT * FROM user_innodb WHERE phone = "13203398311";
EXPLAIN
可以檢視搜尋語句的執行計劃,其中,possible_keys
列表示在當前查詢中,可能用到的索引有哪一些;key
列表示實際用到的索引有哪一些。
但是一旦加上name
的搜尋條件,就會使用到聯合索引,而且不需要在意name
在WHERE
子句中的位置,因為查詢優化器會幫我們優化。
EXPLAIN SELECT * FROM user_innodb WHERE phone = "13203398311" AND name = '蟬沐風';
4.2 使用反向查詢(!=, <>,NOT LIKE)
MySQL在使用反向查詢(!=, <>, NOT LIKE)的時候無法使用索引,會導致全表掃描,覆蓋索引除外。
EXPLAIN SELECT * FROM user_innodb WHERE name != '蟬沐風';
4.3 LIKE以萬用字元開頭
當使用name LIKE '%沐風'
或者name LIKE '%沐%'
這兩種方式都會使索引失效,因為聯合索引的B+樹資料頁內的記錄首先按照name
欄位進行排序,這兩種搜尋方式不在意name
欄位的開頭是什麼,自然就無法使用索引,只能通過全表掃描的方式進行查詢。
EXPLAIN SELECT * FROM user_innodb WHERE name LIKE '%沐風';
但是使用萬用字元結尾就沒有問題
EXPLAIN SELECT * FROM user_innodb WHERE name LIKE '蟬沐%';
4.4 對索引列做任何操作
如果不是單純使用索引列,而是對索引列做了其他操作,例如數值計算、使用函式、(手動或自動)型別轉換等操作,會導致索引失效。
4.4.1 使用函式
EXPLAIN SELECT * FROM user_innodb WHERE LEFT(name,3) = '蟬沐風';
MySQL8.0新增了函式索引的功能,我們可以給函式作用之後的結果建立索引,使用以下語句
ALTER TABLE user_innodb ADD KEY IDX_NAME_LEFT ((left(name,3)));
再次執行EXPLAIN
語句,此時索引生效
4.4.2 使用表示式
EXPLAIN SELECT * FROM user_innodb WHERE id + 1 = 1100000;
換一種方式,單獨使用id
,就能高效使用索引:
EXPLAIN SELECT * FROM user_innodb WHERE id = 1100000 - 1;
4.4.3 使用型別轉換
例1
user_innodb
中的phone
欄位為varchar
型別,實驗之前我們先給phone
欄位建立個索引
ALTER TABLE user_innodb ADD INDEX IDX_PHONE (phone);
隨便搜尋一個存在的手機號,看一下索引是否成功
EXPLAIN SELECT * FROM user_innodb WHERE phone = '13203398311';
可以看到能使用到索引,現在我們稍微修改一下,把phone = '13203398311'
修改為phone = 13203398311
,這意味著我們將字串的搜尋條件改成了整形的搜尋條件,再看一下還會不會使用到索引:
EXPLAIN SELECT * FROM user_innodb WHERE phone = 13203398311;
顯示索引失效。
例2
我們再看一個例子,主鍵id
型別是bigint
,但是在搜尋條件中我估計使用字串型別:
EXPLAIN SELECT * FROM user_innodb WHERE id = '1099999';
總結
稍微總結一下這個問題,當索引欄位型別為字串時,使用數字型別進行搜尋不會用到索引;而索引欄位型別為數字型別時,使用字串型別進行搜尋會使用到索引。
要搞明白這個問題,我們需要知道MySQL的資料型別轉換規則是什麼。簡單地說就是MySQL會自動將數字轉化為字串,還是將字串轉化為數字。
一個簡單的方法是,通過SELECT '10' > 9
的結果來確定MySQL的型別轉換規則:
- 結果為1,說明MySQL會自動將字串型別轉化為數字,相當於執行了
SELECT 10 > 9
; - 結果為0,說明MySQL會自動將數字轉化為字串,相當於執行了
SELECT '10' > '9'
。
mysql> SELECT '10' > 9;
+----------+
| '10' > 9 |
+----------+
| 1 |
+----------+
1 row in set (0.00 sec)
上面的執行結果為1,說明MySQL遇到型別轉換時,會自動將字串轉換為數字型別,因此對於例1:
EXPLAIN SELECT * FROM user_innodb WHERE phone = 13203398311;
就相當於
EXPLAIN SELECT * FROM user_innodb WHERE CAST(phone AS signed int) = 13203398311;
也就是對索引欄位使用了函式,按照前文的介紹,對索引使用函式是不會使用到索引的。
對於例2:
EXPLAIN SELECT * FROM user_innodb WHERE id = '1099999';
就相當於
EXPLAIN SELECT * FROM user_innodb WHERE id = CAST('1099999' AS unsigned int);
沒有在索引欄位新增任何操作,因此能夠使用到索引。
4.5 OR連線
使用OR
連線的查詢語句,如果OR
之前的條件列是索引列,但是OR
之後的條件列不是索引列,則不會使用索引。舉例:
EXPLAIN SELECT * FROM user_innodb WHERE id = 1099999 OR gender = 0;
上面總結了一些索引失效的場景,這些經驗的總結往往對SQL的優化很有益處,但同時需要注意的是這些經驗並非金科玉律。
比如使用<>
查詢時,在某些時候是可以用到索引的:
EXPLAIN SELECT * FROM user_innodb WHERE id <> 1099999;
最終是否使用索引,完全取決於MySQL的優化器,而優化器的判定依據就是cost開銷(Cost Base Optimizer),優化器並非基於具體的規則,也不是基於語義,就是單純地執行開銷小的方案罷了。所以在·EXPLAIN·的結果中你會看到possible_keys
一列,優化器會把這裡邊的索引都試一遍(是不是又加深了對不能隨便建立索引的認識呢?),然後選一個開銷最小的,如果都不太行,那就直接全表掃描好了。
而cost開銷,和資料庫版本、資料量等都有關係,因此如果想更精準地提升索引功能性,擁抱EXPLAIN
吧!
5. 索引建立(使用)原則
之前講過的索引覆蓋和索引下推都可以作為索引建立的原則,就是在建立索引的時候,儘量發揮索引覆蓋和索引下推的優勢。
儘量避免上述提及到的索引可能失效的情況的出現,同樣是索引的使用原則。
除此之外,再給大家介紹一些。
5.1 不為離散度高的列建立索引
先來看一下列的離散度公式:COUNT(DISTINCT(column_name)) / COUNT(*)
,列的不重複值的個數與所有資料行的比例。簡而言之,如果列的重複值越多,列的離散度越低。重複值越少,離散度就越高。
舉個例子,gender
(性別)列只有0、1兩個值,列的離散度非常低,假如我們為該列建立索引,我們會在二級索引中搜索到大量的重複資料,然後進行大量回表操作。大量回表哈?你懂了吧。
不要為重複值多的列建立索引
5.2 只為用於搜尋、排序或分組的列建立索引
我們只為出現在WHERE
子句中的列或者出現在ORDER BY
和GROUP BY
子句中的列建立索引即可。僅出現在查詢列表中的列不需要建立索引。
5.3 用好聯合索引
用2條SQL語句來說明這個問題:
1. SELECT * FROM user_innodb WHERE name = '蟬沐風' AND phone = '13203398311';
2. SELECT * FROM user_innodb WHERE name = '蟬沐風';
語句1和語句2都能夠使用索引,這帶給我們的一個索引設計原則就是:
不要為聯合索引的第一個索引列單獨建立索引
因為聯合索引本身就是先按照name
列進行排序,因此聯合索引對name
的搜尋是有效的,不需要單獨為name
再建立索引了。也正因為此
建立聯合索引的時候,一定要把最常用的列放在最左邊
5.4 對過長的欄位,建立字首索引
如果一個字串格式的列佔用的空間比較大(就是說允許儲存比較長的字串資料),為該列建立索引,就意味著該列的資料會被完整地記錄在每個資料頁的每條記錄中,會佔用相當大的儲存空間。
對此,我們可以為該列的前幾個字元建立索引,也就是在二級索引的記錄中只會保留字串的前幾個字元。比如我們可以為phone
列建立索引,索引只保留手機號的前3位:
ALTER TABLE user_innodb ADD INDEX IDX_PHONE_3 (phone(3));
然後執行下面的SQL語句:
EXPLAIN SELECT * FROM user_innodb WHERE phone = '1320';
由於在IDX_PHONE_3
索引中只保留了手機號的前3位數字,所以我們只能定位到以132開頭的二級索引記錄,然後在遍歷所有的這些二級索引記錄時再判斷它們是否滿足第4位數為0的條件。
當列中儲存的字串包含的字元較多時,為該欄位建立字首索引可以有效節省磁碟空間
5.5 頻繁更新的值,不要作為主鍵或索引
因為可能涉及到資料頁分裂的情況,會影響效能。
5.6 隨機無序的值,不建議作為索引,例如身份證、UUID
具體原因我在圖解|12張圖解釋MySQL主鍵查詢為什麼這麼快文章中講過,感興趣可以閱讀一下。
6. 推薦閱讀
我是蟬沐風,如果你覺得這篇文章寫得不錯,請點贊和評論,你們的支援對我非常重要!我們下期見!