解析 Nebula Graph 子圖設計及實踐

本文首發於 Nebula Graph 公衆號 NebulaGraphCommunity,Follow 看大廠圖數據庫技術實踐。

解析 Nebula Graph 子圖設計及實踐

前言

在先前的 Query Engine 源碼解析中,我們介紹了 2.0 中 Query Engine 和 1.0 的主要變化和大體的結構:

架構變化

大家可以大概瞭解到用戶通過客戶端發送一條查詢語句,Query Engine 是如何解析語句、把語句構建爲抽象語法樹,在抽象語法樹進行校驗、生成執行計劃的過程。本文會通過 2.0 中新增的子圖算法模塊繼續講解 Query Engine 背後所做的內容,並着重介紹執行計劃生成的過程,以便加強你對源碼更好地理解。

子圖的定義

子圖是指節點集合和邊集合分別是某一圖的節點集的子集和邊集的子集的圖。直觀地理解,就是從用戶指定的起點開始出發沿着指定的邊一步步拓展,直到達到用戶所設定的步數爲止,然後返回在拓展過程中遇到的所有點集和邊集。

子圖的語法​

GET SUBGRAPH [<step_count> STEPS] FROM {<vid>, <vid>...} [IN <edge_type>, <edge_type>...]
[OUT <edge_type>, <edge_type>...] [BOTH <edge_type>, <edge_type>...]
  • step_count:指定從起始點開始的跳數,返回從 0 到 step_count 跳的子圖。必須是非負整數。默認值爲 1
  • vid:指定起始點 ID
  • edge_type:指定邊類型。可以用 INOUTBOTH 來指定起始點上該邊類型的方向。默認爲 BOTH

子圖的實現

當 Query Engine 接收到 GET SUBGRAPH 命令後,Parser 模塊(由 flex 和 bison 實現)會根據已經寫好的規則(parser.yyget_subgraph_sentence 規則)把所需要的內容從查詢語句中提取出來,生成一個抽象語法樹,如下所示:

解析 Nebula Graph 子圖設計及實踐

然後進入 Validate 階段,此時對生成的抽象語法樹進行校驗,目的是爲了驗證用戶的輸入是否合法(參考 Query Engine 的文章),當校驗通過後,會把語法樹中的內容提取出來,生成一個執行計劃。 ​ 那麼這個執行計劃是如何生成的呢?對同一功能不同的數據庫廠商可能會生成不同的執行計劃,但是原理都是相同的。那就是要看自身的算子有哪些和查詢層和存儲層是如何進行交互的。因爲我們的每一條查詢語句到最後都是要從存儲層取數據的。在 Nebula Graph 中 Query Engine 和存儲層是通過 RPC 方式(fbthrift)進行交互的(接口定義在 common 倉中的 interface 目錄下)。這裏有兩個非常關鍵的接口 getNeighbors 和 getProps 需要了解一下。

getNeighbors 其中 fbthrift 的定義格式如下:

struct GetNeighborsRequest {    
    1: common.GraphSpaceID                      space_id,
    2: list<binary>                             column_names,
    3: map<common.PartitionID, list<common.Row>>
        (cpp.template = "std::unordered_map")   parts,
    4: TraverseSpec                             traverse_spec
}

該結構中每個變量的詳細定義可以參考 https://github.com/vesoft-inc/nebula-common/blob/master/src/common/interface/storage.thrift,裏面有詳細的註釋。 ​

其主要功能就是 Query Engine 根據定義好的結構傳入起始點和要拓展的邊類型信息,然後存儲層會找到起始點,然後把該點的屬性和以該點的出邊的邊屬性找出來組裝成一個表格返回給 Query Engine,其中返回的表格的格式參考 https://github.com/vesoft-inc/nebula-common/blob/master/src/common/interface/storage.thrift 中 GetNeighborsResponse 的定義,然後在 Query Engine 中我們就可以通過這個表格提取到我們想要的內容。 ​ 例如在 basketba l l 數據集中,當起始點爲 Tim Duncan、Manu Ginobili 沿着 like 邊雙向拓展。想要獲得 $^.[player.name](http://player.name/)like._dst$$.[player.name](http://player.name/)like.likeness 這四個屬性。其返回的數據大致如下所示:

數據圖

表格1

因爲是雙向拓展第四列的 + like 代表出邊,第五列的 - like 代表入邊。

在 Nebula Graph 的存儲層中邊是和起始點在一起存放的,所以通過 getNeighbor 接口就可以獲得起點和出邊的所有屬性信息,但是如果想要在拓展過程中拿到目的點的屬性信息則需要使用 getProps 接口,當然如果我只想通過 fetch 語句拿到某個點或者邊的屬性也需要調用這個接口。你可以自行了解 https://github.com/vesoft-inc/nebula-common/blob/master/src/common/interface/storage.thrift 下 getPropRequest 的定義,加深理解。

執行計劃

