從真實案例出發,全方位解讀 NebulaGraph 中的執行計劃

nebulagraph發表於2023-11-15

本文整理自 NebulaGraph 核心開發 Yee 在直播《聊聊執行計劃這件事》中的主題分享。分享影片參見 B 站:

一條 Query 的一生

在開始正式地解讀執行計劃之前,我們先來了解在 NebulaGraph 中,一條查詢語句(Query)是如何被校驗、生成語法樹,到最後被轉為邏輯 / 物理的執行計劃。而這個 Query 生命週期,無論是 NebulaGraph 原生查詢語言 nGQL 或者是從 v2.x 開始相容的 openCypher,都會經歷從字串到執行計劃的過程。這個過程對應到程式語言,是一個編譯的過程。

查詢語句的生命週期大概有以下幾個階段:

同大多數的資料庫類似,一個 Query 的字串會經過 Parser(詞法語法解析器),轉成一個 AST(抽象語法樹)。而抽象語法樹中,不同的查詢語句會轉成不同的抽象語法樹節點。再經過一道 Validator(校驗器),進行語句的合法性校驗。校驗階段,主要是根據建立點邊元素的 Schema 資訊(後設資料資訊)。由於 NebulaGraph 查詢語言設計之初是 schema-based 的設定,因此在語句查詢時可(基於語句上下文關係)事先進行相關推導,比如某個查詢會用到哪些(Schema)型別。

這裡插個知識點,在 NebulaGraph 中需要注意兩個關鍵型別:EdgeType(Edge)和 Tag(Vertex),像每條 Edge 對應的邊型別 EdgeType,其包含的邊型別屬性都是有具體的 Data Type。此外,便是每個點 Vertex 的屬性,每個點的屬性都會歸類放到對應的 Tag 中,變成其對應的點型別屬性 Tag property。類比關係型資料庫的話,Tag 相當於是一張表。在 NebulaGraph 中,點和邊的屬性雖然受到 Schema 約束,但是對某個邊的兩端點對應的型別是不作約束。因此,插入點或者是修改點,是透過 ID(VID)進行相關操作,與點型別無關。

回到上面的 Query 生命週期,在進行語句校驗之後,將之前的詞法轉成 NebulaGraph 中包含特定運算元的執行計劃 Execution Planner。隨後,執行計劃會經過一道最佳化,透過最佳化器 Optimizer 變成更優的執行計劃,再交由排程器去執行。在傳統的資料庫中,執行計劃分為邏輯計劃和物理計劃,但在 NebulaGraph 中目前只存在一種計劃,便是物理計劃,像上面流程圖中,Optimzer 處理的計劃便是一個物理計劃。因此,經過 Optimizer 最佳化過的 optimized plan,其實是一個可直接執行的物理計劃。

而執行計劃的執行,主要是由上圖的 Executor(其他資料庫可能叫 Operator)完成。在執行這層,會涉及到介面NULL互動,例如:同 storage 層 “打交道”,會呼叫 rpc 介面;同 graph 層互動,會呼叫一些計算運算元,像上圖加粗的 Project、Filter 這種純計算運算元。除此之外,執行還會涉及到執行模型,比如多執行緒、Runtime 等問題。社群中大家遇到的一些執行效能問題,可能就同它們有關。

執行計劃示例

GO 3 STEPS FROM "player100" OVER follow WHERE $$.player.age > 34 YIELD DISTINCT $$.player.name AS name, $$.player.age AS age | ORDER BY $-.age ASC, $-.name DESC;

示例出處:《nGQL 簡明教程 vol.02 執行計劃詳解與調優》 網址: https://discuss.nebula-graph.com.cn/t/topic/12010

這是一個典型的 nGQL 查詢語句,透過  GO 子句遍歷,再透過  WHERE 子句進行過濾,最後經由  ORDER BY 排序輸出。

下圖是上面查詢語句生成的執行計劃:

go_n_steps

如上所示,這個執行計劃還是相對比較複雜的。它同大家見慣的關係型資料庫的執行計劃不大一樣,像右下角的 Loop,左上角單依賴的 LeftJoin 可能同大家之前接觸的都不大一樣。

