用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

吳YH堅發表於2019-03-02

本篇較長較枯燥,請保持耐心看完。

前面兩章介紹了一下倒排索引以及倒排索引字典的兩種儲存結構,分別是跳躍表雜湊表,本篇我們介紹另一種資料結構,他也被大量使用在資訊檢索領域,我在github上實現的搜尋引擎的詞典也是用的這個資料結構,它就是B+樹。

首先,我們看看什麼是樹,樹是程式設計中一個非常基礎的資料結構,記得大學時候的資料結構課,連結串列,棧,佇列,然後就是樹了,雖然那時候想必大家都被前序遍歷,中序遍歷,後序遍歷折騰過,不過樹確實是一種非常有用的資料結構。

上一篇我們說過,表2的第一列首要解決的問題就是能快速找到對應的詞,然後找到對應詞的倒排列表,除了跳躍表和雜湊表,B+樹也能滿足條件,B+樹是B樹的變種,我們B樹我們就不看了,感興趣的大家可以直接去google一下,我們主要講的是B+樹,下圖就是一個3層的B+樹,我畫出來可能和大家搜出來的有點出入,但是沒關係,關鍵B+樹這種資料結構的思想大家瞭解了就行。

假設我們有一組數字 34,40,67,5,37,12,45,24,那麼,把他們存成B+樹就是下圖這個樣子。

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

我們很明顯看到幾個特點

  • 每個節點的大小為2
  • 非葉子層的最後一個節點的最後一個元素為NULL
  • 最底層的葉子節點是順序排列的,這個例子是從小到大
  • 上面的內節點的每一個元素都指向的下一級節點中最大的一個數相等

我儘量的把B+樹說簡單點,網上的資料也好,查書也好,看上去都挺複雜的,首先我們看看怎麼建立這棵樹,我儘量用圖了,少一些文字也好理解一點,前方大量圖預警。

首先,我們的陣列是34,12,5,67,37,40,45,24

####第一步,初始化B+樹,是這樣子的

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

這時候,啥也沒有,但是佔用了兩個節點,標識為的,表示這個元素無意義,標記為NULL表示無窮大

####第二步,插入34這個元素,那麼圖變成這樣子

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

我們看到,插入的過程是順著指標一直走到葉子節點,發現葉子節點是空的,然後把元素插入到葉子節點的頭部,然後返回上一級節點,將NULL後移,然後把第一個元素置為他的子節點的最大值,請記住這句話:置為他的子節點的最大值

####第三步,接著插入第二個元素12

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

這個步驟複雜一點

  • 從根節點開始遍歷,發現12小於根節點的某一個元素【在這裡是第1個元素】,順著指標往下走
  • 到達葉子節點,發現12小於葉子節點的某一個元素,說明可以放在這個葉子節點中,並且葉子節點還有一個空位置,那麼直接把12按大小順序插入到這個節點中

####第四步,然後是插入5

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

這一步更復雜一點,產生了分裂

  • 從根節點開始遍歷,5小於34,順著指標往下走,到達葉子節點
  • 到達葉子節點,發現5小於葉子節點的某一個元素,說明可以放在這個葉子節點中,但是,這個節點已經滿了,那麼,分裂出一個新的節點,將5放到老節點中,被擠走的元素順移到新節點中
  • 返回上一級節點,由於第一個葉子節點的最大元素已經變成12了,所以將該節點的元素由34改成指向的葉子節點的最大元素12
  • 由於新生成了一個節點,將NULL這個元素指向新生成的節點

####第五步,接著我們插入67

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

這一步比較簡單

  • 從根節點開始遍歷,67小於NULL,順著指標往下走,到達葉子節點
  • 到達葉子節點,發現67大於該節點的每一個元素,並且葉子節點有空位,直接插入即可

####第六步,我們插入37,插完這個後面的我就不寫了,感興趣可以自己畫一下

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

這一步複雜了,這一步不僅分裂了,而且分裂了兩次,並且層數增加了一層

  • 從根節點開始遍歷,37小於NULL,順著指標往下走,到達葉子節點
  • 到達葉子節點,37小於葉子節點中的67,表示可以插入到這個節點中,但是節點滿了,我們按照第四步的操作,分裂節點。
  • 分裂完了以後,產生了一個[34,37],一個[67,無]兩個節點,往上走的時候,發現上一層的節點插入了37以後也滿了,繼續按照第四步分裂。
  • 分裂完了以後,發現上層沒有節點了,那麼就新建一個根節點當上層節點,按照分裂的步驟給根節點賦值。

按照這六步,前5個元素就插入到B+樹中了,後面的步驟您可以自己走一走,B+樹基本的思想就是這樣子的,可能我沒有按照教科書上的做法來說,但這並不影響大家的理解,我相信看完了以後雖然你腦子裡沒有標準的演算法步驟,但應該有個大致的輪廓了,只不過需要自己再仔細想想步驟。

####總的來說,B+樹的插入步驟無外乎以下幾個步驟

  • 每次都要從根節點開始
  • 比較大小,找到小於當前值的元素,順著指標往下走,繼續比較大小,一直到達葉子節點,那麼這個葉子節點就是你要操作的節點了。
  • 在葉子節點只有幾種操作,一是葉子節點有空位置,那麼直接插入進去,一是葉子節點滿了,那麼分裂一個節點出來。
  • 不管在葉子節點進行了那種操作,最後都要順著指標回去,如果沒有分裂,那麼上層就不會分裂,可能會更新上層節點元素的值,如果分裂了,那麼就帶著兩個分裂的節點往上走,該更新值就更新值,該分裂就分裂。
  • 如果一層一層分裂到最上層了,那麼就新增一個根節點吧

