你真的理解索引嗎?從資料結構層面解析mysql索引原理

六點半起床 發表於 2020-08-10

從《mysql儲存引擎InnoDB詳解,從底層看清InnoDB資料結構》中,我們已經知道了資料頁內各個記錄是按主鍵正序排列並組成了一個單向連結串列的,並且各個資料頁之間形成了雙向連結串列。在資料頁內,通過頁目錄,根據主鍵可以快速定位到一條記錄。這一章中,我們深入理解一下mysql索引實現。

本文主要內容是根據掘金小冊《從根兒上理解 MySQL》整理而來。如想詳細瞭解,建議購買掘金小冊閱讀。

索引資料結構

先回顧一下上一章節中資料頁基本結構
資料頁基本結構
從上圖可以推斷出,查詢某條記錄關鍵步驟只有2個:

  1. 定位到資料頁
  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_typenext_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已經存在多條記錄,資料頁結構如下所示:
索引實現
為了能夠使用二分法快速查詢資料頁,我們可以給每個資料頁建一個目錄項,每個目錄項主要包含兩部分資料:

  1. 頁的使用者記錄中最小的主鍵值,我們用key來表示。
  2. 頁號,我們用page_no表示。

索引實現
在mysql中,這些目錄項其實就是另一型別的資料記錄,稱為目錄項資料記錄(record_type=1),目錄項資料記錄也是儲存在中的,同一頁中的目錄項資料記錄也可以通過頁目錄快速定位。
索引實現

雖然目錄項記錄基本只儲存了主鍵值和頁號。但是當表中的資料很多時,一個資料頁肯定是無法儲存所有的目錄項記錄的。因此儲存目錄項記錄資料頁實際上可能有很多個。
索引實現
這個時候,我們就需要快速定位儲存目錄項記錄資料頁了。實際上,我們只需要生成更高階的目錄即可,同時保證最高一級的目錄項記錄資料頁只有一個。這樣就能根據主鍵從上到下快速定位到一條記錄了。
索引實現

實際上,上面的結構就是一顆B+樹。實際的使用者記錄其實都存放在B+樹的葉子節點上,而非葉子節點存放的是目錄項

聚簇索引

上面介紹的索引實際上就是聚簇索引,它有兩個特點:

  1. 使用主鍵值的大小進行記錄和頁的排序,這包括三個方面的含義:
    1. 頁內的記錄是按照主鍵的大小順序排成一個單向連結串列。
    2. 各個存放使用者記錄的頁也是根據頁中使用者記錄的主鍵大小順序排成一個雙向連結串列。
    3. 存放目錄項記錄的頁分為不同的層次,在同一層次中的頁也是根據頁中目錄項記錄的主鍵大小順序排成一個雙向連結串列。
  2. B+樹的葉子節點儲存的是完整的使用者記錄。

InnoDB儲存引擎會自動根據主鍵建立聚簇索引。同時,聚簇索引就是InnoDB儲存引擎中資料的儲存方式(所有的使用者記錄都儲存在了葉子節點),也就是所謂的索引即資料,資料即索引

二級索引

在實際場景中,我們更多的是為某個列建立二級索引。實際上,二級索引和聚簇索引實現的原理一樣的。主要的區別只有2個:

  1. 使用索引列的值的大小進行記錄和頁的排序。
  2. B+樹的葉子節點儲存的是對應記錄的主鍵值。

如圖是以c2列建立的二級索引:
二級索引

由於B+樹的葉子節點儲存的是對應記錄的主鍵值。如果我們要查詢完成記錄的話,在拿到主鍵之後,再需要再到聚簇索引中查出使用者記錄,這個過程也叫回表

聯合索引

在實際場景中,經常也出現為多個列建立一個索引的情況,這種索引也稱為聯合索引聯合索引本質上也是二級索引,區別僅僅在於由一個列變為多個列而已。簡單來說就是同時以多個列的大小作為排序規則,也就是同時為多個列建立索引。比如我們為c2c3列建立聯合索引:

  1. 先把各個記錄和頁按照c2列進行排序。
  2. 在記錄的c2列相同的情況下,採用c3列進行排序。

