Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

NebulaGraph發表於2021-10-02

Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

上篇我們講到 Validator 會將由 Parser 生成的抽象語法樹(AST)轉化為執行計劃,這次,我們來講下執行計劃是如何生成的。

概述  

Planner 是執行計劃(Execution Plan)生成器,它會根據 Validator 校驗過、語義合法的查詢語法樹生成可供執行器(Executor)執行的未經優化的執行計劃,而該執行計劃會在之後交由 Optimizer 生成一個優化的執行計劃,並最終交給 Executor 執行。執行計劃由一系列節點(PlanNode)組成。

原始碼目錄結構

src/planner
├── CMakeLists.txt
├── match/
├── ngql/
├── plan/
├── Planner.cpp
├── Planner.h
├── PlannersRegister.cpp
├── PlannersRegister.h
├── SequentialPlanner.cpp
├── SequentialPlanner.h
└── test

其中,Planner.h中定義了 SubPlan 的資料結構和 planner 的幾個介面。 

struct SubPlan {
    // root and tail of a subplan.
    PlanNode*   root{nullptr};
    PlanNode*   tail{nullptr};
};

PlannerRegister 負責註冊可用的 planner,Nebula Graph 目前註冊了 SequentialPlanner、PathPlanner、LookupPlanner、GoPlanner、MatchPlanner。

SequentialPlanner 對應的語句是 SequentialSentences,而 SequentialSentence 是由多個 Sentence 及間隔分號組成的組合語句。每個語句又可能是 GO/LOOKUP/MATCH等語句,所以 SequentialPlanner 是通過呼叫其他幾個語句的 planner 來生成多個 plan,並用 Validator::appendPlan 將它們首尾相連。

Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

match 目錄定義了 openCypher 相關語句及子句(如 MATCH、UNWIND、WITH、RETURN、WHERE、ORDER BY、SKIP、LIMIT)的 planner 和 SubPlan 之間的連線策略等。SegmentsConnector 根據 SubPlan 之間的關係使用相應的連線策略(AddInput、addDependency、innerJoinSegments 等)將它們首尾連線成一個完整的 plan。

src/planner/match
├── AddDependencyStrategy.cpp
├── AddDependencyStrategy.h
├── AddInputStrategy.cpp
├── AddInputStrategy.h
├── CartesianProductStrategy.cpp
├── CartesianProductStrategy.h
├── CypherClausePlanner.h
├── EdgeIndexSeek.h
├── Expand.cpp
├── Expand.h
├── InnerJoinStrategy.cpp
├── InnerJoinStrategy.h
├── LabelIndexSeek.cpp
├── LabelIndexSeek.h
├── LeftOuterJoinStrategy.h
├── MatchClausePlanner.cpp
├── MatchClausePlanner.h
├── MatchPlanner.cpp
├── MatchPlanner.h
├── MatchSolver.cpp
├── MatchSolver.h
├── OrderByClausePlanner.cpp
├── OrderByClausePlanner.h
├── PaginationPlanner.cpp
├── PaginationPlanner.h
├── PropIndexSeek.cpp
├── PropIndexSeek.h
├── ReturnClausePlanner.cpp
├── ReturnClausePlanner.h
├── SegmentsConnector.cpp
├── SegmentsConnector.h
├── SegmentsConnectStrategy.h
├── StartVidFinder.cpp
├── StartVidFinder.h
├── UnionStrategy.h
├── UnwindClausePlanner.cpp
├── UnwindClausePlanner.h
├── VertexIdSeek.cpp
├── VertexIdSeek.h
├── WhereClausePlanner.cpp
├── WhereClausePlanner.h
├── WithClausePlanner.cpp
├── WithClausePlanner.h
├── YieldClausePlanner.cpp
└── YieldClausePlanner.h

ngql 目錄定義了 nGQL 語句相關的 planner(如 GO、LOOKUP、FIND PATH)

src/planner/ngql
├── GoPlanner.cpp
├── GoPlanner.h
├── LookupPlanner.cpp
├── LookupPlanner.h
├── PathPlanner.cpp
└── PathPlanner.h

plan 目錄定義了 7 大類,共計 100 多種 Plan Node。