查詢操作和更新操作幾乎一樣,就是更新操作的前面兩步,就不說了。

一般的更新的時候也是先查詢,找到葉子節點,再更新,然後順著指標往上走繼續分裂,這個順著往上走一般情況下首先想到的是雙向指標,但是雙向指標分裂的時候有點麻煩,需要把兩個指標都重新指新節點,我實現的時候用了一個棧,查詢葉子節點的時候把經過的節點依次壓棧,到達葉子節點後,完成插入操作,往上遍歷的時候依次把棧彈出來就行了,少了一個指標。

回到上一篇說的那個表2的第一列,如果是那個表的話,用這個B+樹加上倒排鏈的話,最後的資料結構就長成這樣子了(字串的大小我隨便寫的,中文的順序排列哥的腦子排不出來,你就把他們看成從小到大的順序吧)

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

好了,至此,一個倒排索引就建立好了,由兩部分組成,我實現的時候就是這麼實現的,一個結構用B+樹儲存字典,另外一個就是一個順序的檔案,B+樹的葉子節點存一個指向倒排檔案的檔案偏移量,當然,你也可以用前面的雜湊表或者跳躍表,甚至還有其他型別的樹,比如trie樹來實現,或者你還有其他新的高效資料結構也行。

####我們再來說說B+樹,為什麼選它?

之前我實現的時候用的是雜湊表,而且大部分的搜尋引擎用的都是雜湊表,為什麼用樹呢

  • 首先,為了節省空間,如果用雜湊表的話,假如有一個欄位是主鍵,並且是不規則的(比如cookieid),那麼如果巨量的文件的話,雜湊表的桶就會很大,會非常佔用記憶體,而我除錯的機器才8G記憶體的mac。
  • 其次再來看看雜湊表,查詢的時間複雜度是O(1),看上去確實美好,如果單單是一個全文搜尋引擎的話,由於key都是字串,而且基本都是中文字串,整個中文的詞彙量才幾十萬,確實很好,但是如果欄位不見得是中文分詞的東西,還有一些其他的東西,比如各種ID,由於是個通用的搜尋,所以不會給具體欄位去定義專門的雜湊函式,所以可能會大片產生碰撞,那複雜度就不是O(1)了,如果是一個特定場景的搜尋,要規避這個問題,可以根據自己的業務需求來的,甚至可以使用完美雜湊函式,而我實現的時候主要是為了更通用,所以用了B+樹。
  • 我們再看看B+樹本身,如果我們每個節點可以儲存100個元素,那麼一個4層的B+樹,可以儲存1億條資料,不管是主鍵欄位還是其他欄位都夠了,而一個4層的B+樹檢索起來,需要遍歷4個節點,每個節點用二分查詢的話,是log100(2為底),大概7次吧,4層的話,最差需要查詢28次,如果是3層的話,最差要21次,雖然和雜湊比起來慢了這麼多,但1次迴圈大約需要4個CPU的時鐘週期吧,對於現在的伺服器的計算機來說,就算21次迴圈+條件判斷也是微秒級別的,感覺不太出來差別,何況不可能每一次都那麼點背,都要查21次吧。
  • 再有,我的索引生成的時候是按段生成的,後面會涉及到索引的多個段的合併,如果是B+樹的話,字典是順序的,你看上面那個圖,葉子節點是有指標連起來的,所以合併段的時候可以使用一個多路歸併就合併完了,要是雜湊的話,由於不是順序的,合併起來需要重新雜湊一遍,比較麻煩。
  • 還有,B+樹這種資料結構非常適合磁碟檢索,只需要把每個節點的大小設定成一個磁碟頁的大小(一般是4K,至於為什麼設成頁的大小,和機械硬碟的結構以及預讀取機制有關,感興趣的可以自己查查,不過現在都是SSD了,這個的影響不是很大了),把指標改成磁碟的頁編號,那麼不用載入進記憶體,直接在磁碟上就能進行檢索,特別適合巨量資料量的詞典(比如主鍵),索引資料庫的索引(比如Mysql的inneDB)基本上都是B+樹實現的,如果大家感興趣可以單開一篇說這個。
  • 最後,B+樹由於是順序儲存的,所以可以進行範圍搜尋(雖然我沒有用),而雜湊表只能進行全等的搜尋。

最後說說我實現的這棵B+樹,首先,為了更少的佔用記憶體,我是用的磁碟的形式實現的,並且用了mmap的方式來加快讀寫速度,沒有用雙向指標,而用的棧來記錄查詢的路徑,速度還行吧,構造一棵10萬個隨機字串的樹大約需要3秒,隨機查詢10萬次大約需要150毫秒,每次1.5微秒。

當然,我實現的時候比較倉促,就是按照演算法硬編碼快速擼出來的,所以我這個B+樹還有非常大的優化空間,首先,我的key現在是確定的,不能超過64位元組,並且每個節點最多100個元素,當時為了快,確定的key和元素個數比較好程式設計,如果變成動態的更加節省空間,其次,沒有特別的考慮連續key的情況,連續key的插入會造成空間浪費一半,還有,把速度問題交給了mmap來解決,如果記憶體足夠,實際上啟動的時候預讀取非葉子節點到記憶體的話,查詢起來會更快,不過目前基本上滿足需求了,大家如果對B+樹實現很感興趣,可以看看bolt這個專案,這個是一個B+樹實現的KVDB,而且是帶事務的哦。

如果你覺得不錯,歡迎轉發給更多人看到,也歡迎關注我的公眾號,主要聊聊搜尋,推薦,廣告技術,還有瞎扯。。文章會在這裡首先發出來:)掃描或者搜尋微訊號XJJ267或者搜尋西加加語言就行

用 Golang 寫一個搜尋引擎 (0x04) — B + 樹

相關文章