聯合索引

InnoDB的B+樹索引的注意事項

根節點不變性

上面介紹B+樹的時候,為了理解方便,採用自下而上的方式介紹。實際上,B+樹的形成過程如下:

  1. 每次為某個表建立B+索引的時候,都會為這個索引建立一個根節點頁面。當表中沒有記錄時,每個B+根節點既沒有使用者記錄,也沒有目錄項記錄。
  2. 隨後向表中插入使用者記錄時,先把使用者記錄儲存到根節點中。
  3. 當根節點空間用完後,再次插入資料。會將根節點資料複製到一個新頁中,再對這個新頁進行頁分裂操作。此時,根節點自動升級為儲存目錄項記錄的頁。

可以看出,一個B+樹索引的根節點自誕生之日起,便不會再移動

內節點中目錄項記錄的唯一性

我們知道B+樹索引的內節點中目錄項記錄的內容是索引列+頁號的搭配,但是這個搭配對於二級索引來說有點兒不嚴謹。為了保證內節點目錄項記錄的唯一性,目錄項還需要儲存主鍵值資料。也就是說,目錄項記錄的內容包含索引列的值主鍵值頁號
內節點中目錄項記錄的唯一性

MyISAM中的索引方案簡單介紹

我們知道InnoDB中索引即資料,也就是聚簇索引的那棵B+樹的葉子節點中已經把所有完整的使用者記錄都包含了,而MyISAM的索引方案雖然也使用樹形結構,但是卻將索引和資料分開儲存

  • MyISAM儲存引擎把記錄按照記錄的插入順序單獨儲存在資料檔案中。這個檔案並不劃分為若干個資料頁,有多少記錄就往這個檔案中塞多少記錄就成了。我們可以通過行號而快速訪問到一條記錄。
    MyISAM資料檔案
  • MyISAM儲存引擎會把索引資訊另外儲存到索引檔案中。MyISAM會單獨為表的主鍵建立一個索引,只不過在索引的葉子節點中儲存的不是完整的使用者記錄,而是主鍵值+行號的組合。也就是先通過索引找到對應的行號,再通過行號去找對應的記錄!
    這一點和InnoDB是完全不相同的,在InnoDB儲存引擎中,我們只需要根據主鍵值對聚簇索引進行一次查詢就能找到對應的記錄。而在MyISAM中卻需要進行一次回表操作,意味著MyISAM中建立的索引相當於全部都是二級索引
  • 如果有需要的話,我們也可以對其它的列分別建立索引或者建立聯合索引,原理和InnoDB中的索引差不多,不過在葉子節點處儲存的是相應的列+行號。這些索引也全部都是二級索引。

索引的使用

上面介紹了B+索引的原理,接下來介紹如何更好的使用索引。大家都知道索引不是建的越多越好,因為建立索引在空間上和時間上都會付出代價。

  1. 空間上的代價
    每建立一個索引,本質上就是要建立一個B+樹,建立索引肯定會佔用一部分儲存空間。
  2. 時間上的代價
    每次對錶中的資料進行增刪改操作時,都需要去修改各個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)
);

簡要說明一下:

  1. id列為主鍵,自動遞增。InnoDB會自動為id列建立聚簇索引。
  2. name, birthday, phone_number建立了一個聯合索引。所以這個二級索引的葉子節點包含了name, birthday, phone_numberid列的值。

下面,簡要畫一下idx_name_birthday_phone_number聯合索引的示意圖。
聯合索引的示意圖
從圖中可以看出,這個idx_name_birthday_phone_number索引對應的B+樹中頁面和記錄的排序方式就是這樣的:

  1. 先按照name列的值進行排序。
  2. 如果name列的值相同,則按照birthday列的值進行排序。
  3. 如果birthday列的值也相同,則按照phone_number的值進行排序。

全值匹配

全值匹配指的是搜尋條件中的列和索引列一致。比如:

SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239';