從結構上看,同 Neo4j 的樹 Tree 結構不同,NebulaGraph 中的執行計劃,不僅有方向,還有環。為什麼 NebulaGraph 的執行計劃結構如此不同呢?

explain format="dot" {
 $a=go 2 steps from "Tim Duncan" over like yield like.likeness as c1;
 $b=go 1 steps from "Tony Parker" over like yield like.likeness as c1;
 $c=yield $a.c1 as c1 union yield $b.c1 as c1;
}

我們來看一下上面這個例子,這裡定義了 a、b、c 變數。a 和 b 之間不存在任何關係,都是幾跳遍歷之後,輸出結果。而 c 則是對這兩個變數進行了 union 操作。我們來看看這三條語句對應的執行計劃:

可以看到,第一條語句跟第二條語句,還有第三條語句,在執行依賴上是一個順序依賴:以上圖黃色圈為分界線,黃色圈以下為 a 的 plan,黃色圈以上、Union_11 以下為 b 的 plan,Union_11 及其以上為 c 的 plan。

而資料依賴可以看箭頭部分,Project_9 的資料依賴輸入為 a,它是由 Project_4 提供的,而非順序上下方的 PassThrough_13,同理,Project_10 資料依賴是 Project_8 的輸出變數 b。

這裡需要花點時間區分的是:執行依賴和資料依賴。執行依賴就是上圖黑色箭頭的順序依賴,而資料依賴則要看運算元對應的 inputVar 來自哪裡。因此,資料依賴可能同執行依賴是同一個邏輯順序,也可能是不同的順序。

造成上述情況的原因是,nGQL 這個查詢語言本身是非常靈活的。不同於 SQL 將 a、b、c 分別生成執行計劃,生成 3 個執行計劃,在 NebulaGraph 中,像上面這種 a、b 沒有資料依賴關係,c 依賴 a、b,在生成 plan 的時候,會將其當成擁有一定順序 subsequential 的語句進行處理。一條執行計劃便可完成多條語句的執行,當然非常靈活,但也變相地增加了語句調優的難度。

再來看一個 openCypher 的例子:

match (v:player)with v.name as namematch (v:player)--(v2) where v2.name = namewith v2.age as agematch (v:player) where v.age>agereturn v.name

在這個多 MATCH 的例子中,每個 MATCH 都可以作為一個 Pattern。像這條語句中第一個 MATCH 的輸出結果傳遞給下面的第二個 MATCH 變成過濾輸入,而第三個 MATCH 的過濾輸入則來源於第二個 MATCH 的輸出。每個 MATCH 透過 WITH 進行連線,它不同 nGQL,存在 Subquery 的概念,在 openCypher 這裡,資料依賴關係是線性,非常自然地透過一個個 WITH 傳遞給下一個 Pattern 的。如果第一個 WITH 沒有將結果帶到第二個 MATCH,第三個 MATCH 便無法完成。

正是因為這種線性關係,Neo4j 的執行計劃是樹狀的;而 nGQL 因為查詢靈活性的緣故,生成執行計劃中有向有環圖。

執行計劃的最佳化

目前,NebulaGraph 只做了 RBO 最佳化,即:Rule-Based Optimization 基於規則的最佳化。NebulaGraph 的 RBO 是基於業界流行的實現方法,有些許的差異化,使用者可以基於之前的經驗或者相關規則,進行 plan 的最佳化(變形)。

NebulaGraph 的 RBO 是以 memo + bottom up 方式進行的。

在 NebulaGraph 中,每個 plan 的 plan node 會放到一個小組 group 中,每個 group 中的 plan node 語義上等價,上一個 group node 連線到下一個 group,而下一個 group 中有多個 plan node 的話,便可變化出多個 plan。

整個最佳化的過程,其實是一個迭代的過程,最佳化器會找到每個 plan 中最葉子的 plan node,同規則集進行匹配,規則集可以檢視這裡: 。如果規則匹配上,則將兩個 subplan 進行變形,生成新的 subplan,並插入到相關的 group 中。

比如上圖左側的 Filter 運算元,它和 GetNeighbors 運算元透過某個 rule 匹配上了,便會生成新的 GetNeighbors node,它帶有 Filter。新的 GetNeighbors node 會插入到之前的 Filter 所在的 group,因為 memo 的原因,新的 GetNeighbors 便可同之前的 GetNeighbors 連線的 subplan Start 進行連線。