src/planner/plan
├── Admin.cpp
├── Admin.h
├── Algo.cpp
├── Algo.h
├── ExecutionPlan.cpp
├── ExecutionPlan.h
├── Logic.cpp
├── Logic.h
├── Maintain.cpp
├── Maintain.h
├── Mutate.cpp
├── Mutate.h
├── PlanNode.cpp
├── PlanNode.h
├── Query.cpp
├── Query.h
└── Scan.h

部分節點說明:

  • Admin 是資料庫管理相關節點
  • Algo 是路徑、子圖等演算法相關節點
  • Logic 是邏輯控制節點,如迴圈、二元選擇等
  • Maintain 是 schema 相關節點
  • Mutate 是 DML 相關節點
  • Query 是查詢計算相關的節點
  • Scan 是索引掃描相關節點

每個 PlanNode 在 Executor(執行器)階段會生成相應的 executor,每種 executor 負責一個具體的功能。

eg. GetNeighbors 節點:

static GetNeighbors* make(QueryContext* qctx,
                              PlanNode* input,
                              GraphSpaceID space,
                              Expression* src,
                              std::vector<EdgeType> edgeTypes,
                              Direction edgeDirection,
                              std::unique_ptr<std::vector<VertexProp>>&& vertexProps,
                              std::unique_ptr<std::vector<EdgeProp>>&& edgeProps,
                              std::unique_ptr<std::vector<StatProp>>&& statProps,
                              std::unique_ptr<std::vector<Expr>>&& exprs,
                              bool dedup = false,
                              bool random = false,
                              std::vector<storage::cpp2::OrderBy> orderBy = {},
                              int64_t limit = -1,
                              std::string filter = "")

GetNeighbors 是儲存層邊的 kv 的語義上的封裝:它根據給定型別邊的起點,找到邊的終點。在找邊過程中,GetNeighbors 可以獲取邊上屬性(edgeProps)。因為出邊隨起點儲存在同一個 partition(資料切片)上,所以我們還可以方便地獲得邊上起點的屬性(vertexProps)。

Aggregate 節點:

static Aggregate* make(QueryContext* qctx,
                               PlanNode* input, 
                               std::vector<Expression*>&& groupKeys = {},
                               std::vector<Expression*>&& groupItems = {})

Aggregate 節點為聚合計算節點,它根據 groupKeys 作分組,根據 groupItems 做聚合計算作為組內值。

Loop 節點:

static Loop* make(QueryContext* qctx,
                      PlanNode* input,
                      PlanNode* body = nullptr,
                      Expression* condition = nullptr);

loop 為迴圈節點,它會一直執行 body 到最近一個 start 節點之間的 PlanNode 片段直到 condition 值為 false。

InnerJoin 節點:

static InnerJoin* make(QueryContext* qctx,
                           PlanNode* input,
                           std::pair<std::string, int64_t> leftVar,
                           std::pair<std::string, int64_t> rightVar,
                           std::vector<Expression*> hashKeys = {},
                           std::vector<Expression*> probeKeys = {})

InnerJoin 節點對兩個表(Table、DataSet)做內聯,leftVar 和 rightVar 分別用來引用兩個表。

入口函式

planner 入口函式是 Validator::toPlan

Status Validator::toPlan() {
    auto* astCtx = getAstContext();
    if (astCtx != nullptr) {
        astCtx->space = space_;
    }
    auto subPlanStatus = Planner::toPlan(astCtx);
    NG_RETURN_IF_ERROR(subPlanStatus);
    auto subPlan = std::move(subPlanStatus).value();
    root_ = subPlan.root;
    tail_ = subPlan.tail;
    VLOG(1) << "root: " << root_->kind() << " tail: " << tail_->kind();
    return Status::OK();
}

具體步驟

1.呼叫 getAstContext()

首先呼叫 getAstContext() 獲取由 validator 校驗並重寫過的 AST 上下文,這些 context 相關資料結構定義在 src/context中。

src/context/ast
├── AstContext.h
├── CypherAstContext.h
└── QueryAstContext.h
struct AstContext {
    QueryContext*   qctx; // 每個查詢請求的 context
    Sentence*       sentence; // query 語句的 ast
    SpaceInfo       space; // 當前 space
};