idx_name_birthday_phone_number聯合索引上進行全值匹配的查詢過程如下:

  1. 因為B+樹的資料頁和記錄先是按照name列的值進行排序的,所以先可以很快定位name列的值是Ashburn的記錄位置。
  2. name列相同的記錄裡又是按照birthday列的值進行排序的,所以在name列的值是Ashburn的記錄裡又可以快速定位birthday列的值是'1990-09-27'的記錄。
  3. 如果namebirthday列的值都是相同的,那記錄是按照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列排序的,所以我們上邊的查詢過程其實是這樣的:

  1. 找到name值為Asa的記錄。
  2. 找到name值為Barlow的記錄。
  3. 由於葉子節點記錄本身是一個連結串列,直接取出範圍之內的記錄。
  4. 回表查詢完整記錄。

精確匹配某一列並範圍匹配另外一列

對於同一個聯合索引來說,雖然對多個列都進行範圍查詢時只能用到最左邊那個索引列,但是如果左邊的列是精確查詢,則右邊的列可以進行範圍查詢,這種場景下依然會使用索引。

SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday > '1980-01-01' AND birthday < '2000-12-31' AND phone_number > '15100000000';

整個查詢過程大致如下:

  1. name = 'Ashburn',對name列進行精確查詢,當然可以使用B+樹索引了。
  2. birthday > '1980-01-01' AND birthday < '2000-12-31',由於name列是精確查詢,所以通過name = 'Ashburn'條件查詢後得到的結果的name值都是相同的,它們會再按照birthday的值進行排序。所以此時對birthday列進行範圍查詢是可以用到B+樹索引的。
  3. phone_number > '15100000000',通過birthday的範圍查詢的記錄的birthday的值可能不同,所以這個條件無法再利用B+樹索引了,只能遍歷上一步查詢得到的記錄。

用於排序

在實際業務場景中,經常需要對查詢出來的結果進行排序。一般情況下,只能將記錄全部載入到記憶體中(結果集太大可能使用磁碟存放中間結果),再使用排序演算法排序。這種在記憶體中或者磁碟上的排序方式統稱為檔案排序filesort,效能較差。但是如果order by子句使用到了索引列,就可能避免filesort。比如下面這個查詢語句:

SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10;

這個查詢結果依次按namebirthdayphone_number排序,而idx_name_birthday_phone_numberB+索引樹也剛好是按上述規則排好序的,因此只需要直接從索引中提取資料,然後回表即可。
需要注意的是,對於聯合索引來說,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子句中的列建立索引。而出現在查詢列表中的列就沒必要建立索引了。

考慮列的基數

列的基數指的是某一列中不重複資料的個數。,在記錄行數一定的情況下,列的基數越大,該列中的值越分散,列的基數越小,該列中的值越集中。因此推薦的方式是為那些列的基數大的列建立索引,為基數太小列的建立索引效果可能不好。

索引列的型別儘量小

在表示的整數範圍允許的情況下,儘量讓索引列使用較小的型別。原因如下:

  1. 資料型別越小,在查詢時進行的比較操作越快
  2. 資料型別越小,索引佔用的儲存空間就越少,在一個資料頁內就可以放下更多的記錄,從而減少磁碟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_colWHERE my_col * 2 < 4查詢是不會使用索引的,而WHERE my_col < 4/2能正常使用索引。

主鍵插入順序

我們知道,對於InnoDB來說,資料實際上是按主鍵大小正序儲存在聚簇索引的葉子節點上的。所以如果我們插入的記錄的主鍵值是依次增大的話,那我們每插滿一個資料頁就換到下一個資料頁繼續插入。而如果我們插入的主鍵值忽大忽小的話,就會造成頻繁的頁分裂,嚴重影響效能。因此,為了保證效能,需要保證主鍵是遞增的。

無法使用索引的幾種情況

  1. ORDER BY的子句後邊的列的順序也必須跟索引列的順序不一致。
  2. ASCDESC混用
  3. 排序列包含非同一個索引的列
  4. 排序列使用了複雜的表示式
  5. 索引列上使用函式(replace\SUBSTR\CONCAT\sum count avg)、表示式、 計算(+ - * /)
  6. like 條件中前面帶%
  7. 字串不加引號,出現隱式轉換

原創不易,覺得文章寫得不錯的小夥伴,點個贊👍 鼓勵一下吧~