要了解數據庫索引的底層原理,我們就得先了解一種叫樹的數據結構,而樹中很經典的一種數據結構就是二叉樹!所以下面我們就從二叉樹到平衡二叉樹,再到B-樹,最后到B+樹來一步一步了解數據庫索引底層的原理!
二叉樹(Binary Search Trees)
二叉樹是每個結點最多有兩個子樹的樹結構。通常子樹被稱作“左子樹”(left subtree)和“右子樹”(right subtree)。二叉樹常被用於實現二叉查找樹和二叉堆。二叉樹有如下特性:
1、每個結點都包含一個元素以及n個子樹,這里0≤n≤2。
2、左子樹和右子樹是有順序的,次序不能任意顛倒。左子樹的值要小於父結點,右子樹的值要大於父結點。
光看概念有點枯燥,假設我們現在有這樣一組數[35 28 49 13 30 37 60],順序的插入到一個數的結構中,步驟如下
圖1
圖2
圖3
圖4
圖3是錯誤示范,請大家在分析二叉樹的時候不要犯這個錯誤。
好了,這就是一棵二叉樹啦!我們能看到,經通過一系列的插入操作之后,原本無序的一組數已經變成一個有序的結構了,並且這個樹滿足了上面提到的兩個二叉樹的特性!
但是如果同樣是上面那一組數,我們自己升序排列后再插入,會怎么樣呢?
由於是升序插入,新插入的數據總是比已存在的結點數據都要大,所以每次都會往結點的右邊插入,最終導致這棵樹嚴重偏科!!!上圖就是最壞的情況,也就是一棵樹退化為一個線性鏈表了,這樣查找效率自然就低了,完全沒有發揮樹的優勢了呢!為了較大發揮二叉樹的查找效率,讓二叉樹不再偏科,保持各科平衡,所以有了平衡二叉樹!
平衡二叉樹 (AVL Trees)
平衡二叉樹是一種特殊的二叉樹,所以他也滿足前面說到的二叉樹的兩個特性,同時還有一個特性:
它的左右兩個子樹的高度差的絕對值不超過1,並且左右兩個子樹都是一棵平衡二叉樹。
大家也看到了前面[35 28 49 13 30 37 60]插入完成后的圖,其實就已經是一顆平衡二叉樹啦。
那如果按照[35 28 49 13 30 37 60]的順序插入一顆平衡二叉樹,會怎么樣呢?我們看看插入以及平衡的過程:
整個插入的以及平衡的過程就如上圖所示。右子樹與左子樹的高度差絕對值大於1就需要調整。
這棵樹始終滿足平衡二叉樹的幾個特性而保持平衡!這樣我們的樹也不會退化為線性鏈表了!我們需要查找一個數的時候就能沿着樹根一直往下找,這樣的查找效率和二分法查找是一樣的呢!
一顆平衡二叉樹能容納多少的結點呢?這跟樹的高度是有關系的,假設樹的高度為h,那每一層最多容納的結點數量為2^(n-1),整棵樹最多容納節點數為2^0+2^1+2^2+...+2^(h-1)。這樣計算,100w數據樹的高度大概在20左右,那也就是說從有着100w條數據的平衡二叉樹中找一個數據,最壞的情況下需要20次查找。如果是內存操作,效率也是很高的!但是我們數據庫中的數據基本都是放在磁盤中的,每讀取一個二叉樹的結點就是一次磁盤IO,這樣我們找一條數據如果要經過20次磁盤的IO?那性能就成了一個很大的問題了!那我們是不是可以把這棵樹壓縮一下,讓每一層能夠容納更多的節點呢?雖然我矮,但是我胖啊...
B-Tree
這顆矮胖的樹就是B-Tree,注意中間是杠精的杠而不是減,所以也不要讀成B減Tree了~
那B-Tree有哪些特性呢?一棵m階的B-Tree有如下特性:
1、每個結點最多m個子結點。
2、除了根結點和葉子結點外,每個結點最少有m/2(向上取整)個子結點。
3、如果根結點不是葉子結點,那根結點至少包含兩個子結點。
4、所有的葉子結點都位於同一層。
5、每個結點都包含k個元素(關鍵字),這里m/2≤k<m,這里m 2向下取整。
7、每個元素(關鍵字)字左結點的值,都小於或等於該元素(關鍵字)。右結點的值都大於或等於該元素(關鍵字)。
是不是感覺很懵逼!下面我們以一個[0,1,2,3,4,5,6,7]的數組插入一顆3階的B-Tree為例,將所有的條件都串起來,你就明白了!
不管裂變前后,所有葉子結點都在同一層,滿足第4點特性,並且子葉結點的數量也滿足第2點特性。
在二叉樹中,每個結點只有一個元素。但是在B-Tree中,每個結點都可能包含多個元素,並且非葉子結點在元素的左右都有指向子結點的指針。
如果需要在B-Tree查找一個元素,那流程是怎么樣的呢?我們看下圖,如果我們要在下面的B-Tree中找到關鍵字24,那流程如下:
16<24<26所以數據在16和26中間找,然后就找到了24
從這個流程我們能看出,B-Tree的查詢效率好像也並不比平衡二叉樹高。但是查詢所經過的結點數量要少很多,也就意味着要少很多次的磁盤IO,這對 性能的提升是很大的。
前面對B-Tree操作的圖我們能看出來,元素就是類似1、2、3這樣的數值,但是數據庫的數據都是一條條的數據,如果某個數據庫以B-Tree的數據結構存儲數據,那數據怎么存放的呢?我們看下一張圖
普通的B-Tree的結點中,元素就是一個個的數字。但是上圖中,我們把元素部分拆分成了key-data的形式,key(數字)就是數據的主鍵,data就是具體的數據。這樣我們在找一條數的時候,就沿着根結點往下找就ok了,效率是比較高的。
B+Tree
B+Tree是在B-Tree基礎上的一種優化,使其更適合實現外存儲索引結構。B+Tree與B-Tree的結構很像,但是也有幾個自己的特性:
1、所有的非葉子節點只存儲關鍵字信息。
2、所有衛星數據(具體數據)都存在葉子結點中。
3、所有的葉子結點中包含了全部元素的信息。
4、所有葉子節點之間都有一個鏈指針。
如果上面B-Tree的圖變成B+Tree,那應該如下:
注意紅色標記框2中的箭頭是雙向的,懶得改了,聲明一下。請注意一下的該截圖中標記的1、2兩處。
b+圖1
大家仔細對比於B-Tree的圖能發現什么不同?
1、非葉子結點上已經只有key信息了,滿足上面第1點特性!
2、所有葉子結點下面都有一個data區域,滿足上面第2點特性!
3、非葉子結點的數據在葉子結點上都能找到,如根結點的元素4、8在最底層的葉子結點上也能找到,滿足上面第3點特性!
4、注意圖中葉子結點之間的箭頭,滿足滿足上面第4點特性!
以上圖為例,我們來講解一下這顆B+Tree。Mysql的底層規定一個節點是16kB,在節點的每個元素存儲的值是這樣的【索引(比如數字15)+指向下一個子結點的指針(磁盤文件指針)(15后面的空白)】,在B+Tree中只在葉子節點存儲數據,其他子結點存儲的都是索引以及指針,這樣的話在有限的節點里面,存儲的索引就大大的增加了,樹的階數可以變得更小了,在數據量在千萬級的情況下,對數據的檢索時間將大大的減少。下面我們來具體的說明一下。假設上圖中的索引是8位的整型(8B),我們的指針占的位置為(4B),那我們一個元素所占的內存空間就12B,一個節點我們可以放多少索引呢?是((16KB*1024B/KB)/12B=1365),一個節點可以存放1365個索引(元素)。假設我們葉子結點存儲的(data+索引)為1K,那么我們上圖中的3階樹可以存放多少數據呢?我們來計算一下1365*1365*16=29811600,可以存放接近3千萬的數據,是不是覺得存儲量很大,效率很高。同樣的索引來查找,用B+tree和B-Tree有啥差別?我們來看一下,B-Tree的前面講過了不講了。現在我們根據索引來查找30,同樣的從根結點開始,15<30<56在根節點的左子樹上,20<30<49在這之間,繼續往下找,然后你就找到了30 下的一條條數據。注意,在查找的時候我們是不是都進行了比較,B+Tree只是比較索引,但是B-Tree是帶着數據的索引進行比較,速度誰更快不用講。
在看B+Tree的時候“b+圖1”中的紅色標記1,大家都注意到了20的data在20的右子樹的葉子結點上,這個稱之為冗余結點,為什么要這么設計呢,這就引出另外一個問題,那就是圖中的紅色標記2,雙向箭頭。其實葉子結點 除了存儲指針還存儲了前后葉子結點的鏈針,這個鏈針和冗余結點的好處就是方便我們的范圍查找,同時檢索效率也大大的提高了。
B-Tree or B+Tree?
在講這兩種數據結構在數據庫中的選擇之前,我們還需要了解的一個知識點是操作系統從磁盤讀取數據到內存是以磁盤塊(block)為基本單位的,位於同一個磁盤塊中的數據會被一次性讀取出來,而不是需要什么取什么。即使只需要一個字節,磁盤也會從這個位置開始,順序向后讀取一定長度的數據放入內存。這樣做的理論依據是計算機科學中著名的局部性原理:當一個數據被用到時,其附近的數據也通常會馬上被使用。
預讀的長度一般為頁(page)的整倍數。頁是計算機管理存儲器的邏輯塊,硬件及操作系統往往將主存和磁盤存儲區分割為連續的大小相等的塊,每個存儲塊稱為一頁(在許多操作系統中,頁得大小通常為4k)。
B-Tree和B+Tree該如何選擇呢?都有哪些優劣呢?
1、B-Tree因為非葉子結點也保存具體數據,所以在查找某個關鍵字的時候找到即可返回。而B+Tree所有的數據都在葉子結點,每次查找都得到葉子結點。所以在同樣高度的B-Tree和B+Tree中,B-Tree查找某個關鍵字的效率更高。
2、由於B+Tree所有的數據都在葉子結點,並且結點之間有指針連接,在找大於某個關鍵字或者小於某個關鍵字的數據的時候,B+Tree只需要找到該關鍵字然后沿着鏈表遍歷就可以了,而B-Tree還需要遍歷該關鍵字結點的根結點去搜索。
3、由於B-Tree的每個結點(這里的結點可以理解為一個數據頁)都存儲主鍵+實際數據,而B+Tree非葉子結點只存儲關鍵字信息,而每個頁的大小有限是有限的,所以同一頁能存儲的B-Tree的數據會比B+Tree存儲的更少。這樣同樣總量的數據,B-Tree的深度會更大,增大查詢時的磁盤I/O次數,進而影響查詢效率。
鑒於以上的比較,所以在常用的關系型數據庫中,都是選擇B+Tree的數據結構來存儲數據!下面我們以mysql的innodb存儲引擎為例講解,其他類似sqlserver、oracle的原理類似!
延伸拓展:
Hash數據結構存儲
在mysql中建立索引的時候,可以選擇是用hash,還是B+Tree.Hash是這樣的,當你存入索引的時候會通過hash算法生成一個hashCode,在存入內存的時候就將你的這個hashCode和地址指針配對存入。這樣做的好處就是當你在查詢的時候,能夠快速的定位,根本不需要去比較查找,只要根據這個值通過hash算法就可以找到你要的數據了。但是這種結構在實際應用中基本上是很少用的(至少我是沒遇到過),為什么呢?因為在實際的應用中我們的sql語句查詢用的比較多的是范圍查找而不是精准定位。
綜上所述,B+Tree成為了mysql,oracle等數據庫的底層數據結構的不二之選。
索引的底層數據結構和算法
聚集索引&非聚集索引
知道數據庫基本的數據結構以后,下面我們來了解索引的底層數據結構和算法,用mySql為例。
MySql 有兩種索引引擎:MyISAM索引實現(非聚集),innodb索引實現(聚集索引);存儲引擎是形容數據的表的。
MyISAM索引實現(非聚集)
這種索引下面會有三個文檔,分別是:
MyISAM存儲引擎是形容數據的表的:
xxx_MyISAM.frm :存儲表定義結構
xxx_MyISAM.myd :存儲表數據
xxx_MyISAM.myi :存儲表的索引
從以上三個文件結合B+Tree的知識,大家就會很清楚的知道,MyISAM索引將表結構、索引、數據是分開在三個文件中進行存儲的。
以下圖為例,我們來進行講解:
從上圖中紅色方框標記可以看到MyISAM的葉子結點中除了索引以外,存儲的的不是data而是地址指針,在找數據的時候是通過地址指針去尋找的數據。除此之外索引的也是分開存儲的(紅色圓形標記),所以說MyISAM是非聚集的。
非聚集索引的存儲結構與前面是一樣的,不同的是在葉子結點的數據部分存的不再是具體的數據,而數據的聚集索引的key。所以通過非聚集索引查找的過程是先找到該索引key對應的聚集索引的key,然后再拿聚集索引的key到主鍵索引樹上查找對應的數據,這個過程稱為回表!
innodb引擎數據存儲(聚集)
Innodb引擎下面有以下兩種文件:
xxx.frm: 存儲表定義結構
xxx.ibd:存儲表數據和表的索引
它的聚集結構我們來看一下下圖:
圖innoDB-1
從上圖中我們可以看到InnoDB的存儲跟那兩個文件是保持一致的,它的葉子結點存儲的就是索引+完整的數據。
如圖圖innoDB-1所示,如果我們插入一個28,那么這個數應該插入在20與30之間,這樣的話相應的(20,30)前后指針就需要變化,如果我們再插入一個29的話我們的B+TREE就需要分裂,一旦分裂的話,整顆樹就要重構,因為指針地址變更。數據量大的時候樹的重構是非常耗時的,所以innodb要求主鍵自增!
在InnoDB存儲引擎中,也有頁的概念,默認每個頁的大小為16K,也就是每次讀取數據時都是讀取4*4k的大小!假設我們現在有一個用戶表,我們往里面寫數據。這里需要注意的一點是,在某個頁內插入新行時,為了不減少數據的移動,通常是插入到當前行的后面或者是已刪除行留下來的空間,所以在某一個頁內的數據並不是完全有序的,但是為了數據訪問順序性,在每個記錄中都有一個指向下一條記錄的指針,以此構成了一條單向有序鏈表。
這棵樹的非葉子結點上存的都是主鍵,那如果一個表沒有主鍵會怎么樣?在innodb中,如果一個表沒有主鍵,那默認會找建了唯一索引的列,如果也沒有,則會生成一個隱形的字段作為主鍵!
有數據插入那就有刪除,如果這個用戶表頻繁的插入和刪除,那會導致數據頁產生碎片,頁的空間利用率低,還會導致樹變的“虛高”,降低查詢效率!這可以通過索引重建來消除碎片提高查詢效率!
主鍵自增寫入時新插入的數據不會影響到原有頁,插入效率高!且頁的利用率高!但是如果主鍵是無序的或者隨機的,那每次的插入可能會導致原有頁頻繁的分裂,影響插入效率!降低頁的利用率!這也是為什么在innodb中建議設置主鍵自增的原因!
innodb引擎數據查找
數據插入了怎么查找呢?
1、找到數據所在的頁。這個查找過程就跟前面說到的B+Tree的搜索過程是一樣的,從根結點開始查找一直到葉子結點。
2、在頁內找具體的數據。讀取第1步找到的葉子結點數據到內存中,然后通過分塊查找的方法找到具體的數據。
這跟我們在新華字典中找某個漢字是一樣的,先通過字典的索引定位到該漢字拼音所在的頁,然后到指定的頁找到具體的漢字。innodb中定位到頁后用了哪種策略快速查找某個主鍵呢?這我們就需要從頁結構開始了解。
左邊藍色區域稱為Page Directory,這塊區域由多個slot組成,是一個稀疏索引結構,即一個槽中可能屬於多個記錄,最少屬於4條記錄,最多屬於8條記錄。槽內的數據是有序存放的,所以當我們尋找一條數據的時候可以先在槽中通過二分法查找到一個大致的位置。
右邊區域為數據區域,每一個數據頁中都包含多條行數據。注意看圖中最上面和最下面的兩條特殊的行記錄Infimum和Supremum,這是兩個虛擬的行記錄。在沒有其他用戶數據的時候Infimum的下一條記錄的指針指向Supremum,當有用戶數據的時候,Infimum的下一條記錄的指針指向當前頁中最小的用戶記錄,當前頁中最大的用戶記錄的下一條記錄的指針指向Supremum,至此整個頁內的所有行記錄形成一個單向鏈表。
行記錄被Page Directory邏輯的分成了多個塊,塊與塊之間是有序的,也就是說“4”這個槽指向的數據塊內最大的行記錄的主鍵都要比“8”這個槽指向的數據塊內最小的行記錄的主鍵要小。但是塊內部的行記錄不一定有序。
每個行記錄的都有一個nowned的區域(圖中粉紅色區域),nowned標識這個這個塊有多少條數據,偽記錄Infimum的nowned值總是1,記錄Supremum的nowned的取值范圍為[1,8],其他用戶記錄nowned的取值范圍[4,8],並且只有每個塊中最大的那條記錄的nowned才會有值,其他的用戶記錄的n_owned為0。
所以當我們要找主鍵為6的記錄時,先通過二分法在稀疏索引中找到對應的槽,也就是Page Directory中“8”這個槽,“8”這個槽指向的是該數據塊中最大的記錄,而數據是單向鏈表結構所以無法逆向查找,所以需要找到上一個槽即“4”這個槽,然后通過“4”這個槽中最大的用戶記錄的指針沿着鏈表順序查找到目標記錄。
InnoDB的數據引擎同時要求主鍵盡量是整型,為什么?我們下面來分析一下
假設我們的主鍵是英文字符串,那么innodb數據引擎在進行b+Tree的構建的時候就會將英文字母翻譯成數字,比如as,(假如:a=65,s=83)那么innodb會將其翻譯成6583,6583就是主鍵,中間的一次翻譯就會導致效率的降低。所以InnoDB數據引擎要求主鍵盡量是整型。
當存在多個索引的時候具體的聚集結構是什么樣的呢,如下圖所示:
當你又多個索引的時候就將你的索引排序就可以了。
innodb與MyISAM兩種存儲引擎對比
那MyISAM與innodb在存儲上有啥不同呢,根據以上的圖我們能看到的不同是
1、MyISAM主鍵索引樹的葉子結點的數據區域沒有存放實際的數據,存放的是數據記錄的地址。
2、MyISAM數據的存儲不是按主鍵順序存放的,按寫入的順序存放。
也就是說innodb引擎數據在物理上是按主鍵順序存放,而MyISAM引擎數據在物理上按插入的順序存放。並且MyISAM的葉子結點不存放數據,所以非聚集索引的存儲結構與聚集索引類似,在使用非聚集索引查找數據的時候通過非聚集索引樹就能直接找到數據的地址了,不需要回表,這比innodb的搜索效率會更高呢!
以上是個人的理解,僅供參考,望與各位碼農共勉,不喜勿噴,謝謝。