有了上面的接口定義我們就可以開始執行計劃了,首先需要的算子有 start、getNeighbor、subgraph、loop、datacollect。

  • start 算子:相當於執行計劃中葉子節點,不做任何事情。目的是告訴調度器,之後沒有可以依賴的算子,或者可以理解爲遞歸算法中的終止條件。
  • loop 算子:相當於 C 語言中的 while 語法,該算子有三個成員 depend、condition 和 loopBody,depend 在多語句和 PIPE 中會使用當前暫且不表,condition 相當於終止條件。loopBody 相當於 while 中的循環體。
  • subgraph 算子:負責把 getNeighbor 算子結果中的 _dst(目的點)屬性提出來然後過濾掉已經訪問過的目的點(避免重複從存儲層拿數據),然後把它們當作 getNeighbor 算子下一次拓展時的輸入。
  • datacollect 算子:負責在最後把拓展過程中獲得的點和邊屬性收集起來組裝爲 vertex 和 edge 類型。

其中各個算子的詳細信息,可參考源碼 https://github.com/vesoft-inc/nebula-graph/tree/master/src/executor 。 下面通過圖1 舉例,我們是如何構建子圖的

構建子圖 圖1

拓展一步的情況

當從 A 點開始沿着 like 邊只獲取一步的所有點和邊的信息,則很容易。只需要 getNeighbor 和 dataCollect 這兩個算子就可以了。執行計劃如下圖所示 :

拓展一步的情況

拓展多步的情況

一步場景其實是多步的場景的特殊情況。所以可以將一步的場景合入到多步場景中。當從 A 點開始,沿着 like 邊拓展三步的話,根據現有的算子,可以在 getNeighbor 拓展後把目的點提取出來,然後將這些目的點當作起點重新調用 getNeighbor 接口,這個循環兩次就可以了(loop 算子的終止條件設置爲當前步數),因此執行計劃如下圖所示 :

拓展多步的情況

輸入和輸出

一般情況下,每個算子的輸入就是所依賴算子的輸出,這時候根據執行計劃的依賴關係就可以直觀地確定每個算子的輸入和輸出。但是在某些情況下,比如:子圖,在多步場景中每一次 getNeighbor 算子的輸入都應該是上一次拓展邊的目的點,也就是 subgraph 算子的輸出,因此 subgraph 算子的輸出應該就是 getNeighbor 算子的輸入。這時就和上圖的執行計劃依賴不一致,這時就需要自行設置每個算子的輸入和輸出。在 Query Engine 2.0 中我們已經介紹了每個算子的輸入和輸出是存放在哈希表中的,其中 value 是 vector 類型。如下表 ResultMap 所示:

ResultMap

  • 起始點存放在 ResultMap["StartVid"] 中
  • getNeighbor 算子的輸入是 ResultMap["StartVid"], 輸出存放在 ResultMap["GN_1"]
  • subgraph 算子的輸入是 ResultMap["GN_1"], 輸出存放在 ResultMap["StartVid"]
  • loop 算子不產生數據,當作邏輯循環使用,因此不需要設置輸入輸出
  • dataCollect 算子的輸入是 ResultMap["GN_1"], 輸出存放在 ResultMap["DATACOLLECT_2"]

這時 getNeighbor 算子會把每一次的結果放在 ResultMap["GN_1"] 中的 vector 中的末尾,然後 subgraph 算子從 ResultMap["GN_1"] 中的 vector 中的末尾取值,經過計算再把下一次要拓展的起始點存放在 ResultMap["StartVid"] 中。

當拓展第一步後,ResultMap 的結果如下: ​ ResultMap

爲了方便顯示,GetNeighbor 的結果只寫了 _dst 的屬性,實際上會帶上邊上所有的屬性和起始點的所有屬性,類似於表格 1。

subgraph 算子接收"GN_1"的輸入,提取 _dst 屬性,然後將結果放入"StartVid"中。當拓展第二步後,ResultMap 的結果如下:

ResultMap

當拓展第三步後,ResultMap 的結果如下:

ResultMap

最後 dataCollect 算子從 ResultMap["GN_1"] 中取出拓展過程中遇到的所有點集和邊集,組裝成最終的結果返回給用戶。

實例

下面執行一個子圖的實例看看在 Nebula Graph 中執行計劃的具體結構,打開 nebula-console, 切換 space 到 basketball, 輸入 EXPLAIN format="dot" GET SUBGRAPH 2 STEPS FROM 'Tim Duncan' IN like, serve,這時候 nebula-console 會生成 dot 格式的數據,然後打開 Graphviz Online 這個網站,將生成的 dot 數據粘貼上去,就可以看到如下結構:

dot 結果

其中 Start_0 算子是 loop 算子中 depend 的依賴,由於沒有多語句或 PIPE 語句,因此不做任何處理。 ​ 以上爲本次子圖的講解,如果你在使用子圖或者其他 Nebula 過程中遇到問題,歡迎來論壇和我們交流:https://discuss.nebula-graph.com.cn/

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章