索引-建立框架篇


在本篇文章中,開始部分會介紹索引的定義,及常見索引使用的數據結構和 MySQL 的索引模型。然后,根據索引的存儲結構來學習索引的建立原則。最后會介紹索引的使用場景。在閱讀后,應該了解如下的內容:

  • 索引場景的模型:哈希表,有序數組和搜索樹
  • MySQL 的索引模型,了解主鍵/非主鍵索引,回表,頁分裂的概念
  • MySQL 建立索引的原則

索引概述

索引的定義

索引常被用來根據明確的列值來快速找到對應的行。如在 MySQL 中,沒有索引的話,在找到數據時,會從表的第一行開始,遍歷整個表。對於大表來說,代價常常很大。而對於有索引的表來說,可以在不遍歷整個表的情況下,很快決定出數據文件的中間位置,再進行查找。

索引常用的數據結構

索引常常會用到哈希表,有序數組和搜索樹作為常見的存儲結構。

一、哈希表

定義:

哈希表就是常見的 Key/value 的存儲結構。在實現上,通過哈希函數通過 key 算出一個確定的位置,將值保存在對應位置的數組中。

下圖中,數組保存了 1 到 Z 個位置。其中 key 為用戶的身份證號。value 為對應用戶的身份信息。

img

存在的問題:

如上圖中,在位置為 N 的數組上,身份證號為 N2 和 N4 的用戶,在通過哈希函數計算后,得到了相同的位置 N. 這就是所說的哈希沖突。為了解決這個問題,一種常見的方法是,在沖突的位置拉出一個鏈表。

假如想要找到身份證號 n2 的用戶名稱。就先需要通過哈希函數計算出保存的位置 N,然后在 N 的位置上進行遍歷,找到 User2.

適用的場景:

由於數組中的 ID_card_n 的保存位置不是遞增的,在增加用戶時很快,直接向后追加。但由於不是有序的,在區間查詢時,速度很慢。比如要查找 [ID_card_x, ID_card_y] 區間用戶,需要全部掃描一遍。

所以,哈希表這種結構適用於只有等值查詢的場景,比如 Memcached 及其他一些 NoSQL 引擎。

二、有序數組:

定義:

簡單來說,數組數據的會按照一定的順序進行保存。

img

存在的問題:

在更新時,如在中間插入一個記錄需要挪動后面所有的記錄,成本很高。

適用的場景:

在等值查詢或者范圍查詢的場景中性能非常優秀。

比如在圖中,是按照身份證的大小保存的數據。這時在查找某一用戶的名稱事,可以使用二分法來查找,時間復雜度為O(log(N))。

在范圍查詢時,要查 [ID_card_X, ID_card_Y] 區間的用戶,可以使用二分法找到 X 的位置,然后向右遍歷到 Y 就可以了。

所以有序數組索引只適用於靜態存儲引擎,如 2017 年某個城市的所有人口信息,這類不會再修改的數據。

三、搜索樹

為了平衡和更改時,整體的效率,搜索樹作為進一步的選擇。

拿二叉搜索樹舉例

在二叉搜索樹中,每個節點的左兒子小於父節點,父節點小於右兒子。

img

假如要查找 ID 為 ID_card_n2 的節點,在查找時會按照 UserA -> UserC -> UserF -> User2 的順序,時間復雜度為 O(log(N)).

為了維持 O(log(N)) 的查詢復雜度,同時也要保證該樹是平衡二叉樹。在更新時,復雜度也是 O(log(N)).

在實際場景中,一般選用 N 叉樹,而不是二叉。

樹可以有二叉,也可以有多叉。多叉樹就是每個節點有多個兒子,兒子之間的大小保證從左到右遞增。二叉樹是搜索效率最高的,但是實際上大多數的數據庫存儲卻並不使用二叉樹。其原因是,索引不止存在內存中,還要寫到磁盤上。

如果采用二叉樹的結構,查詢效率會很低。因為在機械硬盤時代,從磁盤隨機讀一個數據塊的時間大致為 10ms 左右的尋址時間。對於 100 萬行的表,也就是 100 萬個節點的平衡二叉樹,樹高 20. 也就需要訪問 20 個數據頁(每個葉子節點就是一個頁,每個頁包含兩個數據,頁之間通過鏈式方式鏈接。)單獨訪問一行可能需要 20 * 10ms=200ms 毫秒的時間,查詢的速度太慢。

