圖解MySQL索引(三)—如何正確使用索引?

浪人~發表於2020-06-18

MySQL使用了B+Tree作為底層資料結構,能夠實現快速高效的資料查詢功能。工作中可怕的是沒有建立索引,比這更可怕的是建好了索引又沒有使用到。
本文將圍繞著如何優雅的使用索引,圖文並茂地和大家一起探討索引的正確開啟姿勢,不談底層原理,只求工作實戰。

1. 索引的特點

page之間是雙連結串列形式,而每個page內部的資料則是單連結串列形式存在。當進行資料查詢時,會限定位到具體的page,然後在page中通過二分查詢具體的記錄。

並且索引的順序不同,資料的儲存順序則也不同。所以在開發過程中,一定要注意索引欄位的先後順序。

最左匹配原則

當一個索引中包含多個欄位時,可以稱之為組合索引。MySQL中有個很重要的規則,即最左匹配原則用來定義組合索引的命中規則,它是指在檢索資料時從聯合索引的最左邊開始匹配。假設對使用者表建立一個聯合索引(a,b,c),那麼條件a,(a,b),(a,b,c)都會用到索引。

在匹配過程中會優先根據最左前面的欄位a進行匹配,然後再判斷是否用到了索引欄位b,直到無法找到對應的索引欄位,或者對應的索引被”破壞“(下文中會介紹)。

以下是本文中操作實踐用到的初始化語句,有條件的同學可以再本地執行,建議使用MySQL5.6+版本,畢竟實操才是學習的最佳途徑。

