一、背景
我們工作中經常打交道的就是索引,那么到底什么是索引呢?例如,當一個SQL查詢比較慢的時候,你可能會說給“某個字段加個索引吧”之類的解決方案。
總的來說索引的出現其實就是為了提高數據查詢的效率,就像書的目錄一樣。一本上千頁頁的英語字典,如果你想快速找到其中的某一個單詞,在不借助目錄的情況下,那我估計你可得找一會兒。同樣,對於數據庫的表而言,索引其實就是它的“目錄”。實現索引的方式卻有很多種,所以這里也就引入了索引模型的概念。可以用於提高讀寫效率的數據結構很多,接下里主要介紹常見、也比較簡單的數據結構,它們分別是哈希表、有序數組和搜索樹。接下來用半篇文章的篇幅給大家介紹不同的數據結構,以及它們的適用場景,你可能會覺得有些枯燥。但是,我們還是要多花一些時間來理解下面的內容,畢竟這是數據庫處理數據的核心概念之一,在分析問題的時候會經常用到。當你理解了索引的模型后,就會發現在分析問題的時候會有一個更清晰的視角,體會到引擎設計的精妙之處。
數據庫底層存儲的核心就是基於這些數據模型的。每碰到一個新數據庫,我們需要先關注它的數據模型,這樣才能從理論上分析出這個數據庫的適用場景。
二、哈希表
哈希表是以鍵值對 (key-value) 存儲數據的結構,我們只需要通過key,就可以快速的找到對應的value。哈希表的實現思路很簡單,把值放在數據里,用一個哈希函數把key換算成一個確定的位置,然后把value放在數組的這個位置,不可避免地,多個key值經過哈希函數的換算,會出現同一個值的情況。處理這種情況的一種方法是,拉出一個鏈表。這跟HashMap底層table數組很相似的。如果有小伙伴不了結果HashMap的底層的話,可以看一下我篇文章 https://www.cnblogs.com/huangjuncong/p/9465414.html 。
接下來,用一個學生信息卡信息和姓名的的表來進行舉例子,需要根據身份證號查找對應的名字,這時對應的哈希索引的示意圖如下所示:
可以看到student 2 和student 4根據學號計算哈希計算得到數據的位置是 4 ,那不是沖突了嗎?,放心這沒關系的,后面還跟着一個連表呢。假設,這時候你要查student_id_2 對應的名字是什么,這時候處理的步驟就是:
1.首先,將student_id_2通過哈希函數計算出 4,
2.然后按順序遍歷下去,找到student 2。
需要注意的是,圖中四個student_id_n的值並不是遞增的,這樣做的好處是增加新的student時速度會很快,只需要往后追加。但缺點是,因為不是有序的,所以哈希索引做區間查詢的速度是很慢的。你可以設想下,如果你現在要找學號在[student_id_X, student_id_Y]這個區間的所有的學生,就必須全部掃描一遍了。所以,哈希表這種結構適用於只有等值查詢的場景,比如Memcached及其他一些NoSQL引擎。
三、有序數組
我們知道有序數組在等值查詢和范圍查詢場景中的性能就都非常優秀。還是上面這個根據學號號查學生名字的例子,如果我們使用有序數組來實現的話,示意圖如下所示:
我們知道一個學號是不可能有重復的,這個數組就是學號號遞增的順序保存的。這時候如果你要查student_id_n2對應的名字,用二分法就可以快速得到,這個時間復雜度是O(log(N))。
有數據結構基礎的人,很明顯看到這個索引結構支持范圍查詢。你要查身份證號在[student_id_X, student_id_Y]區間的student,可以先用二分法找到 student_id_X,(如果不存在student_id_X,,就找到大於student_id_X,的第一個stduent),然后向右遍歷,直到查到第一個大於student_id_Y的學號,退出循環。
如果僅僅看查詢效率,有序數組就是最好的數據結構了。但是,在需要更新數據的時候就麻煩了,你往中間插入一個記錄就必須得挪動后面所有的記錄,成本太高。所以,有序數組索引只適用於靜態存儲引擎,比如你要保存的是某一年某個城市的所有人口信息,這類不會再修改的數據。
四、二叉樹
二叉搜索樹的特點是:每個節點的左兒子小於父節點,父節點又小於右兒子。我們繼續根據學號號查學生名字的例子,如果我們用二叉搜索樹來實現的話,示意圖如下所示:
如果你要查student_id_n2的話,按照圖中的搜索順序就是按照studentA -> studentC -> studentF -> student2這個路徑得到。這個時間復雜度是O(log(N))。當然為了維持O(log(N))的查詢復雜度,你就需要保持這棵樹是平衡二叉樹。為了做這個保證,更新的時間復雜度也是O(log(N))。樹可以有二叉,也可以有多叉。多叉樹就是每個節點有多個兒子,兒子之間的大小保證從左到右遞增。二叉樹是搜索效率最高的,但是實際上大多數的數據庫存儲卻並不使用二叉樹。其原因是,索引不止存在內存中,還要寫到磁盤上。
你可以想象一下一棵100萬節點的平衡二叉樹,樹高20。一次查詢可能需要訪問20個數據塊。在機械硬盤時代,從磁盤隨機讀一個數據塊需要10 ms左右的尋址時間。也就是說,對於一個100萬行的表,如果使用二叉樹來存儲,單獨訪問一個行可能需要20個10 ms的時間,這個查詢可真夠慢的。為了讓一個查詢盡量少地讀磁盤,就必須讓查詢過程訪問盡量少的數據塊。那么,我們就不應該使用二叉樹,而是要使用“N叉”樹。這里,“N叉”樹中的“N”取決於數據塊的大小。
以InnoDB的一個整數字段索引為例,這個N差不多是1200。這棵樹高是4的時候,就可以存1200的3次方個值,這已經17億了。考慮到樹根的數據塊總是在內存中的,一個10億行的表上一個整數字段的索引,查找一個值最多只需要訪問3次磁盤。其實,樹的第二層也有很大概率在內存中,那么訪問磁盤的平均次數就更少了。
N叉樹由於在讀寫上的性能優點,以及適配磁盤的訪問模式,已經被廣泛應用在數據庫引擎中了。不管是哈希還是有序數組,或者N叉樹,它們都是不斷迭代、不斷優化的產物或者解決方案。數據庫技術發展到今天,跳表、LSM樹等數據結構也被用於引擎設計中。我們腦中要有個概念,數據庫底層存儲的核心就是基於這些數據模型的。每碰到一個新數據庫,我們需要先關注它的數據模型,這樣才能從理論上分析出這個數據庫的適用場景。
五、InnoDB索引模型
在InnoDB中,表都是根據主鍵順序以索引的形式存放的,這種存儲方式的表稱為索引組織表。由於InnoDB使用了B+樹索引模型,所以數據都是存儲在B+樹中的。每一個索引在InnoDB里面對應一棵B+樹。假設,我們有一個主鍵列為ID的表,表中有字段k,並且在k上有索引。
create table T( id int primary key, k int not null, name varchar(16), index (k))engine=InnoDB;
表中R1~R5的(ID,k)值分別為(100,1)、(200,2)、(300,3)、(500,5)和(600,6),兩棵樹的示例示意圖如下。
從上圖可以看到,根據葉子節點的內容,索引類型分為主鍵索引和非主鍵索引。主鍵索引的葉子節點存的是整行數據。在InnoDB里,主鍵索引也被稱為聚簇索引(clustered index)。
非主鍵索引的葉子節點內容是主鍵的值。在InnoDB里,非主鍵索引也被稱為二級索引(secondary index)。
那么基於主鍵索引和普通索引的查詢有什么區別?
- 如果語句是select * from T where ID=500,即主鍵查詢方式,則只需要搜索ID這棵B+樹;
- 如果語句是select * from T where k=5,即普通索引查詢方式,則需要先搜索k索引樹,得到ID的值為500,再到ID索引樹搜索一次。這個過程稱為回表。
因此,基於非主鍵索引的查詢需要多掃描一棵索引樹。因此,我們在應用中應該盡量使用主鍵查詢。
B+樹為了維護索引有序性,在插入新值的時候需要做必要的維護。以上面這個圖為例,如果插入新的行ID值為700,則只需要在R6的記錄后面插入一個新記錄。如果新插入的ID值為450,就相對麻煩了,需要邏輯上挪動后面的數據,空出位置。而更糟的情況是,如果R6所在的數據頁已經滿了,根據B+樹的算法,這時候需要申請一個新的數據頁,然后挪動部分數據過去。這個過程稱為頁分裂。在這種情況下,性能自然會受影響。
除了性能外,頁分裂操作還影響數據頁的利用率。原本放在一個頁的數據,現在分到兩個頁中,整體空間利用率降低大約50%。當然有分裂就有合並。當相鄰兩個頁由於刪除了數據,利用率很低之后,會將數據頁做合並。合並的過程,可以認為是分裂過程的逆過程。
接下來用一個例子,來分析一下哪些場景下應該使用自增主鍵,而哪些場景下不應該。
自增主鍵是指自增列上定義的主鍵,在建表語句中一般是這么定義的: NOT NULL PRIMARY KEY AUTO_INCREMENT。插入新記錄的時候可以不指定ID的值,系統會獲取當前ID最大值加1作為下一條記錄的ID值。也就是說,自增主鍵的插入數據模式,正符合了我們前面提到的遞增插入的場景。每次插入一條新記錄,都是追加操作,都不涉及到挪動其他記錄,也不會觸發葉子節點的分裂。
而有業務邏輯的字段做主鍵,則往往不容易保證有序插入,這樣寫數據成本相對較高。除了考慮性能外,我們還可以從存儲空間的角度來看。假設你的表中確實有一個唯一字段,比如字符串類型的學生的學號,那應該用學號做主鍵,還是用自增字段做主鍵呢?由於每個非主鍵索引的葉子節點上都是主鍵的值。如果用學號做主鍵,那么每個二級索引的葉子節點占用約20個字節,而如果用整型做主鍵,則只要4個字節,如果是長整型(bigint)則是8個字節。
顯然,主鍵長度越小,普通索引的葉子節點就越小,普通索引占用的空間也就越小。所以,從性能和存儲空間方面考量,自增主鍵往往是更合理的選擇。
有沒有什么場景適合用業務字段直接做主鍵的呢?還是有的。比如,有些業務的場景需求是這樣的:
-
只有一個索引;
-
該索引必須是唯一索引。
沒錯,這就是就是典型的KV場景。由於沒有其他索引,所以也就不用考慮其他索引的葉子節點大小的問題。這時候我們就要優先考慮上一段提到的“盡量使用主鍵查詢”原則,直接將這個索引設置為主鍵,可以避免每次查詢需要搜索兩棵樹。
六、覆蓋索引
如果執行 select * from T where k between 3 and 5這條SQL語句,需要執行幾次樹的搜索操作,會掃描多少行?
結合上面的圖,可以分析得到這條SQL的執行流程如下:
-
在k索引樹上找到k=3的記錄,取得 ID = 300;
-
再到ID索引樹查到ID=300對應的R3;
-
在k索引樹取下一個值k=5,取得ID=500;
-
再回到ID索引樹查到ID=500對應的R5;
-
在k索引樹取下一個值k=6,不滿足條件,循環結束。
大家看到這個過程不就是上面所講到的回表嗎?是的,回到主鍵索引樹搜索的過程,我們稱為回表。可以看到,這個查詢過程讀了k索引樹的3條記錄(步驟1、3和5),回表了兩次(步驟2和4)。
由於查詢結果所需要的數據只在主鍵索引上有,因此逼不得已不得不進行回表。那么,有沒有可能經過索引優化,避免回表過程呢?
當時是有措施避免回表,這就是接下來要進行講解的通過覆蓋索引避免回表操作。
何為覆蓋索引?
如果一個索引包含(或覆蓋)所有需要查詢的字段的值,稱為‘覆蓋索引’。即只需掃描索引而無須回表。
如果執行的語句是select ID from T where k between 3 and 5,這時只需要查ID的值,而ID的值已經在k索引樹上了,因此可以直接提供查詢結果,不需要回表。也就是說,在這個查詢里面,索引k已經“覆蓋了”我們的查詢需求。這就是所謂的覆蓋索引。
由於覆蓋索引可以減少樹的搜索次數,極大的提升查詢性能,因此使用覆蓋索引是一個常用的性能優化有段,這個要切記。
這里需要注意一點,引擎內部使用覆蓋索引在索引k上其實讀了三個記錄,R3~R6(對應的索引k上的記錄項),但是對於MySQL的Server層來說,它就是找引擎拿到了3條記錄,因此MySQL認為掃描行數是3。
結合上面的覆蓋索引,我們思考一個問題:在一個市民信息表上,是否有必要將身份證號和名字建立聯合索引?
市民表的建表語句如下:
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
身份證號是市民的唯一標識。也就是說,如果有根據身份證號查詢市民信息的需求,我們只要在身份證號字段上建立索引就夠了。而再建立一個(身份證號、姓名)的聯合索引,是不是浪費空間?
有根據身份證號查詢市民信息的需求而再建立聯合索引確實是很浪費空間,但是如果現在有一個高頻請求,要根據市民的身份證號查詢他的姓名,這個聯合索引就有意義了。它可以在這個高頻請求上用到覆蓋索引,不再需要回表查整行記錄,減少語句的執行時間。
我們知道索引字段的維護總是有代價的。因此,在建立冗余索引來支持覆蓋索引時就需要權衡考慮了。
七、最左匹配原則
如果為每一種查詢都設計一個索引,這無疑索引的數量越來越多,所占用的空間也就越來越大。如果我現在要按照市民的身份證號去查他的家庭地址呢?雖然這個查詢需求在業務中出現的概率不高,但總不能讓它走全表掃描吧?反過來說,單獨為一個不頻繁的請求創建一個(身份證號,地址)的索引又感覺有點浪費。應該怎么做呢?
辦法還是有的,這就是解析來通過索引的最左前綴來定位數據記錄,為了很好講解,接下里用聯合索引進行舉例子:
從圖蟲可以看到,索引項是按照索引定義里面出現的字段順序排序的。當你的邏輯需求是查到所有名字是“張三”的人時,可以快速定位到ID4,然后向后遍歷得到所有需要的結果。
當你要查的是所有名字第一個字是“張”的人,你的SQL語句的條件是"where name like ‘張%’"。這時,你也能夠用上這個索引,查找到第一個符合條件的記錄是ID3,然后向后遍歷,直到不滿足條件為止。注意前百分號是不走索引的,如 ‘%張’。
因此可以看到,不只是索引的全部定義,只要滿足最左前綴,就可以利用索引來加速檢索。這個最左前綴可以是聯合索引的最左N個字段,也可以是字符串索引的最左M個字符。
接下來讓我們思考一個問題:在建立聯合索引的時候,如何安排索引內的字段順序?
第一原則是,如果通過調整順序,可以少維護一個索引,那么這個順序往往就是需要優先考慮采用的。
評估標准是索引的復用能力。因為可以支持最左前綴,所以當已經有了(a,b)這個聯合索引后,一般就不需要單獨在a上建立索引了。
因此,現在我們知道上面為什么要為高頻請求創建(身份證號,姓名)這個聯合索引,並用這個索引支持“根據身份證號查詢地址”的需求了。
如果既有聯合查詢,又有基於a、b各自的查詢呢?查詢條件里面只有b的語句,是無法使用(a,b)這個聯合索引的,這時候你不得不維護另外一個索引,也就是說你需要同時維護(a,b)、(b) 這兩個索引。這時候,需要我們考慮的原則就是空間了。比如上面這個市民表的情況,name字段是比age字段大的 ,那我就建議你創建一個(name,age)的聯合索引和一個(age)的單字段索引。
八、索引下推優化
滿足最左前綴原則的時候,最左前綴可以用於在索引中定位記錄。這時,你可能要問,那些不符合最左前綴的部分,會怎么樣呢?
以市民表的聯合索引(name, age)為例。如果現在有一個需求:檢索出表中“名字第一個字是張,而且年齡是10歲的所有男孩”。那么,SQL語句是這么寫的:
select * from tuser where name like '張%' and age=10 and ismale=1;
知道了前綴索引規則,所以這個語句在搜索索引樹的時候,只能用 “張”,找到第一個滿足條件的記錄ID3。當然,這還不錯,總比全表掃描要好。有沒有什么條件可以進一步判斷呢?
在MySQL 5.6之前,只能從ID3開始一個個回表。到主鍵索引上找出數據行,再對比字段值。
而MySQL 5.6 引入的索引下推優化(index condition pushdown), 可以在索引遍歷過程中,對索引中包含的字段先做判斷,直接過濾掉不滿足條件的記錄,減少回表次數。
索引下推優化執行流程圖如下所示:
無索引下推的執行流程圖:
索引下推優化執行流程圖如下:
上面兩個圖里面,每一個虛線箭頭表示回表一次。
無索引下推的執行流程圖中,在(name,age)索引里面我特意去掉了age的值,這個過程InnoDB並不會去看age的值,只是按順序把“name第一個字是’張’”的記錄一條條取出來回表。因此,需要回表4次。
索引下推優化執行流程圖跟無索引下推的執行流程圖的區別是,InnoDB在(name,age)索引內部就判斷了age是否等於10,對於不等於10的記錄,直接判斷並跳過。在我們的這個例子中,只需要對ID4、ID5這兩條記錄回表取數據判斷,就只需要回表2次。
可以看到,在滿足語句需求的情況下, 盡量少地訪問資源是數據庫設計的重要原則之一。我們在使用數據庫的時候,尤其是在設計表結構時,也要以減少資源消耗作為目標。
九、普通索引與唯一索引
在不同的業務場景下,應該選擇普通索引,還是唯一索引?
假設你在維護一個市民系統,每個人都有一個唯一的身份證號,而且業務代碼已經保證了不會寫入兩個重復的身份證號。如果市民系統需要按照身份證號查姓名,就會執行類似這樣的SQL語句:
select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz';
我們第一反應就是考慮在id_card字段上建索引。
從這條SQL可以看到身份證號字段比較大,因此不建議你把身份證號當做主鍵,那么現在你有兩個選擇,要么給id_card字段創建唯一索引,要么創建一個普通索引。如果業務代碼已經保證了不會寫入重復的身份證號,那么這兩個選擇邏輯上都是正確的。
從性能的角度考慮,你選擇唯一索引還是普通索引呢?選擇的依據是什么呢?
接下來,我們就從這兩種索引對查詢語句和更新語句的性能影響來進行分析。還是使用上面一個例子的圖,如下:
查詢過程:
假設,執行查詢的語句是 select id from T where k=5。這個查詢語句在索引樹上查找的過程,先是通過B+樹從樹根開始,按層搜索到葉子節點,也就是圖中右下角的這個數據頁,然后可以認為數據頁內部通過二分法來定位記錄。
1.對於普通索引來說,查找到滿足條件的第一個記錄(5,500)后,需要查找下一個記錄,直到碰到第一個不滿足k=5條件的記錄。
2.對於唯一索引來說,由於索引定義了唯一性,查找到第一個滿足條件的記錄后,就會停止繼續檢索。
那么,這兩者不同所帶來的性能差距有大呢?
答案是 微乎其微。
我們知道,InnoDB的數據是按數據頁為單位來讀寫的。也就是說,當需要讀一條記錄的時候,並不是將這個記錄本身從磁盤讀出來,而是以頁為單位,將其整體讀入內存。在InnoDB中,每個數據頁的大小默認是16KB。正是因為引擎是按頁讀寫的,所以說,當找到k=5的記錄的時候,它所在的數據頁就都在內存里了。那么,對於普通索引來說,要多做的那一次“查找和判斷下一條記錄”的操作,就只需要一次指針尋找和一次計算。
當然,如果k=5這個記錄剛好是這個數據頁的最后一個記錄,那么要取下一個記錄,必須讀取下一個數據頁,這個操作會稍微復雜一些。但是,我們之前計算過,對於整型字段,一個數據頁可以放近千個key,因此出現這種情況的概率會很低。所以,我們計算平均性能差異時,仍可以認為這個操作成本對於現在的CPU來說可以忽略不計。
更新過程:
為了說明普通索引和唯一索引對更新語句性能的影響這個問題之前,必須要先了解change buffer。
當需要更新一個數據頁時,如果數據頁在內存中就直接更新,而如果這個數據頁還沒有在內存中的話,在不影響數據一致性的前提下,InooDB會將這些更新操作緩存在change buffer中,這樣就不需要從磁盤中讀入這個數據頁了。在下次查詢需要訪問這個數據頁的時候,將數據頁讀入內存,然后執行change buffer中與這個頁有關的操作。通過這種方式就能保證這個數據邏輯的正確性。實際上它是可以持久化的數據。也就是說,change buffer在內存中有拷貝,也會被寫入到磁盤上。
將change buffer中的操作應用到原數據頁,得到最新結果的過程稱為merge。除了訪問這個數據頁會觸發merge外,系統有后台線程會定期merge。在數據庫正常關閉(shutdown)的過程中,也會執行merge操作。顯然,如果能夠將更新操作先記錄在change buffer,減少讀磁盤,語句的執行速度會得到明顯的提升。而且,數據讀入內存是需要占用buffer pool的,所以這種方式還能夠避免占用內存,提高內存利用率。
哪種索引可以使用change buffer呢?
對於唯一索引來說,所有的更新操作都要先判斷這個操作是否違反唯一性約束。比如,要插入(4,400)這個記錄,就要先判斷現在表中是否已經存在k=4的記錄,而這必須要將數據頁讀入內存才能判斷。如果都已經讀入到內存了,那直接更新內存會更快,就沒必要使用change buffer了。因此,唯一索引的更新就不能使用change buffer,實際上也只有普通索引可以使用。
change buffer用的是buffer pool里的內存,因此不能無限增大。change buffer的大小,可以通過參數innodb_change_buffer_max_size來動態設置。這個參數設置為50的時候,表示change buffer的大小最多只能占用buffer pool的50%。
如果要在上圖中插入一個新記錄(4.5,450)的話,InnoDB的處理流程是怎樣的呢?
第一種情況是,這個記錄要更新的目標頁在內存中。這時,InnoDB的處理流程如下:
- 對於唯一索引來說,找到4和5之間的位置,判斷到沒有沖突,插入這個值,語句執行結束;
- 對於普通索引來說,找到4和5之間的位置,插入這個值,語句執行結束。
這樣看來,普通索引和唯一索引對更新語句性能影響的差別,只是一個判斷,只會耗費微小的CPU時間。
第二種情況是,這個記錄要更新的目標頁不在內存中。這時,InnoDB的處理流程如下:
- 對於唯一索引來說,需要將數據頁讀入內存,判斷到沒有沖突,插入這個值,語句執行結束;
- 對於普通索引來說,則是將更新記錄在change buffer,語句執行就結束了。
將數據從磁盤讀入內存涉及隨機IO的訪問,是數據庫里面成本最高的操作之一。change buffer因為減少了隨機磁盤訪問,所以對更新性能的提升是會很明顯的。
注意一下,當業務中有大量的插入數據的時候,而且原先存在普通索引的時候,千萬不要把普通索引改成唯一索引,因為這樣會導致整個系統處於阻塞狀態,更新語句全部堵住,並且庫內存命中率急劇下降。
到這里,我們在反思一下:難道普通索引的所有場景,使用change buffer都可以起到加速作用嗎?
因為merge的時候是真正進行數據更新的時刻,而change buffer的主要目的就是將記錄的變更動作緩存下來,所以在一個數據頁做merge之前,change buffer記錄的變更越多(也就是這個頁面上要更新的次數越多),收益就越大。
因此,對於寫多讀少的業務來說,頁面在寫完以后馬上被訪問到的概率比較小,此時change buffer的使用效果最好。這種業務模型常見的就是賬單類、日志類的系統。
反過來,假設一個業務的更新模式是寫入之后馬上會做查詢,那么即使滿足了條件,將更新先記錄在change buffer,但之后由於馬上要訪問這個數據頁,會立即觸發merge過程。這樣隨機訪問IO的次數不會減少,反而增加了change buffer的維護代價。所以,對於這種業務模式來說,change buffer反而起到了副作用。
回到我們上面一開始所提到的問題,普通索引和唯一索引應該怎么選擇。其實,這兩類索引在查詢能力上是沒差別的,主要考慮的是對更新性能的影響。因此盡量選擇普通索引。
如果所有的更新后面,都馬上伴隨着對這個記錄的查詢,那么你應該關閉change buffer。而在其他情況下,change buffer都能提升更新性能。在實際使用中,你會發現,普通索引和change buffer的配合使用,對於數據量大的表的更新優化還是很明顯的。特別地,在使用機械硬盤時,change buffer這個機制的收效是非常顯著的。所以,當你有一個類似“歷史數據”的庫,並且出於成本考慮用的是機械硬盤時,那你應該特別關注這些表里的索引,盡量使用普通索引,然后把change buffer 盡量開大,以確保這個“歷史數據”表的數據寫入速度。
十、MySQL對索引的選擇
在MySQL中一張表其實是可以支持多個索引的。但是,你寫SQL語句的時候,並沒有主動指定使用哪個索引。也就是說,使用哪個索引是由MySQL來確定的。
在我們實際開發中,肯定遇到過這種情況:一條加了索引的SQL本來可以執行很快的,但是由於MySQL選錯索引導致執行速度變慢。
下面使用一個例子來看一下MySQL選擇索引的例子,如下:
CREATE TABLE `t` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `a` (`a`), KEY `b` (`b`) ) ENGINE=InnoDB;
可以看到表中有兩個索引,分別是a b,現在往表中插入10萬行數據,執行的存儲過程如下:
delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=100000)do insert into t values(i, i, i); set i=i+1; end while; end;; delimiter ; call idata();
接下來,我們先執行以下一條SQL,腳本如下:
select * from t where a between 10000 and 20000;
可以看到這條語句肯定是使用索引a的,接着我們使用explain命令看這條SQL的執行狀態如下:
這條查詢語句的執行也確實符合預期,key這個字段值是’a’,表示優化器選擇了索引a。不過先別着急,如果我們做以下的操作,這個例子就不會這么簡單了,操作如下圖:
session t1時間 A開啟了一個事務。隨后,session B 在 t2 時間內把數據都刪除后,又調用了 idata這個存儲過程,插入了10萬行數據。
這時候,session B t3時間內的查詢語句select * from t where a between 10000 and 20000就不會再選擇索引a了。我們可以通過慢查詢日志(slow log)來查看一下具體的執行情況。
為了說明優化器選擇的結果是否正確,我增加了一個對照,即:使用force index(a)來讓優化器強制使用索引a(這部分內容,我還會在這篇文章的后半部分中提到)。
下面的三條SQL語句,就是這個實驗過程。
set long_query_time=0; select * from t where a between 10000 and 20000; /*Q1*/ select * from t force index(a) where a between 10000 and 20000;/*Q2*/
第一句,是將慢查詢日志的閾值設置為0,表示這個線程接下來的語句都會被記錄入慢查詢日志中;
第二句,Q1是session B原來的查詢;
第三句,Q2是加了force index(a)來和session B原來的查詢語句執行情況對比。
這三條SQL語句執行完成后的慢查詢日志如下所示:
接下來看這三條的慢查詢日志。如下所示:
可以看到,Q1掃描了10萬行,顯然是走了全表掃描,執行時間是40毫秒。Q2掃描了10001行,執行了21毫秒。也就是說,我們在沒有使用force index的時候,MySQL用錯了索引,導致了更長的執行時間。
這個例子對應的是我們平常不斷地刪除歷史數據和新增數據的場景。這時,MySQL竟然會選錯索引,是不是有點奇怪呢?
我們之前的文章講到,索引的選擇是優化器的工作,優化器選擇索引的目的,是找到一個最優的執行方案,並用最小的代價去執行語句。在數據庫里面,掃描行數是影響執行代價的因素之一。掃描的行數越少,意味着訪問磁盤數據的次數越少,消耗的CPU資源越少。然而,掃描行數並不是唯一的判斷標准,優化器還會結合是否使用臨時表、是否排序等因素進行綜合判斷。
我們這個簡單的查詢語句並沒有涉及到臨時表和排序,所以MySQL選錯索引肯定是在判斷掃描行數的時候出問題了。
那么問題來了,掃描行數是如何判斷的呢?
其實MySQL在真正開始執行語句之前,並不能精確地知道滿足這個條件的記錄有多少條,而只能根據統計信息來估算記錄數。這個統計信息就是索引的“區分度”。顯然,一個索引上不同的值越多,這個索引的區分度就越好。而一個索引上不同的值的個數,我們稱之為“基數”(cardinality)。也就是說,這個基數越大,索引的區分度越好。
我們可以使用show index方法,看到一個索引的基數。如圖4所示,就是表t的show index 的結果 。雖然這個表的每一行的三個字段值都是一樣的,但是在統計信息中,這三個索引的基數值並不同,而且其實都不准確。
SHOW INDEX FROM t;
那么,MySQL是怎樣得到索引的基數的呢?其實MySQL采樣統計的方法。
為什么要采樣統計呢?
因為把整張表取出來一行行統計,雖然可以得到精確的結果,但是代價太高了,所以只能選擇“采樣統計”。采樣統計的時候,InnoDB默認會選擇N個數據頁,統計這些頁面上的不同值,得到一個平均值,然后乘以這個索引的頁面數,就得到了這個索引的基數。而數據表是會持續更新的,索引統計信息也不會固定不變。所以,當變更的數據行數超過1/M的時候,會自動觸發重新做一次索引統計。
在MySQL中,有兩種存儲索引統計的方式,可以通過設置參數innodb_stats_persistent的值來選擇:
- 設置為on的時候,表示統計信息會持久化存儲。這時,默認的N是20,M是10。
- 設置為off的時候,表示統計信息只存儲在內存中。這時,默認的N是8,M是16。
由於是采樣統計,所以不管N是20還是8,這個基數都是很容易不准的。
從 上圖中看到,這次的索引統計值(cardinality列)雖然不夠精確,但大體上還是差不多的,選錯索引一定還有別的原因。
其實索引統計只是一個輸入,對於一個具體的語句來說,優化器還要判斷,執行這個語句本身要掃描多少行。
接下來,我們再一起看看優化器預估的,這兩個語句的掃描行數是多少。
rows這個字段表示的是預計掃描行數。其中,Q1的結果還是符合預期的,rows的值是104620;但是Q2的rows值是37116,偏差就大了。而圖1中我們用explain命令看到的rows是只有10001行,是這個偏差誤導了優化器的判斷。
化器為什么放着掃描37000行的執行計划不用,卻選擇了掃描行數是100000的執行計划呢?
這是因為,如果使用索引a,每次從索引a上拿到一個值,都要回到主鍵索引上查出整行數據,這個代價優化器也要算進去的。而如果選擇掃描10萬行,是直接在主鍵索引上掃描的,沒有額外的代價。優化器會估算這兩個選擇的代價,從結果看來,優化器認為直接掃描主鍵索引更快。當然,從執行時間看來,這個選擇並不是最優的。
使用普通索引需要把回表的代價算進去,在圖1執行explain的時候,也考慮了這個策略的代價 ,但圖1的選擇是對的。也就是說,這個策略並沒有問題。
MySQL選錯索引,這件事兒還得歸咎到沒能准確地判斷出掃描行數。既然是統計信息不對,那就修正。analyze table t 命令,可以用來重新統計索引信息。我們來看一下執行效果。
從圖中看,結果正確了,因此在實踐中,如果你發現explain的結果預估的rows值跟實際情況差距比較大,可以采用這個方法來處理。如果只是索引統計不准確,通過analyze命令可以解決很多問題,但是前面我們說了,優化器可不止是看掃描行數。例如下面這個例子,依然是基於表 t,語句如下:
select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
從條件上看,這個查詢沒有符合條件的記錄,因此會返回空集合。在開始執行這條語句之前,你可以先設想一下,如果你來選擇索引,會選擇哪一個呢?
為了便於分析,我們先來看一下a、b這兩個索引的結構圖。
如果使用索引a進行查詢,那么就是掃描索引a的前1000個值,然后取到對應的id,再到主鍵索引上去查出每一行,然后根據字段b來過濾。顯然這樣需要掃描1000行。
如果使用索引b進行查詢,那么就是掃描索引b的最后50001個值,與上面的執行過程相同,也是需要回到主鍵索引上取值再判斷,所以需要掃描50001行。
所以你一定會想,如果使用索引a的話,執行速度明顯會快很多。那么,下面我們就來看看到底是不是這么一回事兒。
可以看到,返回結果中key字段顯示,這次優化器選擇了索引b,而rows字段顯示需要掃描的行數是50128。
因此可以得到的結論是:
-
掃描行數的估計值依然不准確;
-
這個例子里MySQL又選錯了索引。
十一、索引選擇異常與處理
第一種方法是,采用force index強行選擇一個索引。MySQL會根據詞法解析的結果分析出可能可以使用的索引作為候選項,然后在候選列表中依次判斷每個索引需要掃描多少行。如果force index指定的索引在候選索引列表中,就直接選擇這個索引,不再評估其他索引的執行代價。例子如下:
可以看到,原本語句需要執行2.23秒,而當你使用force index(a)的時候,只用了0.05秒,比優化器的選擇快了40多倍。也就是說,優化器沒有選擇正確的索引,force index起到了“矯正”的作用。
不過很多程序員不喜歡使用force index,一來這么寫不優美,二來如果索引改了名字,這個語句也得改,顯得很麻煩。而且如果以后遷移到別的數據庫的話,這個語法還可能會不兼容。
其實使用force index最主要的問題還是變更的及時性。因為選錯索引的情況還是比較少出現的,所以開發的時候通常不會先寫上force index。而是等到線上出現問題的時候,你才會再去修改SQL語句、加上force index。但是修改之后還要測試和發布,對於生產系統來說,這個過程不夠敏捷。
第二種方法就是,我們可以考慮修改語句,引導MySQL使用我們期望的索引。比如,在這個例子里,顯然把“order by b limit 1” 改成 “order by b,a limit 1” ,語義的邏輯是相同的。效果如下圖所示:
之前優化器選擇使用索引b,是因為它認為使用索引b可以避免排序(b本身是索引,已經是有序的了,如果選擇索引b的話,不需要再做排序,只需要遍歷),所以即使掃描行數多,也判定為代價更小。現在order by b,a 這種寫法,要求按照b,a排序,就意味着使用這兩個索引都需要排序。因此,掃描行數成了影響決策的主要條件,於是此時優化器選了只需要掃描1000行的索引a。
這種修改並不是通用的優化手段,只是剛好在這個語句里面有limit 1,因此如果有滿足條件的記錄, order by b limit 1和order by b,a limit 1 都會返回b是最小的那一行,邏輯上一致,才可以這么做。
如果你覺得修改語義這件事兒不太好,這里還有一種改法,SQL腳本如下:
mysql> select * from (select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 100)alias limit 1;
在這個例子里,我們用limit 100讓優化器意識到,使用b索引代價是很高的。其實是我們根據數據特征誘導了一下優化器,也不具備通用性。
第三種方法是,在有些場景下,我們可以新建一個更合適的索引,來提供給優化器做選擇,或刪掉誤用的索引。