為了減少查詢磁盤的數量,就必須在查詢過程中盡量少訪問數據塊。所以 N 叉樹就派上了用場。N 取決於數據塊的大小。

以 InnoDB 的整數字段索引為例,N 差不多是 1200. 也就是說,樹高為 4 的時候,可以存儲 1200^3 約 3 億的值。

考慮到樹根的數據塊總是在內存中的,一個 10 億行的表上一個整數字段的索引,查找一個值最多只需要訪問 3 次磁盤。其實,樹的第二層也有很大概率在內存中,那么訪問磁盤的平均次數就更少了。

數據庫發展到今天,很多的新的數據機構如跳表,LSM 樹等都被用於在引擎設計中。但數據庫的底層核心就是之前的這些數據模型,后面的新模型只不過是不斷迭代,不斷優化的結果。在每次遇到一個數據庫時,先首先關注它的數據模型,進而才能分析出其使用的場景。

MySQL 中的索引模型

索引組織表

在 InnoDB 中,表都是根據主鍵順序以索引的形式存在,這種存儲方式稱為索引組織表。

InnoDB 中使用了 B+ 樹作為索引模型,每一個索引在 InnoDB 中都對應一顆 B+ 樹。

主鍵索引和非主鍵索引

根據葉子節點的內容,分為主鍵索引和非主鍵索引。

主鍵索引中存的是整行數據。在 InnoDB 里,主鍵索引也被稱為聚簇索引。

非主鍵索引的葉子節點內容是主鍵的值。在 InnoDB 里,非主鍵索引也被稱為二級索引。

如有一個主鍵為 ID,有字段 K 的表,其中 K 上有索引。

mysql> create table T(
id int primary key, 
k int not null, 
name varchar(16),
index (k))engine=InnoDB;

那么對應的 B+ 樹的結構就如下面所示:

img

圖中有 ID 的表示主鍵索引,而 K 的就是非主鍵索引。如在查詢時執行 select * from T; 這時僅需要搜索 ID 這棵 B+ 樹。

如果語句是select * from T where k=500;, 就先需要搜索 k 這棵 B+ 樹,然后再搜索 ID 這棵 B+ 樹。其實這個過程就是稱為回表的過程。因為 K 這棵樹僅僅存儲了 ID 的信息,而沒有正行的數據,想要搜索全部的數據,需要通過 ID 這棵 B+ 樹來查詢。

所以基於回表的情況,非主鍵索引的查詢需要多掃描一棵索引樹。因此,在應用中應該盡量使用主鍵查詢。

InnoDB 中索引的維護

B+ 樹為了維護索引有序性,在插入新值的時候需要做必要的維護。以上圖為例,如果插入新的行 ID 值為 700,則只需要在 R5 的記錄后面插入一個新記錄。如果新插入的 ID 值為 400,就相對麻煩了,需要邏輯上挪動后面的數據,空出位置。

更糟時,如果 R5 所在數據頁已經存滿,根據 B+ 樹的算法,這時候需要申請一個新的數據頁,然后挪動部分數據過去。這個過程稱為頁分裂。在這種情況下,性能自然會受影響。

除了性能外,頁分裂操作還影響數據頁的利用率。原本放在一個頁的數據,現在分到兩個頁中,整體空間利用率降低大約 50%。

為了防止這種情況,當相鄰兩個頁由於刪除了數據,利用率很低之后,會將數據頁做合並。合並的過程,可以認為是分裂過程的逆過程。

自增主鍵的原理

在一些建表規范里,要求建表語句里一定要有自增主鍵。來分析下是否正確。

在性能方面,如果設置了自增主鍵 NOT NULL PRIMARY KEY AUTO_INCREMENT. 在插入新記錄時,就可以不指定 ID 的值,系統會獲取當前 ID 的最大值加 1 作為下一條記錄的 ID。也就是說,這樣就符合了遞增插入的場景,每次插入,都是追加操作。不會挪動其他記錄,也不會觸發葉子節點的分裂,自然性能也就不會出現問題。

