Nebula 基於 ElasticSearch 的全文搜尋引擎的文字搜尋

NebulaGraph 發表於 2021-06-17
ElasticSearch

本文首發於 Nebula Graph 公眾號 NebulaGraphCommunity,Follow 看大廠圖資料庫技術實踐。

Nebula 基於全文搜尋引擎的文字搜尋

1 背景

Nebula 2.0 中已經支援了基於外部全文搜尋引擎的文字查詢功能。在介紹這個功能前,我們先簡單回顧一下 Nebula Graph 的架構設計和儲存模型,更易於下邊章節的描述。

1.1 Nebula Graph 架構簡介

Nebula 基於全文搜尋引擎的文字搜尋

如圖所示,Storage Service 共有三層,最底層是 Store Engine,它是一個單機版 local store engine,提供了對本地資料的get/put/scan/delete操作,相關的介面放在 KVStore / KVEngine.h 檔案裡面,使用者完全可以根據自己的需求定製開發相關 local store plugin,目前 Nebula 提供了基於 RocksDB 實現的  Store Engine。

在 local store engine 之上,便是我們的 Consensus 層,實現了 Multi Group Raft,每一個 Partition 都對應了一組 Raft Group,這裡的 Partition 便是我們的資料分片。目前 Nebula 的分片策略採用了靜態 Hash的方式,具體按照什麼方式進行 Hash,在下一個章節 schema 裡會提及。使用者在建立 SPACE 時需指定 Partition 數,Partition 數量一旦設定便不可更改,一般來講,Partition 數目要能滿足業務將來的擴容需求。

在 Consensus 層上面也就是 Storage Service 的最上層,便是我們的Storage Interfaces,這一層定義了一系列和圖相關的 API。 這些 API 請求會在這一層被翻譯成一組針對相應 Partition 的 KV 操作。正是這一層的存在,使得我們的儲存服務變成了真正的圖儲存,否則,Storage Service 只是一個 KV 儲存罷了。而 Nebula 沒把 KV 作為一個服務單獨提出,其最主要的原因便是圖查詢過程中會涉及到大量計算,這些計算往往需要使用圖的 Schema,而 KV 層是沒有資料 Schema 概念,這樣設計會比較容易實現計算下推。

1.2 Nebula Graph 儲存介紹

Nebula Graph 在 2.0 中,對儲存結構進行了改進,其包含點、邊和索引的儲存結構,接下來我們將簡單回顧一下 2.0 的儲存結構。通過儲存結構的解釋,大家基本也可以簡單瞭解 Nebula Graph 的資料和索引掃描原理。

1.2.1 Nebula 資料儲存結構

Nebula 資料的儲存包含“點”和“邊”的儲存,“點” 和 “邊” 的儲存均是基於 KV 模型儲存,這裡我們主要介紹其 Key 的儲存結構,其結構如下所示

  • Type:  1 個位元組,用來表示 key 的型別,當前的型別有 vertex、edge、index、system 等。
  • PartID: 3 個位元組,用來表示資料分片 partition,此欄位主要用於 partition 重新分佈(balance)時方便根據字首掃描整個 partition 資料
  • VertexID: n 個位元組, 出邊裡面用來表示源點的 ID, 入邊裡面表示目標點的 ID。
  • Edge Type: 4 個位元組, 用來表示這條邊的型別,如果大於 0 表示出邊,小於 0 表示入邊。
  • Rank: 8 個位元組,用來處理同一種型別的邊存在多條的情況。使用者可以根據自己的需求進行設定,這個欄位可存放交易時間交易流水號、或某個排序權重
  • PlaceHolder: 1 個位元組,對使用者不可見,未來實現分散式做事務的時候使用。
  • TagID:4 個位元組,用來表示 tag 的型別。
1.2.1.1 點的儲存結構
Type (1 byte) PartID (3 bytes) VertexID (n bytes) TagID (4 bytes)
1.2.1.2 邊的儲存結構
Type (1 byte) PartID (3 bytes) VertexID (n bytes) EdgeType (4 bytes) Rank (8 bytes) VertexID (n bytes) PlaceHolder (1 byte)

