從《mysql存儲引擎InnoDB詳解,從底層看清InnoDB數據結構》中,我們已經知道了數據頁內各個記錄是按主鍵正序排列並組成了一個單向鏈表的,並且各個數據頁之間形成了雙向鏈表。在數據頁內,通過頁目錄
,根據主鍵可以快速定位到一條記錄。這一章中,我們深入理解一下mysql索引實現。
本文主要內容是根據掘金小冊《從根兒上理解 MySQL》整理而來。如想詳細了解,建議購買掘金小冊閱讀。
索引數據結構
先回顧一下上一章節中數據頁基本結構
:
從上圖可以推斷出,查詢某條記錄關鍵步驟只有2個:
- 定位到數據頁
- 定位到記錄
如果沒有索引,查詢某條記錄只能先依次遍歷數據頁,確定記錄所在的數據頁之后;再從數據頁中通過頁目錄
定位到具體的記錄,這樣做效率肯定是很低的。
為了方便說明,先建一張示例表:
mysql> CREATE TABLE index_demo(
-> c1 INT,
-> c2 INT,
-> c3 CHAR(1),
-> PRIMARY KEY(c1)
-> ) ROW_FORMAT = Compact;
Query OK, 0 rows affected (0.03 sec)
為了展示便方便,行格式中只展示record_type
、next_record
和實際各列的值
。
把一些記錄放到頁里邊的示意圖就是:
上面提到過,數據頁中的記錄是按主鍵正序排列的。實際上就是為了能夠使用二分查找法快速定位一條記錄。同理,要想快速定位一個數據頁,也得保證各個數據頁是按順序排序的。排序的規則就是后一個數據頁的最小主鍵必須大於當前數據頁的最大主鍵。這樣實際上就保證了,所有記錄的主鍵都是正序排列的了。
頁分裂
假設每個數據頁最多只能存放3條記錄。現在index_demo
插入了3條記錄 (1, 4, 'u'), (3, 9, 'd'), (5, 3, 'y')
。
然后,再向index_demo
插入一條記錄(4, 4, 'a')
。由於每個數據頁最多只能存放3條記錄,並且還要保證所有記錄主鍵是按主鍵正序排列的。mysql會新建一個頁面(假設是頁28),然后將主鍵值為5的記錄移動到頁28中,最后再把主鍵值為4的記錄插入到頁10中。
簡單來說,當向一個已經存滿記錄的數據頁插入新記錄時,mysql會以新插入記錄的位置為界,把當前頁面分裂為2個頁面,最后再將新記錄插入進去。
mysql索引實現
假設index_demo
已經存在多條記錄,數據頁結構如下所示:
為了能夠使用二分法
快速查找數據頁,我們可以給每個數據頁建一個目錄項,每個目錄項主要包含兩部分數據:
- 頁的用戶記錄中最小的主鍵值,我們用
key
來表示。 - 頁號,我們用
page_no
表示。
在mysql中,這些目錄項其實就是另一類型的數據記錄,稱為目錄項數據記錄
(record_type=1),目錄項數據記錄
也是存儲在頁
中的,同一頁中的目錄項數據記錄
也可以通過頁目錄
快速定位。
雖然目錄項記錄
基本只存儲了主鍵值和頁號。但是當表中的數據很多時,一個數據頁
肯定是無法保存所有的目錄項記錄
的。因此存儲目錄項記錄
的數據頁
實際上可能有很多個。
這個時候,我們就需要快速定位存儲目錄項記錄
的數據頁
了。實際上,我們只需要生成更高級的目錄即可,同時保證最高一級的目錄項記錄
的數據頁
只有一個。這樣就能根據主鍵從上到下快速定位到一條記錄了。
實際上,上面的結構就是一顆B+樹。實際的用戶記錄其實都存放在B+樹的葉子節點
上,而非葉子節點
存放的是目錄項。
聚簇索引
上面介紹的索引實際上就是聚簇索引,它有兩個特點:
- 使用主鍵值的大小進行記錄和頁的排序,這包括三個方面的含義:
- 頁內的記錄是按照主鍵的大小順序排成一個單向鏈表。
- 各個存放用戶記錄的頁也是根據頁中用戶記錄的主鍵大小順序排成一個雙向鏈表。
- 存放目錄項記錄的頁分為不同的層次,在同一層次中的頁也是根據頁中目錄項記錄的主鍵大小順序排成一個雙向鏈表。
- B+樹的葉子節點存儲的是完整的用戶記錄。
InnoDB存儲引擎會自動根據主鍵創建聚簇索引。同時,聚簇索引就是InnoDB存儲引擎中數據的存儲方式(所有的用戶記錄都存儲在了葉子節點),也就是所謂的索引即數據,數據即索引。
二級索引
在實際場景中,我們更多的是為某個列建立二級索引。實際上,二級索引和聚簇索引實現的原理一樣的。主要的區別只有2個:
- 使用
索引列的值
的大小進行記錄和頁的排序。 - B+樹的葉子節點存儲的是對應記錄的主鍵值。
如圖是以c2
列建立的二級索引:
由於B+樹的葉子節點存儲的是對應記錄的主鍵值。如果我們要查詢完成記錄的話,在拿到主鍵之后,再需要再到聚簇索引
中查出用戶記錄,這個過程也叫回表
。
聯合索引
在實際場景中,經常也出現為多個列建立一個索引的情況,這種索引也稱為聯合索引
。聯合索引
本質上也是二級索引,區別僅僅在於由一個列變為多個列而已。簡單來說就是同時以多個列的大小作為排序規則,也就是同時為多個列建立索引。比如我們為c2
和c3
列建立聯合索引:
- 先把各個記錄和頁按照c2列進行排序。
- 在記錄的c2列相同的情況下,采用c3列進行排序。
InnoDB的B+樹索引的注意事項
根節點不變性
上面介紹B+樹的時候,為了理解方便,采用自下而上的方式介紹。實際上,B+樹的形成過程如下:
- 每次為某個表創建
B+
索引的時候,都會為這個索引創建一個根節點頁面。當表中沒有記錄時,每個B+根節點既沒有用戶記錄,也沒有目錄項記錄。 - 隨后向表中插入用戶記錄時,先把用戶記錄存儲到根節點中。
- 當根節點空間用完后,再次插入數據。會將根節點數據復制到一個新頁中,再對這個新頁進行
頁分裂
操作。此時,根節點自動升級為存儲目錄項記錄的頁。
可以看出,一個B+樹索引的根節點自誕生之日起,便不會再移動。
內節點中目錄項記錄的唯一性
我們知道B+樹索引的內節點中目錄項記錄的內容是索引列+頁號的搭配,但是這個搭配對於二級索引來說有點兒不嚴謹。為了保證內節點目錄項記錄的唯一性,目錄項還需要存儲主鍵值數據。也就是說,目錄項記錄的內容包含索引列的值
、主鍵值
和頁號
。
MyISAM中的索引方案簡單介紹
我們知道InnoDB中索引即數據,也就是聚簇索引的那棵B+樹的葉子節點中已經把所有完整的用戶記錄都包含了,而MyISAM的索引方案雖然也使用樹形結構,但是卻將索引和數據分開存儲:
MyISAM存儲引擎
把記錄按照記錄的插入順序單獨存儲在數據文件
中。這個文件並不划分為若干個數據頁,有多少記錄就往這個文件中塞多少記錄就成了。我們可以通過行號而快速訪問到一條記錄。
MyISAM存儲引擎
會把索引信息另外存儲到索引文件
中。MyISAM
會單獨為表的主鍵創建一個索引,只不過在索引的葉子節點中存儲的不是完整的用戶記錄,而是主鍵值+行號的組合。也就是先通過索引找到對應的行號,再通過行號去找對應的記錄!
這一點和InnoDB
是完全不相同的,在InnoDB存儲引擎中,我們只需要根據主鍵值對聚簇索引進行一次查找就能找到對應的記錄。而在MyISAM中卻需要進行一次回表操作,意味着MyISAM中建立的索引相當於全部都是二級索引!- 如果有需要的話,我們也可以對其它的列分別建立索引或者建立聯合索引,原理和InnoDB中的索引差不多,不過在葉子節點處存儲的是相應的列+行號。這些索引也全部都是二級索引。
索引的使用
上面介紹了B+索引的原理,接下來介紹如何更好的使用索引。大家都知道索引不是建的越多越好,因為創建索引在空間上和時間上都會付出代價。
- 空間上的代價
每創建一個索引,本質上就是要建立一個B+樹,創建索引肯定會占用一部分存儲空間。 - 時間上的代價
每次對表中的數據進行增刪改操作時,都需要去修改各個B+樹索引,而B+樹索引的記錄又是按照索引列的值
排序的。每次增刪改操作時,不可避免的會破壞原有記錄的順序,所以存儲引擎需要額外的時間來進行記錄移位、頁面分裂等操作來維護記錄的順序。
簡單來說,一張表的索引越多,占用的存儲空間也會越多,增刪改的性能會更差。
B+樹索引適用的條件
首先創建一張示例表person_info
,用來存儲人的一些基本信息。
CREATE TABLE person_info(
id INT NOT NULL auto_increment,
name VARCHAR(100) NOT NULL,
birthday DATE NOT NULL,
phone_number CHAR(11) NOT NULL,
country varchar(100) NOT NULL,
PRIMARY KEY (id),
KEY idx_name_birthday_phone_number (name, birthday, phone_number)
);
簡要說明一下:
id
列為主鍵,自動遞增。InnoDB會自動為id列建立聚簇索引。- 為
name
,birthday
,phone_number
建立了一個聯合索引。所以這個二級索引的葉子節點包含了name
,birthday
,phone_number
和id
列的值。
下面,簡要畫一下idx_name_birthday_phone_number
聯合索引的示意圖。
從圖中可以看出,這個idx_name_birthday_phone_number
索引對應的B+樹中頁面和記錄的排序方式就是這樣的:
- 先按照
name
列的值進行排序。 - 如果
name
列的值相同,則按照birthday
列的值進行排序。 - 如果
birthday
列的值也相同,則按照phone_number
的值進行排序。
全值匹配
全值匹配指的是搜索條件中的列和索引列一致。比如:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239';
在idx_name_birthday_phone_number
聯合索引上進行全值匹配的查詢過程如下:
- 因為B+樹的數據頁和記錄先是按照
name
列的值進行排序的,所以先可以很快定位name列的值是Ashburn
的記錄位置。 - 在
name
列相同的記錄里又是按照birthday
列的值進行排序的,所以在name
列的值是Ashburn
的記錄里又可以快速定位birthday
列的值是'1990-09-27'的記錄。 - 如果
name
和birthday
列的值都是相同的,那記錄是按照phone_number列的值排序的,所以聯合索引中的三個列都可能被用到。
聯合索引最左匹配
其實在搜索語句中不用包含全部聯合索引的列,只包含左邊的列也能夠使用索引,這就是聯合索引的最左匹配原則。比如:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27';
如果我們想使用聯合索引中盡可能多的列,搜索條件中的各個列必須是聯合索引中從最左邊連續的列。
前綴匹配
對於字符串類型的索引列來說,我們只匹配它的前綴也是可以快速定位記錄的。因為字符串比較本質上按一個一個字符比較得出的,也就是說這些字符串的前n個字符,也就是前綴都是排好序的。比如:
SELECT * FROM person_info WHERE name LIKE 'As%';
但是如果只給出后綴或者中間的某個字符串,是無法使用索引的,比如這樣:%As
或者%As%
。如果實際場景中碰到要以字符串后綴查詢數據的話,可以考慮逆序存儲
,將后綴匹配轉化為前綴匹配。
范圍匹配
因為索引B+樹是按照索引列大小排序的,因此按索引列范圍查詢可以快速查詢出數據記錄。比如:
SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow';
由於B+樹中的數據頁和記錄是先按name
列排序的,所以我們上邊的查詢過程其實是這樣的:
- 找到name值為
Asa
的記錄。 - 找到name值為
Barlow
的記錄。 - 由於葉子節點記錄本身是一個鏈表,直接取出范圍之內的記錄。
- 回表查詢完整記錄。
精確匹配某一列並范圍匹配另外一列
對於同一個聯合索引來說,雖然對多個列都進行范圍查找時只能用到最左邊那個索引列,但是如果左邊的列是精確查找,則右邊的列可以進行范圍查找,這種場景下依然會使用索引。
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday > '1980-01-01' AND birthday < '2000-12-31' AND phone_number > '15100000000';
整個查詢過程大致如下:
name = 'Ashburn'
,對name
列進行精確查找,當然可以使用B+樹索引了。birthday > '1980-01-01' AND birthday < '2000-12-31'
,由於name
列是精確查找,所以通過name = 'Ashburn'
條件查找后得到的結果的name值都是相同的,它們會再按照birthday
的值進行排序。所以此時對birthday
列進行范圍查找是可以用到B+樹索引的。phone_number > '15100000000'
,通過birthday
的范圍查找的記錄的birthday
的值可能不同,所以這個條件無法再利用B+樹索引了,只能遍歷上一步查詢得到的記錄。
用於排序
在實際業務場景中,經常需要對查詢出來的結果進行排序。一般情況下,只能將記錄全部加載到內存中(結果集太大可能使用磁盤存放中間結果),再使用排序算法排序。這種在內存中或者磁盤上的排序方式統稱為文件排序filesort
,性能較差。但是如果order by
子句使用到了索引列,就可能避免filesort
。比如下面這個查詢語句:
SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10;
這個查詢結果依次按name
、birthday
和phone_number
排序,而idx_name_birthday_phone_number
B+索引樹也剛好是按上述規則排好序的,因此只需要直接從索引中提取數據,然后回表即可。
需要注意的是,對於聯合索引來說,ORDER BY
的子句后邊的列的順序也必須跟索引列的順序一致,否則排序的時候就無法使用索引了。
用於分組
有時候我們為了方便統計表中的一些信息,會把表中的記錄按照某些列進行分組。比如下邊這個分組查詢:
SELECT name, birthday, phone_number, COUNT(*) FROM person_info GROUP BY name, birthday, phone_number
和使用B+樹索引進行排序是一個道理,分組列的順序也需要和索引列的順序一致,也可以只使用索引列中左邊的列進行分組。
覆蓋索引
上面提到到,所謂回表就是在二級索引中獲取到主鍵id集合之后,再分別到聚簇索引查詢出完整記錄,簡單來說就是一次二級索引查詢,多次聚簇索引回表。這意味着二級索引命中的主鍵記錄越多,需要回表的記錄也會也多,整體的性能就會越低。因此某些查詢,寧可使用全表掃描也不使用二級索引。
為了更好的使用二級索引+回表
的方式進行查詢,一般推薦使用limit
限制要查詢的記錄,這樣回表
的次數也能得到控制。
為了徹底告別回表操作帶來的性能損耗,建議:在查詢列表里只包含索引列,比如這樣:
SELECT name, birthday, phone_number FROM person_info WHERE name > 'Asa' AND name < 'Barlow'
因為只查詢name
, birthday
, phone_number
這三個索引列的值,所以就沒必要進行回表操作了。我們把這種只需要用到索引的查詢方式稱為覆蓋索引。
如何挑選索引
上面主要介紹了索引的適用場景,接下來我們介紹下建立索引時或者編寫查詢語句時就應該注意的一些事項。
只為用於搜索、排序或分組的列創建索引
只為出現在WHERE
子句中的列、連接子句中的連接列,或者出現在ORDER BY或GROUP BY子句中的列創建索引。而出現在查詢列表中的列就沒必要建立索引了。
考慮列的基數
列的基數
指的是某一列中不重復數據的個數。,在記錄行數一定的情況下,列的基數越大,該列中的值越分散,列的基數越小,該列中的值越集中。因此推薦的方式是為那些列的基數大的列建立索引,為基數太小列的建立索引效果可能不好。
索引列的類型盡量小
在表示的整數范圍允許的情況下,盡量讓索引列使用較小的類型。原因如下:
- 數據類型越小,在查詢時進行的比較操作越快
- 數據類型越小,索引占用的存儲空間就越少,在一個數據頁內就可以放下更多的記錄,從而減少磁盤I/O帶來的性能損耗,也就意味着可以把更多的數據頁緩存在內存中,從而加快讀寫效率。
使用前綴索引
當字段值比較長的時候,建立索引會消耗很多的空間,搜索起來也會很慢。我們可以通過截取字段的前面一部分內容建立索引,這個就叫前綴索引。
例如:創建一張商戶表,因為地址字段比較長,在地址字段上建立前綴索引:
create table shop(address varchar(120) not null);
問題是,截取多少呢?截取得多了,達不到節省索引存儲空間的目的,截取得少了, 重復內容太多,字段的基數會降低。實際場景中,可以通過不同長度的基數與總記錄數據基數的比值,選擇一個較為合理的截取長度。
select count(distinct left(address,10))/count(*) as sub10,
count(distinct left(address,11))/count(*) as sub11,
count(distinct left(address,12))/count(*) as sub12,
count(distinct left(address,13))/count(*) as sub13
from shop;
避免索引列字段參與計算
如果索引列在比較表達式中不是以單獨列的形式出現,而是以某個表達式,或者函數調用形式出現的話,是用不到索引的。
比如有一個整數列my_col
,WHERE my_col * 2 < 4
查詢是不會使用索引的,而WHERE my_col < 4/2
能正常使用索引。
主鍵插入順序
我們知道,對於InnoDB來說,數據實際上是按主鍵大小正序存儲在聚簇索引的葉子節點上的。所以如果我們插入的記錄的主鍵值是依次增大的話,那我們每插滿一個數據頁就換到下一個數據頁繼續插入。而如果我們插入的主鍵值忽大忽小的話,就會造成頻繁的頁分裂
,嚴重影響性能。因此,為了保證性能,需要保證主鍵是遞增的。
無法使用索引的幾種情況
ORDER BY
的子句后邊的列的順序也必須跟索引列的順序不一致。ASC
、DESC
混用- 排序列包含非同一個索引的列
- 排序列使用了復雜的表達式
- 索引列上使用函數
(replace\SUBSTR\CONCAT\sum count avg)、表達式、 計算(+ - * /)
- like 條件中前面帶%
- 字符串不加引號,出現隱式轉換
原創不易,覺得文章寫得不錯的小伙伴,點個贊👍 鼓勵一下吧~