上篇我們講到 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
將它們首尾相連。
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 之間有執行依賴和資料依賴兩大關係。
- 執行依賴:從執行順序上看,plan 是一個有向無環圖,節點間的依賴關係在生成 plan 時確定。在執行階段,執行器會對每個節點生成一個對應的運算元,並且從根節點開始排程,此時發現此節點依賴其他節點,就先遞迴呼叫依賴的節點,一直找到沒有任何依賴的節點(Start 節點),然後開始執行,執行此節點後,繼續執行此節點被依賴的其他節點,一直到根節點為止。
- 資料依賴:節點的資料依賴一般和執行依賴相同,即來自前面一個排程執行的節點的輸出。有的節點,如:InnerJoin 會有多個輸入,那麼它的輸入可能是和它間隔好幾個節點的某個節點的輸出。
(實線為執行依賴,虛線為資料依賴)
舉個例子
我們以 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。
=>
每個 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 方法又分為以下幾個步驟:
- 尋找擴充的起點:
目前有三個尋找起點的策略,由 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 上的索引。
- 根據起點及 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;
- 輸出 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 節點。
第三個 clause 是 Return Clause,會生成一個 Project 節點。
RETURN id(v), age
最終整合語句完整的的執行計劃如下圖:
以上為本篇文章的介紹內容。
交流圖資料庫技術?加入 Nebula 交流群請先填寫下你的 Nebula 名片,Nebula 小助手會拉你進群~~