1.2.2 Nebula 索引儲存結構

  • props binary (n bytes):tag 或 edge 中的 props 屬性值。如果屬性為 NULL,則會填充 0xFF。
  • nullable bitset (2 bytes):標識 prop 屬性值是否為 NULL,共有 2 bytes(16 bit),由此可知,一個 index 最多可以包含 16 個欄位。
1.2.2.1 tag index 儲存結構
Type (1 byte) PartID (3 bytes) IndexID (4 bytes) props binary (n bytes) nullable bitset (2 bytes) VertexID (n bytes)
1.2.2.2 edge index 儲存結構
Type (1 byte) PartID (3 bytes) IndexID (4 bytes) props binary (n bytes) nullable bitset (2 bytes) VertexID (n bytes) Rank (8 bytes) VertexID (n bytes)

1.3 借用第三方全文搜尋引擎的原因

由以上的儲存結構推理可以看出,如果我們想要對某個 prop 欄位進行文字的模糊查詢,都需要進行一個 full table scanfull index scan,然後逐行過濾,由此看來,查詢效能將會大幅下降,資料量大的情況下,很有可能還沒掃描完畢就出現記憶體溢位的情況。另外,如果將 Nebula 索引的儲存模型設計為適合文字搜尋的倒排索引模型,那將背離 Nebula 索引初始的設計原則。經過一番調研和討論,所謂術業有專攻,文字搜尋的工作還是交給外部的第三方全文搜尋引擎來做,在保證查詢效能的基礎上,同時也降低了 Nebula 核心的開發成本。

2 目標

2.1 功能

2.0 版本我們只對 LOOKUP 支援了文字搜尋功能。也就是說基於 Nebula 的內部索引,藉助第三方全文搜尋引擎來完成 LOOKUP 的文字搜尋功能。對於第三方全文引擎來說,目前只使用了一些基本的資料匯入、查詢等功能。如果是要做一些複雜的、純文字的查詢計算的話,Nebula 目前的功能還有待完善和改進,期待廣大的社群使用者提出寶貴的建議。目前所支援的文字搜尋表示式如下:

  • 模糊查詢
  • 字首查詢
  • 萬用字元查詢
  • 正規表示式查詢

2.2 效能

這裡所說的效能,指資料同步效能和查詢效能。

  • 資料同步效能:既然我們使用了第三方的全文搜尋引擎,那不可避免的是需要在第三方全文搜尋引擎中也儲存一份資料。經過驗證,第三方全文搜尋引擎的匯入效能要低於 Nebula 自身的資料匯入效能,為了不影響 Nebula 自身的資料匯入效能,我們通過非同步資料同步的方案來進行第三方全文搜尋引擎的資料匯入工作。具體的資料同步邏輯我們將在以下章節中詳細介紹。
  • 資料查詢效能:剛剛我們提到了,如果不借助第三方全文搜尋引擎,Nebula 的文字搜尋將是一場噩夢。目前 LOOKUP 中通過第三方全文引擎支援了文字搜尋,不可避免的效能會慢於 Nebula 原生的索引掃描,有時甚至第三方全文引擎自身的查詢都會很慢,此時我們需要有一個時效機制來保證查詢效能。即 LIMIT 和 TIMEOUT,將在下列章節中詳細介紹。

3 名詞解釋

名稱 說明
Tag 用於點上的屬性結構,一個 vertex 可以附加多個 tag,以 tagId 標示。
Edge 類似於 tag,edge 是用於邊上的屬性結構,以 edgetype 標示。
Property tag 或 edge 上的屬性值,其資料型別由 tag 或 edge 的結構確定。
Partition Nebula Graph 的最小邏輯儲存單元,一個 Storage Engine 可包含多個 partition。Partition 分為 leader 和 follower 的角色,raftex 保證了 leader 和 follower 之間的資料一致性。
Graph space 每個 graph space 是一個獨立的業務 graph 單元,每個 graph space 有其獨立的 tag 和 edge 集合。一個 Nebula Graph 叢集中可包含多個 graph space。
Index 下文中出現的 index 指 Nebula Graph 中點和邊上的屬性索引。其資料型別依賴於 tag 或 edge。
TagIndex 基於 tag 建立的索引,一個 tag 可以建立多個索引。因暫不支援複合索引,因此一個索引只可以基於一個 tag。
EdgeIndex 基於 edge 建立的索引。同樣,一個 edge 可以建立多個索引,但一個索引只可以基於一個 edge。
Scan Policy index 的掃描策略,往往一條查詢語句可以有多種索引的掃描方式,但具體使用哪種掃描方式需要 scan policy 來決定。
Optimizer 對查詢條件進行優化,例如對 WHERE 子句的表示式樹進行子表示式節點的排序、分裂、合併等。其目的是獲取更高的查詢效率。