而使用業務邏輯做主鍵時,不能保證有序的插入,這樣在寫數據時,會出現頁分裂的情況,成本較高。

在存儲空間方面,主鍵長度越小,普通索引的葉子節點就越小,普通索引占用的空間也就越小。比如如果使用身份證號的字符串作為主鍵,每個非主鍵索引的葉子節點都是字符串類型的值,那么每個二級索引(非主鍵索引)的葉子節點占用約 20 個字節,如果使用整型做主鍵,僅僅需要 4 個字節,長整型(bigint)則是 8 個字節。

所有考慮到性能和存儲,自增是更合理的選擇。但什么場景可以使用業務字段呢,就是該表只有一個索引,且是唯一索引。也就是典型的 K/V。

由於沒有其他索引,所以不用考慮二級索引的存儲大小。考慮到上一段提到的“盡量使用主鍵查詢”原則,直接將這個索引設置為主鍵,可以避免每次查詢需要搜索兩棵樹。

案例:重建索引的選擇

索引可能因為刪除,或者頁分裂等原因,導致數據頁有空洞,重建索引會創建一個新的索引,把數據按順序插入,這樣頁面的利用率最高,也就是索引更緊湊、更省空間。

對於表 T 重建二級索引

alter table T drop index k;
alter table T add index(k);

對於表 T 重建主鍵索引

alter table T engine=InnoDB;

# 注意有人會采用如下的方法
alter table T drop primary key;
alter table T add primary key(id)
# 不論是刪除主鍵還是創建主鍵,都會將整個表重建。
所以連着執行這兩個語句的話,第一個語句就白做了。

MySQL 中的索引原則

覆蓋索引

什么是覆蓋索引?

覆蓋索引時 select 的數據列,可直接通過索引就能取得,而不必通過主鍵索引獲取數據行。

比如,建立這樣一個表結構:

