上篇教程學院君給大家介紹了 MySQL 數據庫索引的底層數據結構 —— B+ 樹,今天我們來看看不同類型的數據庫索引是如何構建對應的 B+ 樹的。
我們知道數據庫索引通常分為主鍵索引、唯一索引、普通索引和聯合索引,不同索引對應的 B+ 樹存儲數據是不一樣的。
主鍵索引
通常我們會將一張表的 ID 字段設置為主鍵索引,比如下面這個創建數據表 posts
的 SQL 語句:
CREATE TABLE `posts` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`content` text COLLATE utf8mb4_unicode_ci NOT NULL,
`user_id` bigint(20) unsigned NOT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) 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 KEY
和 KEY
關鍵字進行設置:
CREATE TABLE `users` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`email_verified_at` timestamp NULL DEFAULT NULL,
`password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`remember_token` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `users_email_unique` (`email`),
KEY `users_name_index` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
在上面這個 users
表中,將 email
設置為了唯一索引,將 name
設置為普通索引。MySQL 會為每個索引字段維護一棵 B+ 樹,因此,對於 users
表而言,擁有三棵 B+ 樹,分別是主鍵索引、唯一索引和普通索引對應的 B+ 樹。
關於主鍵索引,上面已經介紹過,唯一索引和普通索引插入記錄的 B+ 樹維護邏輯和主鍵索引類似,葉子節點中的數據記錄都是按照對應索引值升序排序,這不過這里的索引值從主鍵 ID 變成了 email
和 name
,排序規則也是根據索引字段對應的排序規則(沒有指定則繼承自表排序規則,數據表也沒有指定則繼承自所在的數據庫全局設置)。
和主鍵索引不同的是,唯一索引和普通索引對應 B+ 樹葉子節點存放數據記錄的地方存儲的不是完整的數據記錄,而是所屬記錄的主鍵索引值,這樣設計的好處是避免數據冗余,因為一張表可能存在多個索引,每個索引對應 B+ 樹都存儲完整的數據記錄會導致不必要的空間浪費,如果數據表很大的話,內存和磁盤空間可能很快就被吃完了,所以好處是顯而易見的,但是也有弊端,那就是要獲取完整的數據記錄,需要再通過主鍵索引對應的 B+ 樹查詢一次(也就是說獲取完整表記錄要遍歷兩棵 B+ 樹),我們將這個過程稱作回表,也因此,我們這種非主鍵索引稱之為二級索引。
但也不見得所有的非主鍵索引查詢都要回表,如果一條 SQL 語句只需要獲取主鍵字段信息,那么從非主鍵索引對應 B+ 樹就可以獲得主鍵字段值直接返回了,這個時候就不需要回表了:
select id from users where name = '學院君';
另外,唯一索引和普通索引從查詢性能上看不分伯仲,因為所有的索引 B+ 樹葉子節點都是排好序的,對於一個命中索引的查詢,都是通過二分查找到對應的記錄並返回,只是普通索引對應的記錄可能不止一條而已,唯一索引的一個優勢是可以在數據庫層面進行兜底避免有重復記錄出現,但是這個去重邏輯也可以在業務代碼層完成。當然,如果普通索引設置不合理,一個索引字段值對應多條記錄,多到要全表掃描,那就是另一回事了,比如在為某個狀態字段設置了普通索引,而所有記錄的狀態值都是一樣的,這個時候通過該狀態值查詢,就是等同於一次全表掃描了。
注:對於 MyISAM 存儲引擎而言,由於索引和數據是分開存儲的,所以即便是主鍵索引,也要再次查詢才能返回完整數據記錄,因此,在 MyISAM 中,所有的索引都是二級索引。
聯合索引
有的時候,業務代碼中經常用到的 SQL 查詢語句可能包含多個查詢條件,並且某些查詢字段會多次用到:
select * from votes where voteable_type = ? and voteable_id = ?;
select * from votes where voteable_type = ?;
這個時候,為了提高查詢效率,同時也為了避免維護不必要的 B+ 樹(B+ 樹越多,數據庫寫入性能越差),我們可以設置聯合索引:
CREATE TABLE `votes` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`voteable_type` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,
`voteable_id` bigint(20) unsigned NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `votes_voteable_type_voteable_id_index` (`voteable_type`,`voteable_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
根據業務代碼用到的查詢條件,這里我們將 voteable_type
和 voteable_id
設置為了聯合索引。
聯合索引也叫組合索引,和普通索引一樣通過 KEY
關鍵字設置,只是包含多個字段而已。如果為每個字段設置聯合索引,則需要多維護一棵 B+ 樹,並且進行如下 SQL 語句查詢時:
select * from votes where voteable_type = ? and voteable_id = ?
第一個查詢條件命中索引,然后需要在 voteable_type
獲取到的所有記錄中(經歷一次回表)依次判斷每條記錄是否滿足第二個 voteable_id
對應的查詢條件,如果 voteable_type
查詢返回的結果很多,則可能出現慢查詢,最差的情況甚至出現全表掃描。
而使用聯合索引后,只會維護一棵 B+ 樹,這棵 B+ 樹的葉子節點數據記錄會按照聯合索引包含的所有字段進行排序,這里的排序規則是先按照 voteable_type
字段值進行升序排序,voteable_type
值相同的情況下再按照 voteable_id
字段值進行升序排序:
顯然,如果某個查詢語句是這樣的話,不會應用到任何索引:
select * from votes where voteable_id = ?
我們可以通過 explain
語句進行驗證:
和唯一索引、普通索引一樣,聯合索引的數據記錄部分存儲的也是對應記錄的主鍵 ID,所以聯合索引本質上也是一個二級索引。如果查詢字段只有 voteable_type
、voteable_id
和 id
,也不會進行回表操作:
select voteable_type, voteable_id, id from votes where voteable_type = ? and voteable_id = ?;
我們可以把只包含索引的查詢稱之為覆蓋索引。
另外,對於所有二級索引的 B+ 樹而言,由於數據記錄存儲的只有主鍵信息,所以主鍵長度越小,二級索引的葉子節點就越小,占用的空間也越小,從性能和存儲空間方面綜合考量,自增主鍵往往是最合理的選擇(整型數據相對字符串類型占用空間小,自增字段無需對插入位置進行定位,直接放到最后一個數據頁的最后面的位置即可)。
維護索引的代價
通過前面這么多的分析,我們可以得知設置數據庫索引主要是為了優化查詢性能,因為對於數據庫主要應用場景的 Web 項目而言,往往是讀多寫少,查詢語句占據了數據庫操作的 90% 以上份額,所以合理設置索引提升查詢效率非常有必要,但是維護這些索引也需要付出代價:
- 空間上的代價:每個索引都對應着一棵 B+ 樹,每棵 B+ 樹的每個葉子節點都是一個數據頁,數據頁默認的大小是 16 KB,因此維護索引需要額外的存儲空間;
- 時間上的代價:插入、修改、刪除記錄這些數據庫寫入操作都會引起索引 B+ 樹的調整和自平衡,甚至產生頁分裂和頁合並,這些操作都需要額外的時間成本,會對數據庫性能造成一定的損耗。
好了,關於不同類型數據庫索引的 B+ 樹維護我們就簡單介紹到這里,下篇教程,學院君將給大家介紹不同類型的查詢語句如何命中索引提升查詢效率。