4 實現邏輯

目前我們相容的第三方全文搜尋引擎是 ElasticSearch,此章節中主要圍繞 ElasticSearch 來進行描述。

4.1 儲存結構

4.1.1 DocID

partId(10 bytes) schemaId(10 bytes) encoded_columnName(32 bytes) encoded_val(max 344 bytes)
  • partId:對應於 Nebula 的 partition ID,當前的 2.0 版本中還沒有用到,主要用於今後的查詢下推和 es routing 機制。
  • schemaId:對應於 Nebula 的 tagId 或 edgetype。
  • encoded_columnName:對應於 tag 或 edge 中的 column name,此處做了一個 md5 的編碼,用以避免 ES DocID 中不相容的字元。
  • encoded_val 之所以最大為 344 個 byte,是因為 prop value 做了一個 base64 的編碼,用於解決 prop 中存在某些 docId 不支援的可見字元的問題。實際的 val 大小被限制在 256 byte。這裡為什麼會將長度限制在 256?設計之初,主要的目的是完成 LOOKUP 中的文字搜尋功能。基於 Nebula 自身的 index,其長度也有限制,類似傳統關聯式資料庫 MySQL 一樣,其索引的欄位長度建議在 256 個字元之內。因此將第三次搜尋引擎的長度也限制在 256 之內。此處並沒有支援長文字的全文搜尋
  • ES 的 docId 最長為 512 byte,目前有大約 100 個 byte 的保留位元組。

4.1.2 Doc Fields

  • schema_id:對應於 Nebula 的 tagId 或 edgetype。
  • column_id:nebula tag 或 edge 中 column 的編碼。
  • value:對應於 Nebula 原生索引中的屬性值。

4.2 資料同步邏輯

Leader & Listener

上邊的章節中簡單介紹了資料非同步同步的邏輯,此邏輯將在本章節中詳細介紹。介紹之前,先讓我們認識一下 Nebula 的 Leader 和 Listener。

  • Leader:Nebula 本身是一個可水平擴充套件的分散式系統,其分散式協議是 raft。一個分割槽(Partition)在分散式系統中可以有多種角色,例如 Leader、Follower、Learner 等。當有新資料寫入時,會由 Leader 發起 WAL 的同步事件,將 WAL 同步給 Follower 和 Learner。當有網路異常、磁碟異常等情況發生時,其 partition 角色也會隨之改變。由此保證了分散式資料庫的資料安全。無論是 Leader、Follower,還是 Learner,都是在 nebula-storaged 程式中控制,其系統引數由配置引數nebula-stoage.conf決定。
  • Listener:不同於 Leader、Follower 和 Learner,Listener 由一個單獨的程式控制,其配置引數由 nebula-stoage-listener.conf 決定。Listener 作為一個監聽者,會被動的接收來自於 Leader 的 WAL,並定時的將 WAL 進行解析,並呼叫第三方全文引擎的資料插入 API 將資料同步到第三方全文搜尋引擎中。對於 ElasticSearch,Nebula 支援 PUTBULK 介面。

