解析 Nebula Graph 子圖設計及實踐

NebulaGraph 發表於 2021-06-25

本文首發於 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 報名傳送門