基於 RBO 預設如果匹配上規則,則匹配上規則之後生成的新的 plan 是優於之前的 plan,新的 plan 會直接替代舊的 plan。因此,這個執行計劃最後會變成右側這樣。

幾個執行計劃調優的 flag

上文提到過,執行計劃中會有個 Runtime 階段,下面引數主要是影響 graph 這邊 Runtime 階段的執行效率。

  • --max_job_size:每個運算元內部併發的最大 job 數;
  • --min_batch_size:運算元內部併發執行時,切分任務時的最小 batch 行數;
  • --optimize_appendvertices:AppendVertices 運算元內部的效能最佳化控制,當沒有懸掛邊時可以開啟該項減少 RPC 呼叫。

max_job_size 主要控制 graph 部分運算元的併發程度,同  min_batch_size 配合使用。這和 NebulaGraph 的物化模型有關,在 NebulaGraph 中每個運算元在被執行完之後,其結果會被物化到記憶體中,在下一次迭代的時候去對應記憶體中撈取資料,而不是透過 Pipeline 的方式進行計算。 max_job_size 和  min_batch_size 這兩個 flag 控制了迭代過程中每個執行緒可處理的 batch 數量,從而達到最佳化的效果。

optimize_appendvertices 引數主要是用來服務 MATCH 語句的,當我們使用 MATCH 時,可能會常遇到一個情況:用 MATCH 去做路徑查詢時,希望這個路徑中是不存在懸掛邊的。假如,你知道你的資料中不存在懸掛邊,可以設定  optimize_appendvertices 為  true,這樣就無需再同 storage 互動去驗證是否這條邊的起始點存在,從而會節約執行時間。

執行計劃如何看?

上面說了些原理,下面可是實操下,教你看懂執行計劃,以及如何去最佳化它。

將  PROFILE 或者是  EXPLAIN 加在對應的語句前面,即可得到相關的執行計劃。像這樣:

profile GET SUBGRAPH 5 STEPS FROM "Yao Ming" OUT like where like. likeness > 80 YIELD VERTICES AS nodes, EDGES AS relationshipis;
explain GET SUBGRAPH 5 STEPS FROM "Yao Ming" OUT Like where like. likeness > 80 YIELD VERTICES AS nodes, EDGES AS relationshipis;

如果你不知道資料量的情況下,用  EXPLAIN 可檢視對應的執行計劃構成,它不包括該語句各個運算元的執行時間。

在社群中,常會到一類問題:我透過 SUBGRAPH 進行條件過濾時,是不是每一跳都會應用到邊過濾。相信透過這個例子,你就能知道是不是每跳都會應用到條件過濾了。