接下來我們介紹一下資料同步邏輯:

  1. 通過 Client 或 Console 插入 vertex 或 edge
  2. graph 層通過 Vertex ID 計算出相關 partition
  3. graph 層通過 storageClient 將 INSERT 請求傳送到相關 Partition 的 Leader
  4. Leader 解析 INSERT 請求,並將 WAL 同步到 Listener 中
  5. Listener 會定時處理新同步來的 WAL,並解析 WAL,獲取 tag 或 edge 中欄位型別為 string 的屬性值。
  6. 將 tag 或 edge 的後設資料和屬性值組裝成 ElasticSearch 相容的資料結構
  7. 通過 ElasticSearch 的 PUTBULK 介面寫入到 ElasticSearch 中。
  8. 如果寫入失敗,則回到第 5 步,繼續重試失敗的 WAL,直到寫入成功。
  9. 寫入成功後,記錄成功的 Log ID 和 Term ID,做為下次 WAL 同步的起始值。
  10. 回到第 5 步的定時器,處理新的 WAL。

在以上步驟中,如果因為 ElasticSearch 叢集掛掉,或 Listener 程式掛掉,則停止 WAL 同步。當系統恢復後,會接著上次成功的 Log ID 繼續進行資料同步。在這裡有一個建議,需要 DBA 通過外部監控工具實時監控 ES 的執行狀態,如果 ES 長期處於無效狀態,會導致 Listener 的 log 日誌暴漲,並且無法做正常的查詢操作。

4.3 查詢邏輯

Nebula 基於全文搜尋引擎的文字搜尋

由上圖可知,其文字搜尋的關鍵步驟是 “Send Fulltext Scan Request” → "Fulltext Cluster" → "Collect Constant Values" → "IndexScan Optimizer"。

  • Send Fulltext Scan Request: 根據查詢條件、schema ID、Column ID 生成全文索引的查詢請求(即封裝成 ES 的 CURL 命令)
  • Fulltext Cluster:傳送查詢請求到 ES,並獲取 ES 的查詢結果。
  • Collect Constant Values:將返回的查詢結果作為常量值,生成 Nebula 內部的查詢表示式。例如原始的查詢請求是查詢 C1 欄位中以“A”開頭的屬性值,如果返回的結果中包含 “A1” 和 "A2"兩條結果,那麼在這一步,將會解析為 neubla 的表示式 C1 == "A1" OR C1 == "A2"
  • IndexScan Optimizer:根據新生成的表示式,基於 RBO 找出最優的 Nebula 內部 Index,並生成最優的執行計劃。
  • 在"Fulltext Cluster"這一步中,可能會有查詢效能慢,或海量資料返回的情況,這裡我們提供了 LIMITTIMEOUT 機制,實時中斷 ES 端的查詢。

5 演示

5.1 部署外部ES叢集

對於 ES 叢集的部署,這裡不再詳細介紹,相信大家都很熟悉了。這裡需要說明的是,當 ES 叢集啟動成功後,我們需要對 ES 叢集建立一個通用的 template,其結構如下:

{
 "template": "nebula*",
  "settings": {
    "index": {
      "number_of_shards": 3,
      "number_of_replicas": 1
    }
  },
  "mappings": {
    "properties" : {
            "tag_id" : { "type" : "long" },
            "column_id" : { "type" : "text" },
            "value" :{ "type" : "keyword"}
        }
  }
}

5.2 部署 Nebula Listener

  • 根據實際環境,修改配置引數 nebula-storaged-listener.conf
  • 啟動 Listener:./bin/nebula-storaged --flagfile ${listener_config_path}/nebula-storaged-listener.conf

5.3 註冊 ElasticSearch 的客戶端連線資訊

nebula> SIGN IN TEXT SERVICE (127.0.0.1:9200);
nebula> SHOW TEXT SEARCH CLIENTS;
+-------------+------+
| Host        | Port |
+-------------+------+
| "127.0.0.1" | 9200 |
+-------------+------+
| "127.0.0.1" | 9200 |
+-------------+------+
| "127.0.0.1" | 9200 |
+-------------+------+

5.4 建立 Nebula Space

CREATE SPACE basketballplayer (partition_num=3,replica_factor=1, vid_type=fixed_string(30));
 
USE basketballplayer;

5.5 新增 Listener