CypherAstContext 中定義了 openCypher 相關語法的 ast context,QueryAstContext 中定義了 nGQL 相關語法的 ast context。

2.呼叫Planner::toPlan(astCtx)

然後呼叫 Planner::toPlan(astCtx),根據 ast context 在 PlannerMap 中找到語句對應註冊過的 planner,然後生成相應的執行計劃。

每個 Plan 由一系列 PlanNode 組成,PlanNode 之間有執行依賴資料依賴兩大關係。

  1. 執行依賴:從執行順序上看,plan 是一個有向無環圖,節點間的依賴關係在生成 plan 時確定。在執行階段,執行器會對每個節點生成一個對應的運算元,並且從根節點開始排程,此時發現此節點依賴其他節點,就先遞迴呼叫依賴的節點,一直找到沒有任何依賴的節點(Start 節點),然後開始執行,執行此節點後,繼續執行此節點被依賴的其他節點,一直到根節點為止。
  2. 資料依賴:節點的資料依賴一般和執行依賴相同,即來自前面一個排程執行的節點的輸出。有的節點,如:InnerJoin 會有多個輸入,那麼它的輸入可能是和它間隔好幾個節點的某個節點的輸出。

Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

(實線為執行依賴,虛線為資料依賴)

舉個例子

我們以 MatchPlanner 為例,來看一個執行計劃是如何生成的:

語句:

MATCH (v:player)-[:like*2..4]-(v2:player)\
WITH v, v2.age AS age ORDER BY age WHERE age > 18\
RETURN id(v), age

該語句經過 MatchValidator 的校驗和重寫後會輸出一個 context 組成的 tree。

Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

=>

Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

每個 Clause 及 SubClause 對應一個 context:

enum class CypherClauseKind : uint8_t {
    kMatch,
    kUnwind,
    kWith,
    kWhere,
    kReturn,
    kOrderBy,
    kPagination,
    kYield,
};

struct CypherClauseContextBase : AstContext {
    explicit CypherClauseContextBase(CypherClauseKind k) : kind(k) {}
    virtual ~CypherClauseContextBase() = default;

    const CypherClauseKind  kind;
};

struct MatchClauseContext final : CypherClauseContextBase {
    MatchClauseContext() : CypherClauseContextBase(CypherClauseKind::kMatch) {}

    std::vector<NodeInfo>                       nodeInfos; // pattern 中涉及的頂點資訊
    std::vector<EdgeInfo>                       edgeInfos; // pattern 中涉及的邊資訊
    PathBuildExpression*                        pathBuild{nullptr}; // 構建 path 的表示式
    std::unique_ptr<WhereClauseContext>         where; // filter SubClause
    std::unordered_map<std::string, AliasType>* aliasesUsed{nullptr}; // 輸入的 alias 資訊
    std::unordered_map<std::string, AliasType>  aliasesGenerated; // 產生的 alias 資訊
};
...

然後:

1.找語句 planner

找到對應語句的 planner,該語句型別為 Match。在 PlannersMap 中找到該語句的 planner MatchPlanner。

2.生成 plan

呼叫 MatchPlanner::transform 方法生成 plan:

StatusOr<SubPlan> MatchPlanner::transform(AstContext* astCtx) {
    if (astCtx->sentence->kind() != Sentence::Kind::kMatch) {
        return Status::Error("Only MATCH is accepted for match planner.");
    }
    auto* matchCtx = static_cast<MatchAstContext*>(astCtx);

    std::vector<SubPlan> subplans;
    for (auto& clauseCtx : matchCtx->clauses) {
        switch (clauseCtx->kind) {
            case CypherClauseKind::kMatch: {
                auto subplan = std::make_unique<MatchClausePlanner>()->transform(clauseCtx.get());
                NG_RETURN_IF_ERROR(subplan);
                subplans.emplace_back(std::move(subplan).value());
                break;
            }
            case CypherClauseKind::kUnwind: {
                auto subplan = std::make_unique<UnwindClausePlanner>()->transform(clauseCtx.get());
                NG_RETURN_IF_ERROR(subplan);
                auto& unwind = subplan.value().root;
                std::vector<std::string> inputCols;
                if (!subplans.empty()) {
                    auto input = subplans.back().root;
                    auto cols = input->colNames();
                    for (auto col : cols) {
                        inputCols.emplace_back(col);
                    }
                }
                inputCols.emplace_back(unwind->colNames().front());
                unwind->setColNames(inputCols);
                subplans.emplace_back(std::move(subplan).value());
                break;
            }
            case CypherClauseKind::kWith: {
                auto subplan = std::make_unique<WithClausePlanner>()->transform(clauseCtx.get());
                NG_RETURN_IF_ERROR(subplan);
                subplans.emplace_back(std::move(subplan).value());
                break;
            }
            case CypherClauseKind::kReturn: {
                auto subplan = std::make_unique<ReturnClausePlanner>()->transform(clauseCtx.get());
                NG_RETURN_IF_ERROR(subplan);
                subplans.emplace_back(std::move(subplan).value());
                break;
            }
            default: { return Status::Error("Unsupported clause."); }
        }
    }

    auto finalPlan = connectSegments(astCtx, subplans, matchCtx->clauses);
    NG_RETURN_IF_ERROR(finalPlan);
    return std::move(finalPlan).value();
}