SET NAMES utf8mb4;
-- ----------------------------
-- Table structure for test_table
-- ----------------------------
DROP TABLE IF EXISTS `test_table`;
CREATE TABLE `test_table` (
  `id` bigint(20unsigned NOT NULL AUTO_INCREMENT,
  `a` varchar(255COLLATE utf8mb4_bin NOT NULL,
  `b` varchar(255COLLATE utf8mb4_bin NOT NULL,
  `c` varchar(255COLLATE utf8mb4_bin NOT NULL,
  `d` varchar(255COLLATE utf8mb4_bin NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_a_b_c` (`a`,`b`,`c`)
ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

-- ----------------------------
-- Records of test_table
-- ----------------------------
BEGIN;
INSERT INTO `test_table` VALUES 
(1'zhangsan''12222222222''23''aafasd'),
(2'lisi''13333333333''21''cxvcxv'),
(3'wanger''14444444444''24''dfdf'),
(4'liqiang''18888888888''18''ccsdf');
COMMIT;

2. 正確建立索引

儘量使用自增長主鍵

使用自增長主鍵的原因筆者認為有兩個。首先能有效減少頁分裂,MySQL中資料是以頁為單位儲存的且每個頁的大小是固定的(預設16kb),如果一個資料頁的資料滿了,則需要分成兩個頁來儲存,這個過程就叫做頁分裂。

如果使用了自增主鍵的話,新插入的資料都會盡量的往一個資料頁中寫,寫滿了之後再申請一個新的資料頁寫即可(大多數情況下不需要分裂,除非父節點的容量也滿了)。

自增主鍵

非自增主鍵

其次,對於快取友好。系統分配給MySQL的記憶體有限,對於資料量比較多的資料庫來說,通常只有一小部分資料在記憶體中,而大多數資料都在磁碟中。如果使用無序的主鍵,則會造成隨機的磁碟IO,影響系統效能。

選擇性高的列優先

關注索引的選擇性。索引的選擇性,也可稱為資料的熵。在建立索引的時候通常要求將選擇性高的列放在最前面,對於選擇性不高的列甚至可以不建立索引。如果選擇性不高,極端性情況下可能會掃描全部或者大多數索引,然後再回表,這個過程可能不如直接走主鍵索引效能高。

索引列的選擇往往需要根據具體的業務場景來選擇,但是需要注意的是索引的區分度越高則價值就越高,意味著對於檢索的價效比就高。索引的區分度等於count(distinct 具體的列) / count(*),表示欄位不重複的比例。

唯一鍵的區分度是1,而對於一些狀態值,性別等欄位區分度往往比較低,在資料量比較大的情況下,甚至有無限接近0。假設一張表中用data_status來表示資料的狀態,1-有效,2-刪除,則資料的區分度為 1/500000。如果100萬條資料中只有1條被刪除,並且在查詢資料時查詢data_status = 0 的資料時,需要進行全表掃描。由於索引也是需要佔用記憶體的,所以在記憶體較為有限的環境下,區分度不高的索引幾乎沒有意義。

聯合索引優先於多列獨立索引

聯合索引優先於多列獨立索引, 假設有三個欄位a,b,c, 索引(a)(a,b),(a,b,c)可以使用(a,b,c)代替。MySQL中的索引並不是越多越好,各個公司的規定中往往會限制單表中的索引的個數。原因在於,索引本身也會佔用一定的空間,並且維護一個索引時有一定的程式碼的,所以在滿足需求的情況下一定要儘可能建立更少的索引。

執行語句:

explain select * from test_table where a = "zhangsan";
explain select * from test_table where a = "zhangsan" and b = "188466668888";
explain select * from test_table where a = "zhangsan" and b = "188466668888" and c = "23";

執行結果分析:

實際上建立(a, b, c)聯合索引時,其作用相當於(a), (a, b), (a, b, c) 三個索引。所以以上三種查詢方式均會命中索引。

覆蓋索引避免回表

覆蓋索引如果執行的語句是 select ID from T where k between 3 and 5,這時只需要查 ID 的值,而 ID 的值已經在 k 索引樹上了,因此可以直接提供查詢結果,不需要回表。也就是說,在這個查詢裡面,索引 k 已經“覆蓋了”我們的查詢需求,我們稱為覆蓋索引。由於覆蓋索引可以減少樹的搜尋次數,顯著提升查詢效能,所以使用覆蓋索引是一個常用的效能優化手段。

覆蓋索引的查詢優化

覆蓋索引同時還會影響索引的選擇,對於(a,b,c)索引來說,理論上來說不滿足最左匹配原則,但是實際上也會走索引。原因在於,優化器認為(a,b,c)索引的效能會高於全表掃描,實際情況也是這樣的,感興趣的小夥伴不妨分析一下上文中介紹的資料結構。

explain select a,b,c from test_table where b = "188466668888" and c = "23";

執行結果:

滿足查詢和排序

索引要滿足查詢和排序。大部分同學在建立索引時,通常第一反應是查詢條件來選擇索引列,需要注意的是查詢和排序同樣重要,我們建立的索引要同時滿足查詢和排序的需求.

包含要排序的列

select c, d from test_table  where a = 1 and b = 2 order by c;

雖然查詢條件只使用了a,b兩個欄位,但是由於排序用到了c欄位,我們能可以建立(a,b,c)聯合索引來進行優化。

保證索引欄位順序

如上文中的介紹,索引的欄位順序決定了索引資料的組織順序。要想更高效能的檢索資料,一定要儘可能的藉助底層資料結構的特點來進行。如,索引(a, b)的預設組織形式就是先根據a排序,在a相同的情況下再根據b排序。

考慮索引的大小

記憶體中的空間十分寶貴,而索引往往又需要在記憶體中。為了在有限的記憶體中儲存更多的索引,在設計索引時往往要考慮索引的大小。比如我們常用的郵箱,xxxx@xx.com, 假設都是abc公司的,則郵箱字尾完全一致為@abc.com, 索引的區分度完全取決於@前面的字串。

針對上述情況,MySQL 是支援字首索引的,也就是說,你可以定義字串的一部分作為索引。預設地,如果你建立索引的語句不指定字首長度,那麼索引就會包含整個字串。

如果使用的 email 整個字串的索引結構執行順序是這樣的:從 index1 索引樹找到滿足索引值是’liqiang156@11.com’的這條記錄,取得 id (主鍵)的值ID2;到主鍵上查到主鍵值是ID2的行,將這行記錄加入結果集;

取 email 索引樹上剛剛查到的位置的下一條記錄,發現已經不滿足 email='liqiang156@qq.com’的條件了,迴圈結束。這個過程中,只需要回主鍵索引取一次資料,所以系統認為只掃描了一行。但是它的問題就是索引的後半部分都是重複的,浪費記憶體。

這時我們可以考慮使用字首索引,如果使用的是 index2 (email(7) 索引結構),執行順序是這樣的:從 index2 索引樹找到滿足索引值是’liqiang’的記錄,找到的第一個是 ID1,到主鍵上查到主鍵值是 ID1 的行,判斷出 email 的值是’liqiang156@xxx.com’,加入結果集。

取 index2 上剛剛查到的位置的下一條記錄,發現仍然是’liqiang’,取出 ID2,再到 ID 索引上取整行然後判斷,這次值仍然不對,則丟棄繼續往下取。
重複上一步,直到在 index2 上取到的值不是’liqiang’或者索引搜尋完畢之後,迴圈結束。在這個過程中,要回主鍵索引取 4 次資料,也就是掃描了 4 行。通過這個對比,你很容易就可以發現,使用字首索引後,可能會導致查詢語句讀資料的次數變多。

不過方法總比困難多,我們在建立索引時可以先通過語句檢視一下索引的區分度,或者提前預估餘下字首長度,對於上述問題我們可以將字首長度調整為9即可達到效果。索引,在使用字首索引時,一定要充分考慮資料的特徵,選擇合適的

對於一些比較長的欄位的等值查詢,我們也可以採用其他方式來縮短索引的長度。比如url一般都是比較長,我們可以冗餘一列儲存其Hash值

 select field_list from t where id_card_crc=crc32('input_id_card_string'and id_card='input_id_card_string'

對於我們國家的身份證號,一共 18 位,其中前 6 位是地址碼,所以同一個縣的人的身份證號前 6 位一般會是相同的。為了提高區分度,我們可以將身份證號碼倒序儲存

 select field_list from t where id_card = reverse('input_id_card_string');

3. 正確使用索引

建立合適的索引是前提,想要取得理想的查詢效能,還應保證能夠用到索引。避免索引失效即是優化。

不在索引上進行任何操作

索引上進行計算,函式,型別轉換等操作都會導致索引從當前位置(聯合索引多個欄位,不影響前面欄位的匹配)失效,可能會進行全表掃描。

explain select * from test_table where upper(a) = "ZHANGSAN" 

對於需要計算的欄位,則一定要將計算方法放在“=”後面,否則會破壞索引的匹配,目前來說MySQL優化器不能對此進行優化。

explain select * from test_table where a = lower("ZHANGSAN")

隱式型別轉換

需要注意的是,在查詢時一定要注意欄位型別問題,比如a欄位時字串型別的,而匹配引數用的是int型別,此時就會發生隱式型別轉換,相當於相當於在索引上使用函式。

explain select * from test_table where a = 1;

a是字串型別,然後使用int型別的1進行匹配,此時就發生了隱式型別轉換,破壞索引的使用。

只查詢需要的列

在日常開發中很多同學習慣使用 select * … 來構建查詢語句,這種做法也是極不推薦的。主要原因有兩個,首先查詢無用的列在資料傳輸和解析繫結過程中會增加網路IO,以及CPU的開銷,儘管往往這些消耗可以被忽略,但是我們也要避免埋坑。

explain select a,b,c from test_table where a="zhangsan" and b = "188466668888" and c = "23";

其次就是會使得覆蓋索引"失效", 這裡的失效並非真正的不走索引。覆蓋索引的本質就是在索引中包含所要查詢的欄位,而 select * 將使覆蓋索引失去意義,仍然需要進行回表操作,畢竟索引通常不會包含所有的欄位,這一點很重要。

explain select * from test_table where a="zhangsan" and b = "188466668888" and c = "23";

不等式條件

查詢語句中只要包含不等式,負向查詢一般都不會走索引,如 !=, <>, not in, not like等。

explain select * from test_table where a !="1222" and b="12222222222" and c = 23;
explain select * from test_table where a <>"1222" and b="12222222222" and c = 23;
explain select * from test_table where a not in ("xxxx");

模糊匹配查詢

最左字首在進行模糊匹配時,一般禁止使用%前導的查詢,如like “%zhangsan”。

explain select * from test_table where a like "zhangsan";
explain select * from test_table where a like "%zhangsan";
explain select * from test_table where a like "zhangsan%";

最左匹配原則

索引是有順序的,查詢條件中缺失索引列之後的其他條件都不會走索引。比如(a, b, c)索引,只使用b, c索引,就不會走索引。

explain select * from test_table where b = "188466668888" and c = "23";

如果索引從中間斷開,索引會部分失效。這裡的斷開指的是缺失該欄位的查詢條件,或者說滿足上述索引失效情況的任意一個。不過這裡的仍然會使用到索引,只不過只能使用到索引的前半部分。

explain select * from test_table where a="zhangsan" and b != 1 and c = "23"

值得注意的是,如果使用了不等式查詢條件,會導致索引完全失效。而上一個例子中即使用了不等式條件,也使用了隱式型別轉換卻能用到索引。

同理,根據最左字首匹配原則,以下如果使用b,c作為查詢條件則不會使用(a, b, c)索引。

執行語句:

explain select * from test_table where b = "188466668888" and c = "23";

執行結果:

索引下推

在說索引下推之前,我們先執行一下SQL。

執行語句:

explain select * from test_table where a = "zhangsan" and c = "23";

上述的最左字首匹配原則相信大家都能很容易的理解,那麼使用(a, c)條件查詢能夠利用(a, b, c)嗎?答案是肯定的,正如上圖所示。即使沒有索引下推也會會根據最左匹配原則,使用到索引中的a欄位。有了索引下推之後會增加查詢的效率。

在面試中通常會問到這樣一個問題,已知有索引(a,b,c)則根據條件(a,c)查詢時會不會走索引呢?答案是肯定的,但是是有版本限制的。

而 MySQL 5.6 引入的索引下推優化(index condition pushdown), 可以在索引遍歷過程中,對索引中包含的欄位先做判斷,直接過濾掉不滿足條件的記錄,減少回表次數,是對查詢的一種優化,感興趣的同學可以看一下官方說明https://dev.mysql.com/doc/refman/8.0/en/index-condition-pushdown-optimization.html。

上述是沒有索引下推,每次查詢完之後都會回表,取到對應的欄位進行匹配。


利用索引下推,每次儘可能在輔助索引中將不符合條件資料過濾掉。比如,索引中已經包含了name和age,索引不妨暫且忽略破壞索引匹配的條件直接匹配。

查詢優化-自適應索引順序

查詢時,mysql的優化器會優化sql的執行,即使查詢條件的順序沒有按照定義順序來使用,也是可以使用索引的。但是需要注意的是優化本身也會消耗一定的效能,所以還是推薦按照索引的定義來書寫sql。

explain select  * from test_table where b="12222222222" and a="zhangsan" and c = 23;
explain select  * from test_table where a="zhangsan" and b="12222222222" and c = 23;

4. 總結

索引並不是什麼高深的技術,從底層來看,不過是一個資料結構罷了。要想使用好索引,一定要先將B+Tree理解透徹,在此基礎上對於日常使用和麵試則是信手拈來。

脫離業務的設計都是耍流氓,技術的意義在於服務業務。所以,索引的設計需要充分考慮業務的需求與設計原則之間做一些取捨,滿足需求是基礎。

在工作中,各個公司的版本可能大不相同,會存在一些奇奇怪怪,不確定的問題。所以為了驗證索引的有效性,強烈推薦把主要的查詢sql都通過explain檢視一下執行計劃,是否會用到索引。

參考資料:
[1] 《MySQL 45講》—極客時間
[2] 《InnoDB儲存引擎》
[3] 《高效能MySQL》
[4] https://dev.mysql.com/doc/refman/8.0/en/

相關文章