插入后表中的數(shù)據(jù)如下:不知你有沒發(fā)現(xiàn)我們在插入的時候并沒有指定 id 值,但 InnoDB 為每條記錄默認添加了一個 id 值,而且這個 id 值是遞增的,每插入一條記錄,id 遞增 1,id 為什么要遞增呢,主要是為了查詢方便,每條記錄按 id 由小到大的順序用鏈表連接起來,這樣每次查找 id = xxx 的值就從 id = 1 開始依次往后查找即可現(xiàn)在假設(shè)我們要執(zhí)行以下 SQL 語句,MySQL 會怎么查詢呢
select?*?from?user?where?id?=?3
頁
如前所述,首先從 id 最小的記錄也就是 id = 1 讀起,每次讀一條記錄,將其 id 值與要查詢的值比較,連續(xù)讀三次記錄于是找到了記錄 3,注意這個讀的操作,是首先需要把存儲在磁盤的記錄讀取到內(nèi)存然后再比較 id 的,從磁盤讀到內(nèi)存算一次 IO,也就是說此過程中產(chǎn)生了三次 IO。如果只是幾條記錄還好,但如果要比較的條數(shù)多的話對性能是非常嚴重的挑戰(zhàn),如果我要查詢?yōu)?id = 100 的記錄那豈不是要產(chǎn)生 100 次 IO?既然瓶頸在 IO,那該怎么改進呢?很簡單,我們現(xiàn)在的設(shè)計一次 IO 只能讀一條記錄,那改為一次 IO 能讀取 100 條甚至更多不就只產(chǎn)生一次 IO 了嗎?這背后的思想就是程序局部性原理:當用到了某項數(shù)據(jù)時,很可能會用到與之相鄰的數(shù)據(jù),所以干脆把相依的數(shù)據(jù)一起加載進去(你從 id = 1 開始讀,那很可能用到 id = 1 緊隨其后的元素,于是干脆把 id = 1 ~ id = 100 的記錄都加載進去)當然一次 IO 的讀取記錄也并不是多多益善,總不能為了一條查詢記錄而把很多無關(guān)的數(shù)據(jù)都加載到內(nèi)存吧,那會造成資源的極大浪費,于是我們采用了一個比較折中的方案。我們規(guī)定一次 IO 讀取 16 K 的數(shù)據(jù),假設(shè)為 100 條數(shù)據(jù)好了,這樣如果我們要查詢 id = 100 的記錄,只產(chǎn)生了一次 IO 讀(id=1~id=100 的記錄),比起原來的 100 次 IO 提升了 100 倍的性能我們把這 16KB 的記錄組合稱為一個頁
頁目錄
一次 IO 會讀取一個頁,然后再在內(nèi)存里查找頁里的記錄,在內(nèi)存里查找確實比磁盤快多了,但我們?nèi)圆粷M意,因為如果要查找 id = 100 的記錄,要先從 ?id = 1 的記錄比較起,然后是id=2,…,id=100,需要比較 100 次,能否更快一點?可以參照二分查找,先查找 id = (1+100)/2 ?= 50,由于 50 < 100,接著在 ?50~100 的記錄中查,然后再在 75~100 中查,這樣經(jīng)過 7 次就可找到 id = 100 次的記錄,比起原來的 100 次比較又提升了不少性能。但現(xiàn)在問題來了,第一次要找到 id = ?50 的記錄又得從 id = 1 開始遍歷 50 次才能找到,能否一下就定位到 id=50 的記錄呢,如果不能,哪怕第一次從 id = 30 或 40 開始查找也行啊有什么數(shù)據(jù)結(jié)構(gòu)能滿足這種需求呢,還記得跳表不,每隔 n 個元素抽出一個組成一級索引,每隔 2*n 個元素組成二級索引。。。如圖示,以建立一級索引為例,我們在查找的時候先在一級索引查找,在一級索引里定位到了再到鏈表里查找,比如我們要找 7 這個數(shù)字,如果不用跳表直接在鏈表里查,需要比較 7 次。而如果用了跳表我們先在一級索引查找,發(fā)現(xiàn)只要比較 3 次,減少了四次,所以我們可以利用跳表的思想來減少查詢次數(shù),具體操作如下,每 4 個元素為一組組成一個槽(slot),槽只記錄本組元素最大的那條記錄以及記錄本組有幾條記錄現(xiàn)在假設(shè)我們想要定位 id = 9 的那條記錄,該怎么做呢,很簡單:首先定位記錄在哪個槽,然后遍歷此槽中的元素
定位在哪個槽,首先取最小槽和最大槽對應(yīng)的 id(分別為 4, 12),先通過二分查找取它們的中間值為 (4+12)/2 = 8,8 小于 9,且槽 2 的最大 id 為 12,所以可知 id = 9 的記錄在槽 2 里
遍歷槽 2 中的元素,現(xiàn)在問題來了,我們知道每條記錄都構(gòu)成了一個單鏈表,而每個槽指向的是此分組中的最大 id 值,該怎么從此槽的第一個元素開始遍歷呢,很簡單,從槽 1 開始遍歷不就行了,因為它指向元素的下一個元素即為槽 2 的起始元素,遍歷后發(fā)現(xiàn)槽 2 的 第一個元素即為我們找到的 id 為 9 的元素
可以看到通過這種方式在頁內(nèi)很快把我們的元素定位出來了,MySQL 規(guī)定每個槽中的元素在 1~8 條,所以只要定位了在哪個槽,剩下的比較就不是什么問題了。當然一個頁裝的記錄終究是有限的,如果頁滿了,就要要開辟另外的頁來裝記錄了,頁與頁之間通過鏈表連接起來,但注意看下圖,為啥要用雙向鏈表連接起來呢?別忘了最開頭我們列出的 「order by id asc 」和「order by id desc 」這兩個查詢條件,也就是說記錄需要同時支持正序與逆序查找,這就是為什么要使用雙向鏈表的原因。
相信你已經(jīng)發(fā)現(xiàn)了,上文中我們舉的 B+ 樹的例子針對的是 id 也就是主鍵的索引,不難發(fā)現(xiàn)主鍵索引中的葉子結(jié)點存儲了完整的 SQL 記錄,我們把這種存儲了完整記錄的索引稱為聚簇索引,只要你定義了主鍵,那么主鍵索引就是聚簇索引。那么如果是非主鍵的列創(chuàng)建的索引又是怎樣的形式呢,非葉子節(jié)點的形式完全一樣,但葉子節(jié)點的存儲則有些不同,非主鍵列索引葉子節(jié)點上存儲的是索引列及主鍵值,比如我們假設(shè)對 age 這個列建立了索引,那么它的索引樹如下可以看到非葉子節(jié)點保存的是「age 值 + 頁碼」,而葉子節(jié)點保存的是 「age 值+主鍵值」,那么你可能就會疑惑了,如下 SQL 是怎么取出完整記錄的呢
select?*?from?user?where?age?=?xxx
第一步大家都知道,上述 SQL 可以命中 age 列對應(yīng)的索引,然后找到葉子節(jié)點上對應(yīng)的記錄(如果有的話),但葉子節(jié)點上的記錄只有 age 和 id 這兩列,而你用的是 select *,意味著要查找 user 的所有列信息,該怎么辦呢。答案是根據(jù)拿到的 id 再到聚簇索引找 id 對應(yīng)的完整記錄,這就是我們所說的回表,如果回表多的話顯然會造成一定的性能問題,因為 id 可能分布在不同的頁中,這意味著要將不同的頁從磁盤讀入內(nèi)存,這些頁很可能不是相鄰的,也就意味著會造成大量的隨機 IO,會嚴重地影響性能。看到這相信大家不難明白一道高頻面試題:為什么設(shè)置了命中了索引但還是造成了全表掃描,其中一個原因就是雖然命中了索引但在葉子節(jié)點查詢到記錄后還要大量的回表,導致優(yōu)化器認為這種情況還不如全表掃描會更快些有人可能會問,為啥都二級索引不存儲完整的記錄呢,當然是為了節(jié)省空間,畢竟完整的數(shù)據(jù)是很耗空間的,如果每加一個索引都要額外存儲完整的記錄,那會造成很多數(shù)據(jù)冗余。怎么避免這種情況呢?索引覆蓋,如果如下 SQL 滿足你的需求,那么就建議采用如下形式
看完本文相信大家能明白索引的由來了。此外對頁以及磁盤預讀對性能的提升應(yīng)該也有不少了解,其實 MySQL 的頁結(jié)構(gòu)與我們推演的結(jié)構(gòu)有些許出入。不過不影響整體的理解,如果大家有興趣深入了解 MySQL 的頁結(jié)構(gòu),強烈建議大家看看文末的<MySQL是怎樣運行的>這本書,講解得非常細致