match 語句可能由多個 MATCH/UNWIND/WITH/RETURN Clause 組成,所以在 transform 中,根據 Clause 的型別,直接呼叫相應的 ClausePlanner 生成 SubPlan,最後再由 SegmentsConnector 依據各種連線策略將它們連線起來。

在我們的示例語句中,

第一個 Clause 是 Match Clause: MATCH (v:player)-[:like*2..4]-(v2:player),所以會呼叫 MatchClause::transform 方法

StatusOr<SubPlan> MatchClausePlanner::transform(CypherClauseContextBase* clauseCtx) {
    if (clauseCtx->kind != CypherClauseKind::kMatch) {
        return Status::Error("Not a valid context for MatchClausePlanner.");
    }

    auto* matchClauseCtx = static_cast<MatchClauseContext*>(clauseCtx);
    auto& nodeInfos = matchClauseCtx->nodeInfos;
    auto& edgeInfos = matchClauseCtx->edgeInfos;
    SubPlan matchClausePlan;
    size_t startIndex = 0;
    bool startFromEdge = false;

    NG_RETURN_IF_ERROR(findStarts(matchClauseCtx, startFromEdge, startIndex, matchClausePlan));
    NG_RETURN_IF_ERROR(
        expand(nodeInfos, edgeInfos, matchClauseCtx, startFromEdge, startIndex, matchClausePlan));
    NG_RETURN_IF_ERROR(projectColumnsBySymbols(matchClauseCtx, startIndex, matchClausePlan));
    NG_RETURN_IF_ERROR(appendFilterPlan(matchClauseCtx, matchClausePlan));
    return matchClausePlan;
}

該 transform 方法又分為以下幾個步驟:

  1. 尋找擴充的起點:

目前有三個尋找起點的策略,由 planner 註冊在 startVidFinders 裡:

// MATCH(n) WHERE id(n) = value RETURN n
startVidFinders.emplace_back(&VertexIdSeek::make);

// MATCH(n:Tag{prop:value}) RETURN n
// MATCH(n:Tag) WHERE n.prop = value RETURN n
startVidFinders.emplace_back(&PropIndexSeek::make);

// seek by tag or edge(index)
// MATCH(n: tag) RETURN n
// MATCH(s)-[:edge]->(e) RETURN e
startVidFinders.emplace_back(&LabelIndexSeek::make);

這三個策略中,VertexIdSeek 最佳,可以確定具體的起點 VID;PropIndexSeek 次之,會被轉換為一個附帶屬性 filter 的 IndexScan;LabelIndexSeek 會被轉換為一個 IndexScan。

findStarts 函式會對每個尋找起點策略,分別遍歷 match pattern 中的所有節點資訊,直到找到一個可以作為起點的 node,並生成相應的找起點的 Plan Nodes。

示例語句的尋點策略是 LabelIndexScan,確定的起點是 v。最終生成一個 IndexScan 節點,索引為 player 這個 tag 上的索引。

  1. 根據起點及 match pattern,進行多步擴充:

示例中句子的 match pattern 為:(v:player)-[:like*1..2]-(v2:player),以 v 為起點,沿著邊 like 擴充一到二步,終點擁有 player 型別 tag。