mysql> create table T (
ID int primary key,
k int NOT NULL DEFAULT 0, 
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

對應索引樹的結構如下:

img

當執行 select * from T where k between 3 and 5;時:

  1. 會在 k 二級索引樹上,找到 k=3 的記錄,獲取 ID=300.
  2. 然后在 ID 索引樹上,查找 300 對應的行 R3.
  3. 在 K 索引樹上,取得 ID=500.
  4. 然后在 ID 索引樹上,查找 500 對應的行 R4.
  5. 在 K 索引樹上,取 k=6,發現無法取到,退出。

在這個過程中,從 K 索引到主鍵索引搜索的過程為回表。而此過程一共回表了兩次(步驟2,4)。

但當我們執行 select ID from T where between 3 and 5; 時,由於想要查詢的 ID 列,已經在 K 二級索引上了,索引就省去了再去搜索主鍵索引的步驟。換句話說,在這個查詢中,k 已經覆蓋了我們的查詢需求,索引被稱為覆蓋索引。

需要注意的是,在引擎的內部在索引 K 上其實讀了 3 個記錄,但對於 Server 層來說,引擎就拿到兩條記錄,所以會認為掃描行數是 2。

為什么要有覆蓋索引?

避免回表,覆蓋索引可以減少樹的搜索次數,顯著提升查詢性能,是常見的性能優化手段。

當然,索引字段的維護是有代價的,在建立冗余索引來支持覆蓋索引時就需要權衡考慮了。

最左前綴原則

為什么要有最左前綴原則?

有時會面臨這樣的情況,有一個查詢請求不是太頻繁,為其單獨建立索引不太合適,但走全表掃描不太合適。這時應該怎么辦,就可以利用 B+ 樹這種索引結構的最左前綴原則來定位。

什么是最左前綴原則?

假如有這樣一張市民表:

CREATE TABLE `tuser` (
  `id` int(11) NOT NULL,
  `id_card` varchar(32) DEFAULT NULL,
  `name` varchar(32) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `ismale` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `id_card` (`id_card`),
  KEY `name_age` (`name`,`age`)
) ENGINE=InnoDB

對應 name_age 的索引結構如下:

img

可以看到索引項會按照索引定義里面出現的字段順序來排序。

當要查找名字是張三的人時,可以快速定位到 ID4, 然后向后找到所有叫張三的人。

同樣如果想要查找的是姓張的人where name like '張%',同樣也能用上這個索引,會先找到姓張的 ID3 然后向后遍歷。

這時可以看到,不僅是滿足索引的全部定義的可以使用索引來加速,只要滿足最左原則,就可以用該索引進行加速。這個最左前綴可以是聯合索引的 N 個字段,也可以是字符串索引的最左 M 個字符。

所以對於聯合索引來說,如何安排索引內字段的順序就變得很重要。這里需要考慮到兩方面的內容,首先是索引的復用能力,然后是考慮空間的問題。

對於復用能力,由於支持最左前綴的原則,所以當有(a,b)的聯合索引后,一般就不需要再 a 上建立索引了。假如有了兩個新的查詢,根據身份證號查詢名字,根據身份證號查詢地址的需求。就可以將身份證號放在聯合索引的第一個位置。然后根據名字或地址的請求頻率來放置第二個。

對於空間來說,有時會出現既有(a,b)的聯合查詢,又有基於 a,b 的各自查詢。對於查詢條件里只有 b 的語句,是無法使用(a,b)的索引的,所以需要再維護一個索引,即 (a,b)和 (b)。還是市民表的情況 ,name 字段比 age 字段大,所以建議創建的是 (name, age) 和 age 索引,而不是(age,name)和 name 索引。

索引下推原則

當滿足最左前綴索引時,可以通過它在索引中定位。這時,對於那些不符合最左前綴索引的部分會怎么樣呢?

還是拿聯合索引(name,age)來舉例,如果現在想要查詢第一個名字是張,但是年齡是 10 歲的所有男孩,

mysql> select * from tuser where name like '張%' and age=10 and ismale=1;; 在開始部分,可以用張來找到第一個滿足條件的記錄 ID3。然后在判斷其他條件是否滿足。

MySQL 5.6 前,做法是通過找到 ID3 后,然后開始回表,一條條的查詢記錄是否滿足條件。注意,這里只會快速定位到 name ,對於 age 無法定位。而是通過回表時,查看 name 的值是否滿足。

img

在 5.7 后,引入索引下推優化,在遍歷二級索引是,會先對索引包含的列的條件做判斷是否滿足,然后再直接做過濾,減少回表的次數。簡單來說,在(name,age)索引樹上就會對 age 進行判斷,只有滿足條件的才去回表。

總結

總結一下,首先介紹了常見的索引模型有哈希表,有序數組和搜索樹。其中哈希表適用於等值查詢的場景,在插入時很快,但由於 key 並不是有序的,所以在范圍查詢表現的很差,並且還存在 hash 沖突的情況。

有序數組由於有序的特點,在等值查詢和范圍查詢都很快,但不適合修改數據。所以有序數組適用於那些數據不會輕易改變的場景。

搜索樹是為了平衡和查詢的關系,在實際中一般使用 N 叉樹,其中 N 取決於數據塊的大小。

之后介紹了,MySQL InnoDB 引擎中,采用的是 B+ 的數據模型,並解釋了由於 B+ 樹的特點,如果不使用自增主鍵時,會出現頁分裂的情況,從而造成性能的降低。
如果出現數據頁存在空洞現象比較多的情況,可以采用重建索引的方式。

最后介紹了建立索引的了三個原則:

  • 覆蓋索引:在建立索引時包含常見的搜索列,從而減少回表的次數。
  • 最左前綴原則:在建立聯合索引時,要在空間和復用性考慮,利用 B+ 樹,會匹配聯合索引第一個字段或者第一個字段的前 N 個字符做匹配。
  • 索引下推:介紹了當查詢的內容大於索引的內容,無法利用覆蓋索引時。會根據最左前綴原則,根據查找的條件先過濾,然后再回表,從而減少回表的次數。達到性能的提升。

參考

How MySQL Uses Indexes

題外話:最近在系統的學習 MySQL,推薦一個比較好的學習材料就是<<丁奇老師的 MySQL 45 講>>,鏈接已經附在文章末尾。

文章中很多知識點就是從中學來,加入自己的理解並整理的。

大家在購買后,強烈推薦讀一讀評論區的內容,價值非常高,不少同學問出了自己在思考時的一些困惑。

丁奇 MySQL 45 講鏈接

丁奇 MySQL 45 講鏈接


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM