作為一個(gè)軟件開(kāi)發(fā)工程師,你對(duì)數(shù)據(jù)庫(kù)肯定再熟悉不過(guò)了。作為主流的數(shù)據(jù)存儲(chǔ)系統(tǒng),它在我們的業(yè)務(wù)開(kāi)發(fā)中,有著舉足輕重的地位。在工作中,為了加速數(shù)據(jù)庫(kù)中數(shù)據(jù)的查找速度,我們常用的處理思路是,對(duì)表中數(shù)據(jù)創(chuàng)建索引。那你是否思考過(guò),數(shù)據(jù)庫(kù)索引是如何實(shí)現(xiàn)的呢?底層使用的是什么數(shù)據(jù)結(jié)構(gòu)和算法呢?
算法解析
思考的過(guò)程比結(jié)論更重要。跟著我學(xué)習(xí)了這么多節(jié)課,很多同學(xué)已經(jīng)意識(shí)到這一點(diǎn),比如 Jerry 銀銀同學(xué)。我感到很開(kāi)心。所以,今天的講解,我會(huì)盡量還原這個(gè)解決方案的思考過(guò)程,讓你知其然,并且知其所以然。
1. 解決問(wèn)題的前提是定義清楚問(wèn)題
如何定義清楚問(wèn)題呢?除了對(duì)問(wèn)題進(jìn)行詳細(xì)的調(diào)研,還有一個(gè)辦法,那就是,通過(guò)對(duì)一些模糊的需求進(jìn)行假設(shè),來(lái)限定要解決的問(wèn)題的范圍。
如果你對(duì)數(shù)據(jù)庫(kù)的操作非常了解,針對(duì)我們現(xiàn)在這個(gè)問(wèn)題,你就能把索引的需求定義得非常清楚。但是,對(duì)于大部分軟件工程師來(lái)說(shuō),我們可能只了解一小部分常用的 SQL 語(yǔ)句,所以,這里我們假設(shè)要解決的問(wèn)題,只包含這樣兩個(gè)常用的需求:
根據(jù)某個(gè)值查找數(shù)據(jù),比如 select * from user where id=1234;
根據(jù)區(qū)間值來(lái)查找某些數(shù)據(jù),比如 select * from user where id > 1234 and id < 2345。
除了這些功能性需求之外,這種問(wèn)題往往還會(huì)涉及一些非功能性需求,比如安全、性能、用戶體驗(yàn)等等。限于專欄要討論的主要是數(shù)據(jù)結(jié)構(gòu)和算法,對(duì)于非功能性需求,我們著重考慮性能方面的需求。性能方面的需求,我們主要考察時(shí)間和空間兩方面,也就是執(zhí)行效率和存儲(chǔ)空間。
在執(zhí)行效率方面,我們希望通過(guò)索引,查詢數(shù)據(jù)的效率盡可能地高;在存儲(chǔ)空間方面,我們希望索引不要消耗太多的內(nèi)存空間。
2. 嘗試用學(xué)過(guò)的數(shù)據(jù)結(jié)構(gòu)解決這個(gè)問(wèn)題
問(wèn)題的需求大致定義清楚了,我們現(xiàn)在回想一下,能否利用已經(jīng)學(xué)習(xí)過(guò)的數(shù)據(jù)結(jié)構(gòu)解決這個(gè)問(wèn)題呢?支持快速查詢、插入等操作的動(dòng)態(tài)數(shù)據(jù)結(jié)構(gòu),我們已經(jīng)學(xué)習(xí)過(guò)散列表、平衡二叉查找樹(shù)、跳表。
我們先來(lái)看散列表。散列表的查詢性能很好,時(shí)間復(fù)雜度是 O(1)。但是,散列表不能支持按照區(qū)間快速查找數(shù)據(jù)。所以,散列表不能滿足我們的需求。
我們?cè)賮?lái)看平衡二叉查找樹(shù)。盡管平衡二叉查找樹(shù)查詢的性能也很高,時(shí)間復(fù)雜度是 O(logn)。而且,對(duì)樹(shù)進(jìn)行中序遍歷,我們還可以得到一個(gè)從小到大有序的數(shù)據(jù)序列,但這仍然不足以支持按照區(qū)間快速查找數(shù)據(jù)。
我們?cè)賮?lái)看跳表。跳表是在鏈表之上加上多層索引構(gòu)成的。它支持快速地插入、查找、刪除數(shù)據(jù),對(duì)應(yīng)的時(shí)間復(fù)雜度是 O(logn)。并且,跳表也支持按照區(qū)間快速地查找數(shù)據(jù)。我們只需要定位到區(qū)間起點(diǎn)值對(duì)應(yīng)在鏈表中的結(jié)點(diǎn),然后從這個(gè)結(jié)點(diǎn)開(kāi)始,順序遍歷鏈表,直到區(qū)間終點(diǎn)對(duì)應(yīng)的結(jié)點(diǎn)為止,這期間遍歷得到的數(shù)據(jù)就是滿足區(qū)間值的數(shù)據(jù)。
這樣看來(lái),跳表是可以解決這個(gè)問(wèn)題。實(shí)際上,數(shù)據(jù)庫(kù)索引所用到的數(shù)據(jù)結(jié)構(gòu)跟跳表非常相似,叫作 B+ 樹(shù)。不過(guò),它是通過(guò)二叉查找樹(shù)演化過(guò)來(lái)的,而非跳表。為了給你還原發(fā)明 B+ 樹(shù)的整個(gè)思考過(guò)程,所以,接下來(lái),我還要從二叉查找樹(shù)講起,看它是如何一步一步被改造成 B+ 樹(shù)的。
3. 改造二叉查找樹(shù)來(lái)解決這個(gè)問(wèn)題
為了讓二叉查找樹(shù)支持按照區(qū)間來(lái)查找數(shù)據(jù),我們可以對(duì)它進(jìn)行這樣的改造:樹(shù)中的節(jié)點(diǎn)并不存儲(chǔ)數(shù)據(jù)本身,而是只是作為索引。除此之外,我們把每個(gè)葉子節(jié)點(diǎn)串在一條鏈表上,鏈表中的數(shù)據(jù)是從小到大有序的。經(jīng)過(guò)改造之后的二叉樹(shù),就像圖中這樣,看起來(lái)是不是很像跳表呢?
改造之后,如果我們要求某個(gè)區(qū)間的數(shù)據(jù)。我們只需要拿區(qū)間的起始值,在樹(shù)中進(jìn)行查找,當(dāng)查找到某個(gè)葉子節(jié)點(diǎn)之后,我們?cè)夙樦湵硗蟊闅v,直到鏈表中的結(jié)點(diǎn)數(shù)據(jù)值大于區(qū)間的終止值為止。所有遍歷到的數(shù)據(jù),就是符合區(qū)間值的所有數(shù)據(jù)。
但是,我們要為幾千萬(wàn)、上億的數(shù)據(jù)構(gòu)建索引,如果將索引存儲(chǔ)在內(nèi)存中,盡管內(nèi)存訪問(wèn)的速度非???,查詢的效率非常高,但是,占用的內(nèi)存會(huì)非常多。
比如,我們給一億個(gè)數(shù)據(jù)構(gòu)建二叉查找樹(shù)索引,那索引中會(huì)包含大約 1 億個(gè)節(jié)點(diǎn),每個(gè)節(jié)點(diǎn)假設(shè)占用 16 個(gè)字節(jié),那就需要大約 1GB 的內(nèi)存空間。給一張表建立索引,我們需要 1GB 的內(nèi)存空間。如果我們要給 10 張表建立索引,那對(duì)內(nèi)存的需求是無(wú)法滿足的。如何解決這個(gè)索引占用太多內(nèi)存的問(wèn)題呢?
我們可以借助時(shí)間換空間的思路,把索引存儲(chǔ)在硬盤(pán)中,而非內(nèi)存中。我們都知道,硬盤(pán)是一個(gè)非常慢速的存儲(chǔ)設(shè)備。通常內(nèi)存的訪問(wèn)速度是納秒級(jí)別的,而磁盤(pán)訪問(wèn)的速度是毫秒級(jí)別的。讀取同樣大小的數(shù)據(jù),從磁盤(pán)中讀取花費(fèi)的時(shí)間,是從內(nèi)存中讀取所花費(fèi)時(shí)間的上萬(wàn)倍,甚至幾十萬(wàn)倍。
這種將索引存儲(chǔ)在硬盤(pán)中的方案,盡管減少了內(nèi)存消耗,但是在數(shù)據(jù)查找的過(guò)程中,需要讀取磁盤(pán)中的索引,因此數(shù)據(jù)查詢效率就相應(yīng)降低很多。
二叉查找樹(shù),經(jīng)過(guò)改造之后,支持區(qū)間查找的功能就實(shí)現(xiàn)了。不過(guò),為了節(jié)省內(nèi)存,如果把樹(shù)存儲(chǔ)在硬盤(pán)中,那么每個(gè)節(jié)點(diǎn)的讀取(或者訪問(wèn)),都對(duì)應(yīng)一次磁盤(pán) IO 操作。樹(shù)的高度就等于每次查詢數(shù)據(jù)時(shí)磁盤(pán) IO 操作的次數(shù)。
我們前面講到,比起內(nèi)存讀寫(xiě)操作,磁盤(pán) IO 操作非常耗時(shí),所以我們優(yōu)化的重點(diǎn)就是盡量減少磁盤(pán) IO 操作,也就是,盡量降低樹(shù)的高度。那如何降低樹(shù)的高度呢?
我們來(lái)看下,如果我們把索引構(gòu)建成 m 叉樹(shù),高度是不是比二叉樹(shù)要小呢?如圖所示,給 16 個(gè)數(shù)據(jù)構(gòu)建二叉樹(shù)索引,樹(shù)的高度是 4,查找一個(gè)數(shù)據(jù),就需要 4 個(gè)磁盤(pán) IO 操作(如果根節(jié)點(diǎn)存儲(chǔ)在內(nèi)存中,其他節(jié)點(diǎn)存儲(chǔ)在磁盤(pán)中),如果對(duì) 16 個(gè)數(shù)據(jù)構(gòu)建五叉樹(shù)索引,那高度只有 2,查找一個(gè)數(shù)據(jù),對(duì)應(yīng)只需要 2 次磁盤(pán)操作。如果 m 叉樹(shù)中的 m 是 100,那對(duì)一億個(gè)數(shù)據(jù)構(gòu)建索引,樹(shù)的高度也只是 3,最多只要 3 次磁盤(pán) IO 就能獲取到數(shù)據(jù)。磁盤(pán) IO 變少了,查找數(shù)據(jù)的效率也就提高了。
如果我們將 m 叉樹(shù)實(shí)現(xiàn) B+ 樹(shù)索引,用代碼實(shí)現(xiàn)出來(lái),就是下面這個(gè)樣子(假設(shè)我們給 int 類型的數(shù)據(jù)庫(kù)字段添加索引,所以代碼中的 keywords 是 int 類型的):
/**
* 這是B+樹(shù)非葉子節(jié)點(diǎn)的定義。
*
* 假設(shè)keywords=[3, 5, 8, 10]
* 4個(gè)鍵值將數(shù)據(jù)分為5個(gè)區(qū)間:(-INF,3), [3,5), [5,8), [8,10), [10,INF)
* 5個(gè)區(qū)間分別對(duì)應(yīng):children[0]...children[4]
*
* m值是事先計(jì)算得到的,計(jì)算的依據(jù)是讓所有信息的大小正好等于頁(yè)的大小:
* PAGE_SIZE = (m-1)*4[keywordss大小]+m*8[children大小]
*/
public class BPlusTreeNode {
public static int m = 5; // 5叉樹(shù)
public int[] keywords = new int[m-1]; // 鍵值,用來(lái)劃分?jǐn)?shù)據(jù)區(qū)間
public BPlusTreeNode[] children = new BPlusTreeNode[m];//保存子節(jié)點(diǎn)指針
}
/**
* 這是B+樹(shù)中葉子節(jié)點(diǎn)的定義。
*
* B+樹(shù)中的葉子節(jié)點(diǎn)跟內(nèi)部節(jié)點(diǎn)是不一樣的,
* 葉子節(jié)點(diǎn)存儲(chǔ)的是值,而非區(qū)間。
* 這個(gè)定義里,每個(gè)葉子節(jié)點(diǎn)存儲(chǔ)3個(gè)數(shù)據(jù)行的鍵值及地址信息。
*
* k值是事先計(jì)算得到的,計(jì)算的依據(jù)是讓所有信息的大小正好等于頁(yè)的大小:
* PAGE_SIZE = k*4[keyw..大小]+k*8[dataAd..大小]+8[prev大小]+8[next大小]
*/
public class BPlusTreeLeafNode {
public static int k = 3;
public int[] keywords = new int[k]; // 數(shù)據(jù)的鍵值
public long[] dataAddress = new long[k]; // 數(shù)據(jù)地址
public BPlusTreeLeafNode prev; // 這個(gè)結(jié)點(diǎn)在鏈表中的前驅(qū)結(jié)點(diǎn)
public BPlusTreeLeafNode next; // 這個(gè)結(jié)點(diǎn)在鏈表中的后繼結(jié)點(diǎn)
}
我稍微解釋一下這段代碼。
對(duì)于相同個(gè)數(shù)的數(shù)據(jù)構(gòu)建 m 叉樹(shù)索引,m 叉樹(shù)中的 m 越大,那樹(shù)的高度就越小,那 m 叉樹(shù)中的 m 是不是越大越好呢?到底多大才最合適呢?
不管是內(nèi)存中的數(shù)據(jù),還是磁盤(pán)中的數(shù)據(jù),操作系統(tǒng)都是按頁(yè)(一頁(yè)大小通常是 4KB,這個(gè)值可以通過(guò) getconfig PAGE_SIZE 命令查看)來(lái)讀取的,一次會(huì)讀一頁(yè)的數(shù)據(jù)。如果要讀取的數(shù)據(jù)量超過(guò)一頁(yè)的大小,就會(huì)觸發(fā)多次 IO 操作。所以,我們?cè)谶x擇 m 大小的時(shí)候,要盡量讓每個(gè)節(jié)點(diǎn)的大小等于一個(gè)頁(yè)的大小。讀取一個(gè)節(jié)點(diǎn),只需要一次磁盤(pán) IO 操作。
盡管索引可以提高數(shù)據(jù)庫(kù)的查詢效率,但是,作為一名開(kāi)發(fā)工程師,你應(yīng)該也知道,索引有利也有弊,它也會(huì)讓寫(xiě)入數(shù)據(jù)的效率下降。這是為什么呢?
數(shù)據(jù)的寫(xiě)入過(guò)程,會(huì)涉及索引的更新,這是索引導(dǎo)致寫(xiě)入變慢的主要原因。
對(duì)于一個(gè) B+ 樹(shù)來(lái)說(shuō),m 值是根據(jù)頁(yè)的大小事先計(jì)算好的,也就是說(shuō),每個(gè)節(jié)點(diǎn)最多只能有 m 個(gè)子節(jié)點(diǎn)。在往數(shù)據(jù)庫(kù)中寫(xiě)入數(shù)據(jù)的過(guò)程中,這樣就有可能使索引中某些節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)超過(guò) m,這個(gè)節(jié)點(diǎn)的大小超過(guò)了一個(gè)頁(yè)的大小,讀取這樣一個(gè)節(jié)點(diǎn),就會(huì)導(dǎo)致多次磁盤(pán) IO 操作。我們?cè)撊绾谓鉀Q這個(gè)問(wèn)題呢?
實(shí)際上,處理思路并不復(fù)雜。我們只需要將這個(gè)節(jié)點(diǎn)分裂成兩個(gè)節(jié)點(diǎn)。但是,節(jié)點(diǎn)分裂之后,其上層父節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)就有可能超過(guò) m 個(gè)。不過(guò)這也沒(méi)關(guān)系,我們可以用同樣的方法,將父節(jié)點(diǎn)也分裂成兩個(gè)節(jié)點(diǎn)。這種級(jí)聯(lián)反應(yīng)會(huì)從下往上,一直影響到根節(jié)點(diǎn)。這個(gè)分裂過(guò)程,你可以結(jié)合著下面這個(gè)圖一塊看,會(huì)更容易理解(圖中的 B+ 樹(shù)是一個(gè)三叉樹(shù)。我們限定葉子節(jié)點(diǎn)中,數(shù)據(jù)的個(gè)數(shù)超過(guò) 2 個(gè)就分裂節(jié)點(diǎn);非葉子節(jié)點(diǎn)中,子節(jié)點(diǎn)的個(gè)數(shù)超過(guò) 3 個(gè)就分裂節(jié)點(diǎn))。
正是因?yàn)橐獣r(shí)刻保證 B+ 樹(shù)索引是一個(gè) m 叉樹(shù),所以,索引的存在會(huì)導(dǎo)致數(shù)據(jù)庫(kù)寫(xiě)入的速度降低。實(shí)際上,不光寫(xiě)入數(shù)據(jù)會(huì)變慢,刪除數(shù)據(jù)也會(huì)變慢。這是為什么呢?
我們?cè)趧h除某個(gè)數(shù)據(jù)的時(shí)候,也要對(duì)應(yīng)地更新索引節(jié)點(diǎn)。這個(gè)處理思路有點(diǎn)類似跳表中刪除數(shù)據(jù)的處理思路。頻繁的數(shù)據(jù)刪除,就會(huì)導(dǎo)致某些節(jié)點(diǎn)中,子節(jié)點(diǎn)的個(gè)數(shù)變得非常少,長(zhǎng)此以往,如果每個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)都比較少,勢(shì)必會(huì)影響索引的效率。
我們可以設(shè)置一個(gè)閾值。在 B+ 樹(shù)中,這個(gè)閾值等于 m/2。如果某個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)小于 m/2,我們就將它跟相鄰的兄弟節(jié)點(diǎn)合并。不過(guò),合并之后節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)有可能會(huì)超過(guò) m。針對(duì)這種情況,我們可以借助插入數(shù)據(jù)時(shí)候的處理方法,再分裂節(jié)點(diǎn)。
文字描述不是很直觀,我舉了一個(gè)刪除操作的例子,你可以對(duì)比著看下(圖中的 B+ 樹(shù)是一個(gè)五叉樹(shù)。我們限定葉子節(jié)點(diǎn)中,數(shù)據(jù)的個(gè)數(shù)少于 2 個(gè)就合并節(jié)點(diǎn);非葉子節(jié)點(diǎn)中,子節(jié)點(diǎn)的個(gè)數(shù)少于 3 個(gè)就合并節(jié)點(diǎn)。)。
數(shù)據(jù)庫(kù)索引以及 B+ 樹(shù)的由來(lái),到此就講完了。你有沒(méi)有發(fā)現(xiàn),B+ 樹(shù)的結(jié)構(gòu)和操作,跟跳表非常類似。理論上講,對(duì)跳表稍加改造,也可以替代 B+ 樹(shù),作為數(shù)據(jù)庫(kù)的索引實(shí)現(xiàn)的。
B+ 樹(shù)發(fā)明于 1972 年,跳表發(fā)明于 1989 年,我們可以大膽猜想下,跳表的作者有可能就是受了 B+ 樹(shù)的啟發(fā),才發(fā)明出跳表來(lái)的。不過(guò),這個(gè)也無(wú)從考證了。
總結(jié)引申
今天,我們講解了數(shù)據(jù)庫(kù)索引實(shí)現(xiàn),依賴的底層數(shù)據(jù)結(jié)構(gòu),B+ 樹(shù)。它通過(guò)存儲(chǔ)在磁盤(pán)的多叉樹(shù)結(jié)構(gòu),做到了時(shí)間、空間的平衡,既保證了執(zhí)行效率,又節(jié)省了內(nèi)存。
前面的講解中,為了一步一步詳細(xì)地給你介紹 B+ 樹(shù)的由來(lái),內(nèi)容看起來(lái)比較零散。為了方便你掌握和記憶,我這里再總結(jié)一下 B+ 樹(shù)的特點(diǎn):
每個(gè)節(jié)點(diǎn)中子節(jié)點(diǎn)的個(gè)數(shù)不能超過(guò) m,也不能小于 m/2;
根節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)可以不超過(guò) m/2,這是一個(gè)例外;
m 叉樹(shù)只存儲(chǔ)索引,并不真正存儲(chǔ)數(shù)據(jù),這個(gè)有點(diǎn)兒類似跳表;
通過(guò)鏈表將葉子節(jié)點(diǎn)串聯(lián)在一起,這樣可以方便按區(qū)間查找;
一般情況,根節(jié)點(diǎn)會(huì)被存儲(chǔ)在內(nèi)存中,其他節(jié)點(diǎn)存儲(chǔ)在磁盤(pán)中。
除了 B+ 樹(shù),你可能還聽(tīng)說(shuō)過(guò) B 樹(shù)、B- 樹(shù),我這里簡(jiǎn)單提一下。實(shí)際上,B- 樹(shù)就是 B 樹(shù),英文翻譯都是 B-Tree,這里的“-”并不是相對(duì) B+ 樹(shù)中的“+”,而只是一個(gè)連接符。這個(gè)很容易誤解,所以我強(qiáng)調(diào)下。
而 B 樹(shù)實(shí)際上是低級(jí)版的 B+ 樹(shù),或者說(shuō) B+ 樹(shù)是 B 樹(shù)的改進(jìn)版。B 樹(shù)跟 B+ 樹(shù)的不同點(diǎn)主要集中在這幾個(gè)地方:
B+ 樹(shù)中的節(jié)點(diǎn)不存儲(chǔ)數(shù)據(jù),只是索引,而 B 樹(shù)中的節(jié)點(diǎn)存儲(chǔ)數(shù)據(jù);
B 樹(shù)中的葉子節(jié)點(diǎn)并不需要鏈表來(lái)串聯(lián)。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-489520.html
也就是說(shuō),B 樹(shù)只是一個(gè)每個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)不能小于 m/2 的 m 叉樹(shù)。文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-489520.html
到了這里,關(guān)于B+樹(shù):MySQL數(shù)據(jù)庫(kù)索引的實(shí)現(xiàn)的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!