先做擴充:

Status Expand::doExpand(const NodeInfo& node, const EdgeInfo& edge, SubPlan* plan) {
    NG_RETURN_IF_ERROR(expandSteps(node, edge, plan));
    NG_RETURN_IF_ERROR(filterDatasetByPathLength(edge, plan->root, plan));
    return Status::OK();
}

多步擴充會生成 Loop 節點,loop body 為 expandStep 意為根據給定起點擴充一步,擴充一步需要生成 GetNeighbors 節點。每一步擴充的終點作為後面一步擴充的起點,一直迴圈下去,直到達到 pattern 中指定的最大步數。

在做第 M 步擴充時,以前面得到的長度為 M-1 的 path 的終點作為本次擴充的起點,向外延伸一步,並根據擴充的結果構建一個以邊的起點和邊本身組成的步長為 1 的 path,然後將該步長為 1 的 path 與前面的步長為 M-1 的 path 做一個 InnerJoin 得到步長為 M 的一組 path。

再呼叫對這組 path 做過濾,去除掉有重複邊的 path(openCypher 路徑的擴充不允許有重複邊),最後將 path 的終點輸出作為下一步擴充的起點。下一步擴充繼續做上述步驟,直至達到最大中指定的最大步數。

loop 之後會生成 UnionAllVersionVar 節點,將 loop body 每次迴圈構建出的步長分別為 1 到 M 步的 path 合併起來。filterDatasetByPathLength()函式會生成一個 Filter 節點過濾掉步長小於 match pattern 中指定最小步數的 path。

最終得到的 path 形如(v)-like-()-e-(v)-?,還缺少最後一步的終點的屬性資訊。因此,我們還需要生成一個 GetVertices 節點,然後將獲取到的終點與之前的 M 步 path 再做一個 InnerJoin,得到的就是符合 match pattern 要求的 path 集合了!

match 多步擴充原理會在 Variable Length Pattern Match 一文中有更詳細的解釋。

// Build Start node from first step
SubPlan loopBodyPlan;
PlanNode* startNode = StartNode::make(matchCtx_->qctx);
startNode->setOutputVar(firstStep->outputVar());
startNode->setColNames(firstStep->colNames());
loopBodyPlan.tail = startNode;
loopBodyPlan.root = startNode;

// Construct loop body
NG_RETURN_IF_ERROR(expandStep(edge,
                              startNode,                // dep
                              startNode->outputVar(),   // inputVar
                              nullptr,
                              &loopBodyPlan));

NG_RETURN_IF_ERROR(collectData(startNode,           // left join node
                               loopBodyPlan.root,   // right join node
                               &firstStep,          // passThrough
                               &subplan));
// Union node
auto body = subplan.root;

// Loop condition
auto condition = buildExpandCondition(body->outputVar(), startIndex, maxHop);

// Create loop
auto* loop = Loop::make(matchCtx_->qctx, firstStep, body, condition);

// Unionize the results of each expansion which are stored in the firstStep node
auto uResNode = UnionAllVersionVar::make(matchCtx_->qctx, loop);
uResNode->setInputVar(firstStep->outputVar());
uResNode->setColNames({kPathStr});

subplan.root = uResNode;
plan->root = subplan.root; 
  1. 輸出 table,確定 table 的列名:

將 match pattern 中所有出現的具名符號作為 table 列名,生成一個 table,以供後續子句使用。這會生成一個 Project 節點。

第二個 clause 是 WithClause,呼叫 WithClause::transform 生成 SubPlan

WITH v, v2.age AS age ORDER BY age WHERE age > 18

該 WITH 子句先 yield v 和 v2.age 兩列作為一個 table,然後以 age 作為 sort item 進行排序,然後對排序後的 table 作 filter。

YIELD 部分會生成一個 Project 節點,ORDER BY 部分會生成一個 Sort 節點,WHERE 部分對應一個會生成一個 Filter 節點。

Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

第三個 clause 是 Return Clause,會生成一個 Project 節點

RETURN id(v), age

最終整合語句完整的的執行計劃如下圖:

Nebula Graph 原始碼解讀系列 | Vol.03 Planner 的實現

以上為本篇文章的介紹內容。

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

相關文章