我覺得這個標題應該改改了,我寫下來其實是告訴大家怎麼寫一個搜尋引擎,並沒有涉及太多的Golang的東西,我覺得這樣也挺好,熟悉了原理,用什麼實現其實並不重要了,而且說說原理比說程式碼更實在。
之前已經說了底層的資料結構了,包括倒排和正排索引。今天我們上一層,來說說索引的欄位和段。
欄位這個上一篇已經介紹過了,欄位的概念實際上是搜尋引擎索引中我們能看到的最底層的東西,也是對外暴露的最底層的概念,在欄位之下是倒排和正排索引,這兩項其實對使用者是封裝起來了,我們可以認為每個欄位對應一個正排和一個倒排,而實際上也確實是這樣的。
在欄位之上就是我們這一篇主要說的段了,段這個概念並不是搜尋引擎特有的,也不是必須的,是我這個專案新增出來的,當然,也不是我原創,很多搜尋引擎的引擎系統都有這個概念。
所謂段,就是最基本的檢索系統,一個段包含所有欄位,包含一部分連續的文件集合,能夠進行完整的檢索,可以把它當成一個檢索系統最基本單位。
這麼說可能還是有點抽象,我們打個比方,在資料庫中,一行資料是最基本的單位,對應搜尋引擎中的是一個文件,而表是所有文件的集合,對應搜尋引擎中是一份索引,而段就是一部分表,它包含一部分文件的內容,可以對這一部分文件進行檢索,多個段合併起來就是一份完整的索引。
為什麼要有段這個概念呢?
- 如果一個搜尋引擎的資料再建好索引以後並不變化,那麼完全沒有必要使用段,直接在建立全量索引的時候把資料都建好就行了
- 如果有增量資料,並且增量資料是不斷進入系統的話,那麼段的概念就有必要了,新增的資料首先在記憶體中進行儲存,然後週期性的生成一個段,持久化到磁碟中提供檢索操作。
- 段還有一個好處就是當系統是一個分散式的系統的時候,進行索引同步的時候,因為各個段持久化以後就不會變化了,只需要把段拷貝到各個機器,就可以提供檢索服務了,不需要在各個機器上重建索引。
- 一個段損壞了,並不影響其他段的檢索,只需要從其他機器上將這個段拷貝過來就能正常檢索了,如果只有一個索引的話,一旦索引壞了,就無法提供檢索服務了,需要等把正確索引拷貝過來才行。
一個段都存一些什麼資訊呢?
一個段包含幾個檔案
- indexname_{segementNumber}.meta 這裡是段的元資訊,包括段中欄位的名稱,型別,也包括段的文件的起始和終止編號。
- indexname_{segementNumber}.bt 這裡是段的倒排索引的字典檔案
- indexname_{segementNumber}.idx 這裡是段的所有欄位的倒排檔案
- indexname_{segementNumber}.pfl 這裡是段的所有數字正排檔案的資料,同時也包含字串型別資料的位置資訊
- indexname_{segementNumber}.dtl 這裡是段的字串型別資料的詳情資料
上面的indexname是這個索引的名稱,相當於資料庫中的表名,segmentNumber是段編號,這個編號是系統生成的。
多個段合在一起就是一個完整的索引,檢索的時候實際上是每個段單獨檢索,然後把資料合併起來就是最後的結果集了。
段的構建
下面我們一個一個來說說這些個檔案,看看一堆正排和一堆倒排如何構成一個段的。
一個真正意義上的段的構建由以下幾個步驟來構建,我們以一個實際的例子來說明一下段的構建,比如我們現在索引結構是這樣,這個索引包括三個欄位,分別是姓名(字串),年齡(數字),自我介紹(帶分詞的字串),那麼構建段和索引的時候步驟是這樣的
1.前期準備
首先新建一個段需要先初始化一個段,在初始化段的時候我們實際上已經知道這個段包含哪些欄位,每個欄位的型別。
- 初始化一個段資訊,包含段所包含的欄位資訊和型別,在這裡就是包含姓名(字串【正排和倒排】),年齡(數字【正排】),自我介紹(帶分詞的字串【正排和倒排】)。
- 給段一個編號,比如1000。
- 準備開始接收資料。
2.建立記憶體中的段
記憶體中的段是構建段的第一步,以上述的欄位資訊為例,我們會在記憶體中建立以下幾個資料結構,在這裡我都是使用語言自動的原始資料結構
- 姓名需要建立倒排索引,所以建立一個map
,key是姓名,value是docid,姓名也要建立正排索引,所以建立一個StringArray[],儲存每條資料的姓名的詳情。 - 年齡需要建立正排索引,所以建立一個IntegerArray[],儲存每條資料的年齡的詳情。
- 自我介紹需要建立倒排索引,所以建立一個map
,key是自我介紹的分詞的term,value是docid,自我介紹也要建立正排索引,所以建立一個StringArray[],儲存每條資料的自我介紹的詳情。
當新增一條資料的時候{"name":"張三","age":18,"introduce":"我喜歡跑步"}
,首先我們給他一個docid【假如是0】,然後我們把資料分別存放到上面的5個資料結構中,如果再來一條資料{"name":"李四","age":28,"introduce":"我喜歡唱歌"}
,我們給他一個docid【假如是1】,那麼資料就變成了下圖的樣子
3.將資料結構持久化到磁碟中
這樣,隨著資料的不停匯入,記憶體中的資料結構不斷變化,記憶體段的資料也越來越大,當達到一定閾值的時候(這部分策略以後會說,我把這部分策略放到了引擎層,由引擎來決定什麼時候進行段的持久化),我們將把資料持久化到磁碟中。
進行持久化的過程中
- 如果是map的資料結構,我們將遍歷整個map,首先將value追加寫到.idx檔案中,然後把key建立B+樹,value是剛剛寫入的idx檔案的偏移位置。
- 如果是IntegerArray,我們遍歷整個陣列,然後把資料寫入到pfl檔案中,每個資料佔用8個位元組。
- 如果是StringArray,我們遍歷整個資料,首先把value追加寫入到dtl檔案中,然後把檔案偏移量寫入到pfl檔案中
完成上面的三個步驟,我們的持久化工作就完成了,完成以後資料結構就變成下面的樣子了,大家可以自己腦子裡實現一遍。
4.段構建完成後
段構建完成後,這個段就算完全持久化磁碟中了,不會再進行更改了,相當於提交到索引系統了,可以進行檢索了。這時候,我們再新建一個段,接著接收新的文件資料,然後繼續把後續的段持久化到磁碟中。
當檢索的時候,依次檢索每個段,然後將結果集合並起來返回給前端。
段的合併
段建立好了以後,可能需要對段進行合併操作,段的合併方式也很多,最簡單的就是新建一個段,然後遍歷之前的所有資料,從新建立一個段即可,這比較適合於資料量少的情況,因為新建一個段是在記憶體中的,如果之前的資料太多的話,記憶體會撐不住。
還有一種方式是分別將倒排,正排依次合併,這種方式不耗費記憶體,但是比較耗費磁碟的IO,兩種方式大家可以根據自己的業務場景進行選擇,第一種的方法和之前段的構建是一樣的,這裡我們說說第二種方式。
合併倒排檔案
我們使用的B+樹對倒排索引的字典檔案進行儲存,B+樹天然帶排序,那麼合併段的時候實際上就是合併多個B+樹,我們只要使用歸併排序的方式就能合併多個B+樹了。歸併排序不清楚的可以自己去查查,每個B+樹的Key就是待歸併的元素,一邊掃描B+樹一邊構建一個新的B+樹,然後把倒排檔案合併起來形成一個新的idx檔案,倒排檔案就合併完了。
合併正排檔案
合併正排檔案更加簡單,只需要按照欄位依次遍歷每個段的正排檔案,然後一邊遍歷一邊就形成了一個新的正排檔案,遍歷完正排檔案也就合併完了。
合併的方法在FalconIndex/segment/segment.go
的 MergeSegments
中有詳細程式碼,大家可以參考一下這種最簡單的合併方式。
段的策略
段的策略比較自由,一般也不建議固化到索引中。一般有以下幾種策略可供選擇,具體需要根據自己的業務邏輯來選擇一個合適的段的持久化策略。
- 如果你的系統是一個一旦建立了索引就不怎麼變化的系統,那麼在做全量索引的時候建立一個段就行了,全量索引構建完了,然後把段持久化到磁碟就行了,如果全量索引量很大,怕記憶體扛不住,那麼可以每10萬條建立一個段,當全量索引完成了以後再將所有的段合併成一個段就行了,段的合併後面會說,合併段基本不佔用什麼記憶體,可以隨時合併,如果有增量資料,每隔一段時間序列化一下段,然後再每隔一段時間將所有非全量資料的段合併一下,那麼系統中就基本上只有一個全量的段和一個增量的段,檢索起來還是非常快的。
- 如果你的系統是一個實時變化比較大的系統,比如日誌系統,那麼全量索引實際就沒什麼意義了,由於日誌系統的檢索其實實時性要求沒有那麼高,那麼段的策略可以是每新增10萬條資料持久化一個段,沒到10個段將所有段合併成一個段。或者按照時間戳來合併段,方便剔除老的資料。
- 如果你的系統是一個實時性要求很高的系統,那麼可以按照時間(比如10秒)持久化一次段,每當系統空閒的時候將小的段合併成一個大的段。
總之,段的策略比較自由,完全由引擎層來實現,根據自己的業務場景來選擇重寫一個段合併的策略都是可以的。
段是索引的一部分,也是一個微型的索引,下面一篇我們將會介紹索引層了,索引層介紹玩以後搜尋引擎的資料層就完全結束了,上面就是各種引擎的策略了,有了索引層以後,其實對上你要變成一個搜尋引擎還是要變成一個資料庫,或者變成一個KVDB的資料庫都是可以的,反正基礎的東西不會有太多變化。
好了,如果你想看之前的文章,可以關注我的公眾號哈:)