1. 程式人生 > 實用技巧 >儲存引擎系列(三):不同型別的資料庫索引 B+ 樹是如何維護的

儲存引擎系列(三):不同型別的資料庫索引 B+ 樹是如何維護的

上篇教程學院君給大家介紹了 MySQL 資料庫索引的底層資料結構 —— B+ 樹,今天我們來看看不同型別的資料庫索引是如何構建對應的 B+ 樹的。

我們知道資料庫索引通常分為主鍵索引、唯一索引、普通索引和聯合索引,不同索引對應的 B+ 樹儲存資料是不一樣的。

主鍵索引

通常我們會將一張表的 ID 欄位設定為主鍵索引,比如下面這個建立資料表 posts 的 SQL 語句:

1
CREATE TABLE `posts` (
2
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
3
  `title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
4
  `content` text COLLATE utf8mb4_unicode_ci NOT NULL,
5
  `user_id` bigint(20) unsigned NOT NULL,
6
  `created_at` datetime DEFAULT NULL,
7
  `updated_at` datetime DEFAULT NULL,
8
  PRIMARY KEY (`id`)
9
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

我們通過 PRIMARY KEY (`id`)

設定 id 欄位為主鍵,並且該欄位通過 AUTO_INCREMENT 標記為自增欄位。

對於包含主鍵索引的資料表,當我們插入記錄到資料表時(對於自增欄位,不指定 ID 欄位值的情況下,系統會自動獲取當前 ID 最大值加 1 作為插入記錄的 ID 值),會先在當前主鍵索引對應 B+ 樹葉子節點最後一個數據頁中檢視是否還有剩餘空間,如果有的話,則插入到對應資料頁最後一條資料的後面(B+ 樹葉子節點中的資料記錄會按照索引欄位值升序排列,而主鍵 ID 是自增的,所以肯定是已存在記錄中最大的,前面資料頁定位的邏輯也是這樣),否則的話,需要新建立一個數據頁來儲存資料。

如果插入的記錄指定了 id 欄位值,並且這個 id

值不是當前資料記錄中最大的(資料表由於刪除過記錄存在空洞),則需要定位到要插入的資料頁和插入位置進行插入,如果對應資料頁沒有剩餘空間,則需要開闢新的資料頁,插入位置之後的資料記錄也要調整以便可以順利將待插入記錄插入進來(這個過程叫做頁分裂,顯然,頁分裂效能損耗較大,有頁分裂就有與之相對的頁合併,當刪除記錄較多,資料頁存在較多空洞時,就會進行頁合併操作),從而確保葉子節點裡的資料記錄是按照主鍵索引升序排列的。另外,儲存在葉子節點資料頁中的資料記錄顯然是一個單鏈表結構,這樣設計的好處是避免每次插入、刪除記錄需要移動該位置之後的所有記錄。

注:為了提升操作效率,資料庫插入記錄是在記憶體中進行的,這個我們在前面介紹日誌寫入的時候提到過,因此新增的記錄並沒有立即寫入到磁碟。

這裡可能有同學會疑惑,資料庫底層是按照什麼規則對索引欄位值進行排序的,這個時候,我們前面介紹的字符集和排序規則就派上用場了,MySQL 是按照索引欄位值對應字符集的排序規則對其進行升序排序的。

對於 InnoDB 主鍵索引對應的 B+ 樹而言,葉子節點中存放資料記錄的 data 部分存放的是完整的資料記錄(一條記錄的所有欄位資訊),因此我們也可以將主鍵索引稱之為聚簇索引,通過聚簇索引,可以直接獲取到完整的資料記錄,這就是所謂的索引即資料,資料即索引。

唯一索引和普通索引

對於唯一索引和普通索引這種非主鍵索引,在建立表時分別可以通過 UNIQUE KEYKEY 關鍵字進行設定:

1
CREATE TABLE `users` (
2
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
3
  `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
4
  `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
5
  `email_verified_at` timestamp NULL DEFAULT NULL,
6
  `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
7
  `remember_token` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
8
  `created_at` timestamp NULL DEFAULT NULL,
9
  `updated_at` timestamp NULL DEFAULT NULL,
10
  PRIMARY KEY (`id`),
11
  UNIQUE KEY `users_email_unique` (`email`),
12
  KEY `users_name_index` (`name`)
13
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

在上面這個 users 表中,將 email 設定為了唯一索引,將 name 設定為普通索引。MySQL 會為每個索引欄位維護一棵 B+ 樹,因此,對於 users 表而言,擁有三棵 B+ 樹,分別是主鍵索引、唯一索引和普通索引對應的 B+ 樹。

關於主鍵索引,上面已經介紹過,唯一索引和普通索引插入記錄的 B+ 樹維護邏輯和主鍵索引類似,葉子節點中的資料記錄都是按照對應索引值升序排序,這不過這裡的索引值從主鍵 ID 變成了 emailname,排序規則也是根據索引欄位對應的排序規則(沒有指定則繼承自表排序規則,資料表也沒有指定則繼承自所在的資料庫全域性設定)。

和主鍵索引不同的是,唯一索引和普通索引對應 B+ 樹葉子節點存放資料記錄的地方儲存的不是完整的資料記錄,而是所屬記錄的主鍵索引值,這樣設計的好處是避免資料冗餘,因為一張表可能存在多個索引,每個索引對應 B+ 樹都儲存完整的資料記錄會導致不必要的空間浪費,如果資料表很大的話,記憶體和磁碟空間可能很快就被吃完了,所以好處是顯而易見的,但是也有弊端,那就是要獲取完整的資料記錄,需要再通過主鍵索引對應的 B+ 樹查詢一次(也就是說獲取完整表記錄要遍歷兩棵 B+ 樹),我們將這個過程稱作回表,也因此,我們這種非主鍵索引稱之為二級索引

但也不見得所有的非主鍵索引查詢都要回表,如果一條 SQL 語句只需要獲取主鍵欄位資訊,那麼從非主鍵索引對應 B+ 樹就可以獲得主鍵欄位值直接返回了,這個時候就不需要回表了:

1
select id from users where name = '學院君';

另外,唯一索引和普通索引從查詢效能上看不分伯仲,因為所有的索引 B+ 樹葉子節點都是排好序的,對於一個命中索引的查詢,都是通過二分查詢到對應的記錄並返回,只是普通索引對應的記錄可能不止一條而已,唯一索引的一個優勢是可以在資料庫層面進行兜底避免有重複記錄出現,但是這個去重邏輯也可以在業務程式碼層完成。當然,如果普通索引設定不合理,一個索引欄位值對應多條記錄,多到要全表掃描,那就是另一回事了,比如在為某個狀態欄位設定了普通索引,而所有記錄的狀態值都是一樣的,這個時候通過該狀態值查詢,就是等同於一次全表掃描了。

注:對於 MyISAM 儲存引擎而言,由於索引和資料是分開儲存的,所以即便是主鍵索引,也要再次查詢才能返回完整資料記錄,因此,在 MyISAM 中,所有的索引都是二級索引。

聯合索引

有的時候,業務程式碼中經常用到的 SQL 查詢語句可能包含多個查詢條件,並且某些查詢欄位會多次用到:

1
select * from votes where voteable_type = ? and voteable_id = ?;
2
select * from votes where voteable_type = ?;

這個時候,為了提高查詢效率,同時也為了避免維護不必要的 B+ 樹(B+ 樹越多,資料庫寫入效能越差),我們可以設定聯合索引:

1
CREATE TABLE `votes` (
2
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
3
  `user_id` int(11) NOT NULL,
4
  `voteable_type` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,
5
  `voteable_id` bigint(20) unsigned NOT NULL,
6
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
7
  PRIMARY KEY (`id`),
8
  KEY `votes_voteable_type_voteable_id_index` (`voteable_type`,`voteable_id`)
9
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

根據業務程式碼用到的查詢條件,這裡我們將 voteable_typevoteable_id 設定為了聯合索引。

聯合索引也叫組合索引,和普通索引一樣通過 KEY 關鍵字設定,只是包含多個欄位而已。如果為每個欄位設定聯合索引,則需要多維護一棵 B+ 樹,並且進行如下 SQL 語句查詢時:

1
select * from votes where voteable_type = ? and voteable_id = ?

第一個查詢條件命中索引,然後需要在 voteable_type 獲取到的所有記錄中(經歷一次回表)依次判斷每條記錄是否滿足第二個 voteable_id 對應的查詢條件,如果 voteable_type 查詢返回的結果很多,則可能出現慢查詢,最差的情況甚至出現全表掃描。

而使用聯合索引後,只會維護一棵 B+ 樹,這棵 B+ 樹的葉子節點資料記錄會按照聯合索引包含的所有欄位進行排序,這裡的排序規則是先按照 voteable_type 欄位值進行升序排序,voteable_type 值相同的情況下再按照 voteable_id 欄位值進行升序排序:

顯然,如果某個查詢語句是這樣的話,不會應用到任何索引:

1
select * from votes where voteable_id = ?

我們可以通過 explain 語句進行驗證:

和唯一索引、普通索引一樣,聯合索引的資料記錄部分儲存的也是對應記錄的主鍵 ID,所以聯合索引本質上也是一個二級索引。如果查詢欄位只有 voteable_typevoteable_idid,也不會進行回表操作:

1
select voteable_type, voteable_id, id from votes where voteable_type = ? and voteable_id = ?;

我們可以把只包含索引的查詢稱之為覆蓋索引

另外,對於所有二級索引的 B+ 樹而言,由於資料記錄儲存的只有主鍵資訊,所以主鍵長度越小,二級索引的葉子節點就越小,佔用的空間也越小,從效能和儲存空間方面綜合考量,自增主鍵往往是最合理的選擇(整型資料相對字串型別佔用空間小,自增欄位無需對插入位置進行定位,直接放到最後一個數據頁的最後面的位置即可)。

維護索引的代價

通過前面這麼多的分析,我們可以得知設定資料庫索引主要是為了優化查詢效能,因為對於資料庫主要應用場景的 Web 專案而言,往往是讀多寫少,查詢語句佔據了資料庫操作的 90% 以上份額,所以合理設定索引提升查詢效率非常有必要,但是維護這些索引也需要付出代價:

  • 空間上的代價:每個索引都對應著一棵 B+ 樹,每棵 B+ 樹的每個葉子節點都是一個數據頁,資料頁預設的大小是 16 KB,因此維護索引需要額外的儲存空間;
  • 時間上的代價:插入、修改、刪除記錄這些資料庫寫入操作都會引起索引 B+ 樹的調整和自平衡,甚至產生頁分裂和頁合併,這些操作都需要額外的時間成本,會對資料庫效能造成一定的損耗。

好了,關於不同型別資料庫索引的 B+ 樹維護我們就簡單介紹到這裡,下篇教程,學院君將給大家介紹不同型別的查詢語句如何命中索引提升查詢效率。