Execution Plan (optimize time 41 us)
-----+-------------+--------------+----------------+----------------------------------| id | name        | dependencies | profiling data | operator info                   |-----+-------------+--------------+----------------+----------------------------------|  2 | DataCollect | 1            |                | outputVar: {                    ||    |             |              |                |   "colNames": [                 ||    |             |              |                |     "nodes",                    ||    |             |              |                |     "relationshipis"            ||    |             |              |                |   ],                            ||    |             |              |                |   "type": "DATASET",            ||    |             |              |                |   "name": "__DataCollect_2"     ||    |             |              |                | }                               ||    |             |              |                | inputVar: [                     ||    |             |              |                |   {                             ||    |             |              |                |     "colNames": [],             ||    |             |              |                |     "type": "DATASET",          ||    |             |              |                |     "name": "__Subgraph_1"      ||    |             |              |                |   }                             ||    |             |              |                | ]                               ||    |             |              |                | distinct: false                 ||    |             |              |                | kind: SUBGRAPH                  |-----+-------------+--------------+----------------+----------------------------------|  1 | Subgraph    | 0            |                | outputVar: {                    ||    |             |              |                |   "colNames": [],               ||    |             |              |                |   "type": "DATASET",            ||    |             |              |                |   "name": "__Subgraph_1"        ||    |             |              |                | }                               ||    |             |              |                | inputVar: __VAR_0               ||    |             |              |                | src: COLUMN[0]                  ||    |             |              |                | tag_filter:                     ||    |             |              |                | edge_filter: (like.likeness>80) ||    |             |              |                | filter: (like.likeness>80)      ||    |             |              |                | vertexProps: [                  ||    |             |              |                |   {                             ||    |             |              |                |     "props": [                  ||    |             |              |                |       "_tag"                    ||    |             |              |                |     ],                          ||    |             |              |                |     "tagId": 2                  ||    |             |              |                |   },                            ||    |             |              |                |   {                             ||    |             |              |                |     "props": [                  ||    |             |              |                |       "_tag"                    ||    |             |              |                |     ],                          ||    |             |              |                |     "tagId": 4                  ||    |             |              |                |   },                            ||    |             |              |                |   {                             ||    |             |              |                |     "props": [                  ||    |             |              |                |       "_tag"                    ||    |             |              |                |     ],                          ||    |             |              |                |     "tagId": 3                  ||    |             |              |                |   }                             ||    |             |              |                | ]                               ||    |             |              |                | edgeProps: [                    ||    |             |              |                |   {                             ||    |             |              |                |     "props": [                  ||    |             |              |                |       "_dst",                   ||    |             |              |                |       "_rank",                  ||    |             |              |                |       "_type",                  ||    |             |              |                |       "likeness"                ||    |             |              |                |     ],                          ||    |             |              |                |     "type": 5                   ||    |             |              |                |   }                             ||    |             |              |                | ]                               ||    |             |              |                | steps: 5                        |-----+-------------+--------------+----------------+----------------------------------|  0 | Start       |              |                | outputVar: {                    ||    |             |              |                |   "colNames": [],               ||    |             |              |                |   "type": "DATASET",            ||    |             |              |                |   "name": "__Start_0"           ||    |             |              |                | }                               |-----+-------------+--------------+----------------+----------------------------------
Tue, 17 Oct 2023 17:35:12 CST

生成在終端的這個執行計劃是表結構的,透過 id 和 dependence 可檢視到對應的依賴關係,從而解決之前提到過的執行計劃中有環這種問題。

先看看當中 subgraph 運算元資訊,我們可以 edge_filter 中帶有表示式,如果 edge_filter /tag_filter 中帶有表示式,則表示該表示式被計算下推了。

下面是透過  PROFILE 檢視到的更具體的執行計劃:

DataCollect 運算元有這些引數:

  • execTime:graphd 的處理時間;
  • rows:返回的資料條數;
  • totalTime:從運算元起始到到運算元退出時間;
  • version:像前面章節提到過的 LOOP,其實 LOOP 裡面有一個 LOOP body,對應也是一個 subplan。而這個 subplan 則是會被重複執行,且資料放在同一個變數當中,而這個變數則會有多個版本,即 version。假如運算元不在 LOOP body 內,則只有一個 verison。預設為 0;

Subgraph 運算元有這些引數(同上面已講解引數去重):

  • resp[]:每個 storage 節點執行的結果
  • exec:storaged 的處理時間,同上面的 graphd 處理時間的  execTime
  • storage_detail:storage 資訊,由於 subgraph 需要同 storage 互動,因此由這個欄位用來記錄 storage 這邊 plan node 的執行時間;
  • total:storage client 接收到 graphd 請求到 storage client 傳送請求的時間,即 storaged 本身的處理時間加上序列化和反序列化的時間;
  • vertices:該語句涉及的點的資料;
  • total_rpc_time:graphd 呼叫 storage client 發出請求到接收到請求時間;

生成格式的執行計劃

在剛開始的時候展示的執行計劃和實操時展示的執行計劃格式並不相同, profile format="" 來完成格式指定,預設為表結構,可透過格式指定,指定為  .dot 格式,在終端複製出來執行計劃,前往線上網站:  進行格式渲染。

以上,便是一個全方位的執行計劃講解。當然你可以透過閱讀思為的 《nGQL 簡明教程 vol.02 執行計劃詳解與調優》,瞭解更多的例子從而掌握執行計劃。

推薦閱讀


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69952037/viewspace-2995489/,如需轉載,請註明出處,否則將追究法律責任。

相關文章