nebula> ADD LISTENER ELASTICSEARCH 192.168.8.5:46780,192.168.8.6:46780;
nebula> SHOW LISTENER;
+--------+-----------------+-----------------------+----------+
| PartId | Type            | Host                  | Status   |
+--------+-----------------+-----------------------+----------+
| 1      | "ELASTICSEARCH" | "[192.168.8.5:46780]" | "ONLINE" |
+--------+-----------------+-----------------------+----------+
| 2      | "ELASTICSEARCH" | "[192.168.8.5:46780]" | "ONLINE" |
+--------+-----------------+-----------------------+----------+
| 3      | "ELASTICSEARCH" | "[192.168.8.5:46780]" | "ONLINE" |
+--------+-----------------+-----------------------+----------+

5.6 建立 Tag、Edge、Nebula Index

此時建議欄位 “name” 的長度應該小於 256,如果業務允許,建議 player 中欄位 name 的型別定義為 fixed_string 型別,其長度小於 256。

nebula> CREATE TAG player(name string, age int);
nebula> CREATE TAG INDEX name ON player(name(20));

5.7 插入資料

nebula> INSERT VERTEX player(name, age) VALUES \
  "Russell Westbrook": ("Russell Westbrook", 30), \
  "Chris Paul": ("Chris Paul", 33),\
  "Boris Diaw": ("Boris Diaw", 36),\
  "David West": ("David West", 38),\
  "Danny Green": ("Danny Green", 31),\
  "Tim Duncan": ("Tim Duncan", 42),\
  "James Harden": ("James Harden", 29),\
  "Tony Parker": ("Tony Parker", 36),\
  "Aron Baynes": ("Aron Baynes", 32),\
  "Ben Simmons": ("Ben Simmons", 22),\
  "Blake Griffin": ("Blake Griffin", 30);

5.8 查詢

nebula> LOOKUP ON player WHERE PREFIX(player.name, "B");
+-----------------+
| _vid            |
+-----------------+
| "Boris Diaw"    |
+-----------------+
| "Ben Simmons"   |
+-----------------+
| "Blake Griffin" |
+-----------------+

6 問題跟蹤與解決技巧

對於系統環境的搭建過程中,可能某個步驟錯誤導致功能無法正常執行,在之前的使用者反饋中,我總結了三類可能發生的錯誤,對分析和解決問題的技巧概況如下

  • Listener 無法啟動,或啟動後不能正常工作
    • 檢查 Listener 配置檔案,確保 Listener 的 IP:Port 不和已有的 nebula-storaged 衝突
    • 檢查 Listener 配置檔案,確保 Meta 的 IP:Port 正確,這個要和 nebula-storaged 中保持一致
    • 檢查 Listener 配置檔案,確保 pids 目錄和 logs 目錄獨立,不要和 nebula-storaged 衝突
    • 當啟動成功後,因為配置錯誤,修改了配置,再重啟後仍然無法正常工作,此時需要清理 meta 的相關後設資料。對此提供了操作命令,請參考 nebula 的幫助手冊:文件連結
  • 資料無法同步到 ES 叢集
    • 檢查 Listener 是否從 Leader 端接受到了 WAL,可以檢視 nebula-storaged-listener.conf 配置檔案中 –listener_path 的目錄下是否有檔案。
    • 開啟 vlog(UPDATE CONFIGS storage:v=3),並關注 log 中 CURL 命令是否執行成功,如果有錯誤,可能是 ES 配置或 ES 版本相容性錯誤
  • ES 叢集中有資料,但是無法查詢出正確的結果
    • 同樣開啟 vlog (UPDATE CONFIGS graph:v=3),關注 graph 的 log,檢查 CURL 命令是什麼原因執行失敗
    • 查詢時,只能識別小寫字元,不能識別大寫字元。可能是 ES 的 template 建立錯誤。請對照 nebula 幫助手冊進行建立:文件連結

7 TODO

  • 針對特定的 tag 或 edge 建立全文索引
  • 全文索引的重構(REBUILD)

交流圖資料庫技術?加入 Nebula 交流群請先填寫下你的 Nebula 名片,Nebula 小助手會拉你進群~~

想要和其他大廠交流圖資料庫技術嗎?NUC 2021 大會等你來交流:NUC 2021 報名傳送門