本文由社区用户 Milittle 供稿
LOOKUP 是图数据库 NebulaGraph 的一个查询语句。它依赖索引,可以查询点或者边的信息。在本文,我将着重从源码的角度解析一下 LOOKUP 语句的一生是如何度过的。
本文源码阅读基于内核源码的 v3.3.0 版本,详见 GitHub https://github.com/vesoft-inc/nebula/releases/tag/v3.3.0
读源码之前
首先,我们需要明确 NebulaGraph 中 LOOKUP 语句的语法:
LOOKUP ON {<vertex_tag> | <edge_type>}
[WHERE <expression> [AND <expression> ...]]
YIELD <return_list> [AS <alias>]
[<clause>];
<return_list>
<prop_name> [AS <col_alias>] [, <prop_name> [AS <prop_alias>] ...];
<vertex_tag>
是 Tag 的类型,比如:数据集 basketballplayer 中的 player 和 team;<edge_type>
是 EdgeType 的类型,比如:数据集 basketballplayer 中的 follow 和 serve;<expression>
是表达式;<return_list>
是返回的列表,比如:id(vertex),这部分内容详细参见 nGQL 的 Schema 函数 nGQL Schema 函数详解;<clause>
是子句,可以是ORDER BY
、LIMIT
等子句,子句详情参见 子句;
这里有个 LOOKUP 使用注意事项:
- 如果已经存在点、边,但是没有索引。必须在新建索引后再通过
REBUILD INDEX
重建索引,才能使其生效;
读语句解析原理
为了便于大家理解这里放一张 NebulaGraph 计算层的服务架构:
我们再来看下此次阅读的语句,是一个比较简单的 LOOKUP Sentence。用比较简单的语句来解析 LOOKUP 语句的基本原理,后面可以慢慢扩展条件语句和子句:
// 我们需要分析以下语句
LOOKUP ON player YIELD id(vertex);
1. 从 Parser 开始
我们先从 Parser 入手分析 LOOKUP Sentence 的组成部分。这里不介绍 lex 词法分析和 yacc 语法分析,感兴趣的小伙伴自己可以了解一下。下面,我们直接上我们关心的部分:
我们打开源码,找到文件 src/parser/parser.yy
文件,里面有所有语句的定义。我们 定位到 LOOKUP Sentence,是这里 https://github.com/Milittle/nebula/blob/90a3107044ce1621c7834a0f36a4eef273ec2f31/src/parser/parser.yy#L2176
。下面便是 LOOKUP 语句的定义,你也可以拷贝上面的链接访问 GitHub 查看。来,我们分析分析每个部分:
/// LOOKUP 语句的语法定义
lookup_sentence
: KW_LOOKUP KW_ON name_label lookup_where_clause yield_clause {
$$ = new LookupSentence($3, $4, $5);
}
;
// KW_LOOKUP 是 LOOKUP 的关键字,大小写不敏感的
// KW_ON 是 ON 的关键字,大小写不敏感的
// name_label 是 LABEL 的定义,也是 strval,简单的说就是字符串
// lookup_where_clause 是 WHERE 子句的定义,这个我们后面有机会扩展介绍,也有一个对应的语义定义
// yield_clause 这个是 YIELD 输出数据的关键语句,在 v3.x 版本以后,YIELD 子句是必须要指定的,不指定会报语法错误
/// YIELD clause 的语法定义,其实 YIELD clause 用在了很多其他语句中,比如 GO、FIND PATH、GET SUBGRAPH
yield_clause
: %empty { $$ = nullptr; }
| KW_YIELD yield_columns {
if ($2->hasAgg()) {
delete($2);
throw nebula::GraphParser::syntax_error(@2, "Invalid use of aggregating function in yield clause.");
}
$$ = new YieldClause($2);
}
| KW_YIELD KW_DISTINCT yield_columns {
if ($3->hasAgg()) {
delete($3);
throw nebula::GraphParser::syntax_error(@3, "Invalid use of aggregating function in yield clause.");
}
$$ = new YieldClause($3, true);
}
;
// 可以为 empty,但是后面 validator 会进行校验,不指定就会报 Error
// KW_YIELD 是 YIELD 的关键字,大小写不敏感
// yield_columns 是输出的列信息,也有对应的一个语法定义
// KW_DISTINCT 是 distinct 关键字,表示是否去除重复数据的语义,大小写不敏感
// LOOKUP Sentence 就是上面所有的信息组成,都会被构造在这个类里面,也就是 LOOKUP 语句的内容了
下面,我们继续从 lookup_sentence
语句的定义往下规约看,可以看到它属于 src/parser/parser.yy:2917: traverse_sentence → src/parser/parser.yy:2936: piped_sentence → src/parser/parser.yy:2942: set_sentence → src/parser/parser.yy:3924: sentence → src/parser/parser.yy:3933: seq_sentence
。
其实,上面这些你可以暂时忽略,因为这些都是对 sentence 的规约抽象,有些集合语句和管道语句。这里,我想表达的是这些语句一定会映射到 seq_sentence
上的,即,序列语句。你可以把它理解为用分号分隔的复合语句,只不过这里面只包含了一条 lookup_sentence
而已。这样子,就好理解为什么下文在 seq_sentence
寻找入口代码,而不是 lookup_sentence
.
2. 从 nGQL 解析看 LOOKUP 语句
第二,从 nGQL 的解析过程继续看 LOOKUP Sentence。其实,刚才已经强调过了,这里解析出来的对象一定是 seq_sentence
。
/// src/graph/service/QueryInstance.cpp
void QueryInstance::execute() {
Status status = validateAndOptimize(); // 1. 负责 validate、执行计划生成、执行计划优化等工作
if (!status.ok()) {
one rror(std::move(status));
return;
}
// Sentence is explain query, finish
if (!explainOrContinue()) { // 6. 判断是否是 explain 语句。如果是,直接输出执行计划,不做实际物理算子执行
onFinish();
return;
}
// The execution engine converts the physical execution plan generated by the Planner into a
// series of Executors through the Scheduler to drive the execution of the Executors.
scheduler_->schedule() // 7. 实际物理算子调度执行的部分,通过 DAG,对每一个 plan -> executor 的转换执行(后续步骤会进行详解)
.thenValue([this](Status s) {
if (s.ok()) {
this->onFinish(); // 8. 这里是干完了所有物理执行计划,然后开始处理客户端 resp 了
} else {
this->onError(std::move(s)); // 9. 这里是上面的过程出错了,需要处理 Error 信息
}
}) // 10. 下面是处理一些异常情况,也是走错误分支
.thenError(folly::tag_t<ExecutionError>{},
[this](const ExecutionError &e) { one rror(e.status()); })
.thenError(folly::tag_t<std::exception>{},
[this](const std::exception &e) { one rror(Status::Error("%s", e.what())); });
}
// 这个函数执行的是注释 1 的内容
Status QueryInstance::validateAndOptimize() {
auto *rctx = qctx()->rctx();
auto &spaceName = rctx->session()->space().name;
VLOG(1) << "Parsing query: " << rctx->query();
// Result of parsing, get the parsing tree
// 2. 第一步中的语法解析就是这里的解释,对 nGQL 进行词法语法解析,出来的 result 就是 Sentence*,通过我们上面的分析,这里吐出来的就是 seq_sentence 了
auto result = GQLParser(qctx()).parse(rctx->query());
NG_RETURN_IF_ERROR(result);
sentence_ = std::move(result).value();
// 3. 这里是做指标的统计。这个可以在 dashboard 里面展示
if (sentence_->kind() == Sentence::Kind::kSequential) {
size_t num = static_cast<const SequentialSentences *>(sentence_.get())->numSentences();
stats::StatsManager::addValue(kNumSentences, num);
if (FLAGS_enable_space_level_metrics && spaceName != "") {
stats::StatsManager::addValue(
stats::StatsManager::counterWithLabels(kNumSentences, {{"space", spaceName}}), num);
}
} else {
stats::StatsManager::addValue(kNumSentences);
if (FLAGS_enable_space_level_metrics && spaceName != "") {
stats::StatsManager::addValue(
stats::StatsManager::counterWithLabels(kNumSentences, {{"space", spaceName}}));
}
}
// Validate the query, if failed, return
// 4. 这个是源码校验 nGQL 解析出来的内容是否符合我们的预期,如果不符合预期就报语法错误
// validate 过程还会涉及到执行计划的生成,重点函数
NG_RETURN_IF_ERROR(Validator::validate(sentence_.get(), qctx()));
// Optimize the query, and get the execution plan
// 5. 对上面生成的执行计划进行 RBO 规则的优化,这个留在后面有机会再介绍
NG_RETURN_IF_ERROR(findBestPlan());
stats::StatsManager::addValue(kOptimizerLatencyUs, *(qctx_->plan()->optimizeTimeInUs()));
if (FLAGS_enable_space_level_metrics && spaceName != "") {
stats::StatsManager::addValue(
stats::StatsManager::histoWithLabels(kOptimizerLatencyUs, {{"space", spaceName}}));
}
return Status::OK();
}
我们按照上面的注释部分进行讲解,有的比较容易的部分,像注释 1、2、3、5。我们下面重点介绍注释 4 的部分
// src/graph/validator/Validator.cpp
// Entry of validating sentence.
// Check session, switch space of validator context, create validators and validate.
// static
// 1. 参数 sentence 就是刚才我们从语法解析器中拿到的 seq_sentence
// 2. 参数 qctx 是我们查询上下文,一个语句进来对应一个查询上下文,这个是在 QueryEngine 里面生成的,感兴趣可以自行阅读一下
Status Validator::validate(Sentence* sentence, QueryContext* qctx) {
DCHECK(sentence != nullptr);
DCHECK(qctx != nullptr);
// Check if space chosen from session. if chosen, add it to context.
auto session = qctx->rctx()->session();
if (session->space().id > kInvalidSpaceID) {
auto spaceInfo = session->space();
qctx->vctx()->switchToSpace(std::move(spaceInfo));
}
// 3. 既然我们需要校验该 sentence 是否符合我们的预期,则需要根据 sentence 的类型,创建一个 validator,记住目前是 seq_sentence
// 所以生成的就是 SequentialValidator,可以直接看下 makeValidator 函数的 switch case
auto validator = makeValidator(sentence, qctx);
// 4. 调用 validator 进行校验,我们切换到下面的函数中
NG_RETURN_IF_ERROR(validator->validate());
auto root = validator->root();
if (!root) {
return Status::SemanticError("Get null plan from sequential validator");
}
qctx->plan()->setRoot(root);
return Status::OK();
}
// 5. 所有子类 validator,调用 validate 方法,进行校验
// Validate current sentence.
// Check validator context, space, validate, duplicate reference columns,
// check permission according to sentence kind and privilege of user.
Status Validator::validate() {
if (!vctx_) {
VLOG(1) << "Validate context was not given.";
return Status::SemanticError("Validate context was not given.");
}
if (!sentence_) {
VLOG(1) << "Sentence was not given";
return Status::SemanticError("Sentence was not given");
}
if (!noSpaceRequired_ && !spaceChosen()) {
VLOG(1) << "Space was not chosen.";
return Status::SemanticError("Space was not chosen.");
}
if (!noSpaceRequired_) {
space_ = vctx_->whichSpace();
VLOG(1) << "Space chosen, name: " << space_.spaceDesc.space_name_ref().value()
<< " id: " << space_.id;
}
auto vidType = space_.spaceDesc.vid_type_ref().value().type_ref().value();
vidType_ = SchemaUtil::propTypeToValueType(vidType);
// 6. 调用子类 validateImpl
NG_RETURN_IF_ERROR(validateImpl());
// Check for duplicate reference column names in pipe or var statement
NG_RETURN_IF_ERROR(checkDuplicateColName());
// Execute after validateImpl because need field from it
if (FLAGS_enable_authorize) {
NG_RETURN_IF_ERROR(checkPermission());
}
// 7. 这里是生成执行计划调用
NG_RETURN_IF_ERROR(toPlan());
return Status::OK();
}
讲了这么久了,啥时候到 LOOKUP。只能说快了,因为第一次讲源码,一些上下文信息需要讲清楚,不然大家一看就看得云里雾里了。
3. 深入到 validator
下面,我们要进入 SequentialValidator.cpp
的 validateImpl()
去一探究竟。
// src/graph/validator/SequentialValidator.cpp
// Validator of sequential sentences which combine multiple sentences, e.g. GO ...; GO ...;
// Call validator of sub-sentences.
Status SequentialValidator::validateImpl() {
Status status;
if (sentence_->kind() != Sentence::Kind::kSequential) {
return Status::SemanticError(
"Sequential validator validates a SequentialSentences, but %ld is "
"given.",
static_cast<int64_t>(sentence_->kind()));
}
auto seqSentence = static_cast<SequentialSentences*>(sentence_);
auto sentences = seqSentence->sentences();
if (sentences.size() > static_cast<size_t>(FLAGS_max_allowed_statements)) {
return Status::SemanticError("The maximum number of statements allowed has been exceeded");
}
DCHECK(!sentences.empty());
// 我们的 StartNode 就是这里创建出来的
seqAstCtx_->startNode = StartNode::make(seqAstCtx_->qctx);
// 一般序列语句中会放很多语句,也就是分号分隔的语句,这里我们只有一条语句就是 lookup_sentence
// LOOKUP 语句创建出来 LookupValidator,终于看到曙光了
for (auto* sentence : sentences) {
auto validator = makeValidator(sentence, qctx_);
NG_RETURN_IF_ERROR(validator->validate());
seqAstCtx_->validators.emplace_back(std::move(validator));
}
return Status::OK();
}
4. 读一读 LookupValidator
终于,看到点 LOOKUP 的影子了,LookupValidator 驾到:
// src/graph/validator/LookupValidator.cpp
// LOOKUP 的 validateImpl 比较简洁,直接对 From Where Yield e分别进行校验
Status LookupValidator::validateImpl() {
lookupCtx_ = getContext<LookupContext>();
// 详情请见下面的子函数分析
NG_RETURN_IF_ERROR(validateFrom());
// 此次不涉及,我们先不做分析
NG_RETURN_IF_ERROR(validateWhere());
// 详情请见下面的子函数分析
NG_RETURN_IF_ERROR(validateYield());
return Status::OK();
}
// Validate specified schema(tag or edge) from sentence
Status LookupValidator::validateFrom() {
auto spaceId = lookupCtx_->space.id;
auto from = sentence()->from();
// 根据 spaceId 和指定的 label_name 查询 Schema
auto ret = qctx_->schemaMng()->getSchemaIDByName(spaceId, from);
NG_RETURN_IF_ERROR(ret);
// 指定的是不是边类型
lookupCtx_->isEdge = ret.value().first;
// 指定的 schemaId
lookupCtx_->schemaId = ret.value().second;
schemaIds_.emplace_back(ret.value().second);
return Status::OK();
}
// Validate yield clause.
Status LookupValidator::validateYield() {
auto yieldClause = sentence()->yieldClause();
if (yieldClause == nullptr) {
return Status::SemanticError("Missing yield clause.");
}
// 这个是判断是否指定了 distinct 关键字,用于后续生成 dedup
lookupCtx_->dedup = yieldClause->isDistinct();
lookupCtx_->yieldExpr = qctx_->objPool()->makeAndAdd<YieldColumns>();
// 如果是边类型,返回的列中,有 src、dst、rank、type
if (lookupCtx_->isEdge) {
idxReturnCols_.emplace_back(nebula::kSrc);
idxReturnCols_.emplace_back(nebula::kDst);
idxReturnCols_.emplace_back(nebula::kRank);
idxReturnCols_.emplace_back(nebula::kType);
// 校验边类型
NG_RETURN_IF_ERROR(validateYieldEdge());
} else { // 如果点类型、返回的列中有 vid
idxReturnCols_.emplace_back(nebula::kVid);
// 校验点类型,这次我们介绍点类型的校验
NG_RETURN_IF_ERROR(validateYieldTag());
}
if (exprProps_.hasInputVarProperty()) {
return Status::SemanticError("unsupport input/variable property expression in yield.");
}
if (exprProps_.hasSrcDstTagProperty()) {
return Status::SemanticError("unsupport src/dst property expression in yield.");
}
extractExprProps();
return Status::OK();
}
// Validate yield clause when lookup on tag.
// Disable invalid expressions, check schema name, rewrites expression to fit semantic,
// check type and collect properties.
Status LookupValidator::validateYieldTag() {
auto yield = sentence()->yieldClause();
auto yieldExpr = lookupCtx_->yieldExpr;
// yield 子句里面的每一个逗号分隔的就是一个 col、我们的示例语句是 id(vertex)
// src/parser/parser.yy:1559 对 col 进行了定义
for (auto col : yield->columns()) {
// 如果发现表达式有 Edge 类型的,则直接把语义错误
if (ExpressionUtils::hasAny(col->expr(), {Expression::Kind::kEdge})) {
return Status::SemanticError("illegal yield clauses `%s'", col->toString().c_str());
}
// 如果是 label 属性,则进行表达式名字的校验,比如 yield player.name 这种语句
if (col->expr()->kind() == Expression::Kind::kLabelAttribute) {
const auto& schemaName = static_cast<LabelAttributeExpression*>(col->expr())->left()->name();
if (schemaName != sentence()->from()) {
return Status::SemanticError("Schema name error: %s", schemaName.c_str());
}
}
// 这块应该是重写表达式,有 label 属性转换为 Tag 的 prop,这里不是特别清楚,后续精读一下
col->setExpr(ExpressionUtils::rewriteLabelAttr2TagProp(col->expr()));
NG_RETURN_IF_ERROR(ValidateUtil::invalidLabelIdentifiers(col->expr()));
auto colExpr = col->expr();
// 推测表达式的类型
auto typeStatus = deduceExprType(colExpr);
NG_RETURN_IF_ERROR(typeStatus);
// 组织输出,由名字和类型组成的集合对象
outputs_.emplace_back(col->name(), typeStatus.value());
yieldExpr->addColumn(col->clone().release());
NG_RETURN_IF_ERROR(deduceProps(colExpr, exprProps_, &schemaIds_));
}
return Status::OK();
}
到这里,LOOKUP 的 validator 工作差不多完事了。
5. 语句如何变成执行计划
介绍得不够细致,我还在熟悉过程,接下来就是介绍将 sentence 转换成执行计划的过程了。
执行计划生成
执行计划的生成,像是一些简单的语句,就通过子类的 validator
的 toPlan
直接生成了,比如:SHOW HOSTS
这个语句,就是直接在 ShowHostsValidator::toPlan
方法中直接生成执行计划。但是,对于一些比较复杂的语句来说,子类 validator
都没有实现 toPlan
方法,也就是需要借助父类的 toPlan
方法来生成执行计划。比如,本文在读的 LOOKUP 语句也属于复杂语句:
// src/graph/validator/Validator.cpp
// 这里就是复杂语句生成执行计划的入口
// 需要配合 AstContext 来生成,对于 LOOKUP 语句来说,就是 LookupContext
// Call planner to get final execution plan.
Status Validator::toPlan() {
// **去子类 LookupValidator 的 getAstContext() 方法看下,是不是返回的是 LookupContext**
auto* astCtx = getAstContext();
if (astCtx != nullptr) {
astCtx->space = space_;
}
// 利用抽象语法树上下文,借用 Planner 的 toPlan 生成具体的执行计划
auto subPlanStatus = Planner::toPlan(astCtx);
NG_RETURN_IF_ERROR(subPlanStatus);
auto subPlan = std::move(subPlanStatus).value();
// 将返回的 subPlan 对 root 和 tail 进行填充
root_ = subPlan.root;
tail_ = subPlan.tail;
VLOG(1) << "root: " << root_->kind() << " tail: " << tail_->kind();
return Status::OK();
}
6. 进入 toPlan() 一探究竟
从章节 5. 上面获知,需要进入 Planner 的 toPlan 方法一探究竟
// src/graph/planner/Planner.cpp
StatusOr<SubPlan> Planner::toPlan(AstContext* astCtx) {
if (astCtx == nullptr) {
return Status::Error("AstContext nullptr.");
}
const auto* sentence = astCtx->sentence;
DCHECK(sentence != nullptr);
// 从抽象语法树的执行上下文取到我们的 sentence
// 下面的 plannerMap 是我们在 src/graph/planner/PlannersRegister.cpp 注册好的,一些复杂的语句都在这里注册好了
auto planners = plannersMap().find(sentence->kind());
if (planners == plannersMap().end()) {
return Status::Error("No planners for sentence: %s", sentence->toString().c_str());
}
for (auto& planner : planners->second) { // second 是语句具体对应的 planner 的实例化对象: MatchAndInstantiate
if (planner.match(astCtx)) { // match 方法是具体 planner 的 match 方法,对应到 LookupPlaner,就是 match
// 这里的 instantiate 是 LookupPlanner 的 make 方法
// 这里的 transform 是拿着 lookupcontext 生成执行计划的函数
return planner.instantiate()->transform(astCtx);
}
}
return Status::Error("No planner matches sentence: %s", sentence->toString().c_str());
}
7. 计划中的 transform()
我们分析到这里,使用了 Planner 的 toPlan 方法生成一些复杂语句的执行计划。接下来,就是进去 LookupPlanner 的 transform 方法从 LookupContext 转换到执行计划的过程了。我们直接定位到 LookupPlanner 的 transform 方法上:
// src/graph/planner/ngql/LookupPlanner.cpp
StatusOr<SubPlan> LookupPlanner::transform(AstContext* astCtx) {
// 是不是我们上面提到的 lookupContext
auto lookupCtx = static_cast<LookupContext*>(astCtx);
auto qctx = lookupCtx->qctx;
// ON 后面的 name_label
auto from = static_cast<const LookupSentence*>(lookupCtx->sentence)->from();
SubPlan plan;
// 如果是边的话,生成的是 EdgeIndexFullScan
if (lookupCtx->isEdge) {
auto* edgeIndexFullScan = EdgeIndexFullScan::make(qctx,
nullptr,
from,
lookupCtx->space.id,
{},
lookupCtx->idxReturnCols,
lookupCtx->schemaId,
lookupCtx->isEmptyResultSet);
edgeIndexFullScan->setYieldColumns(lookupCtx->yieldExpr);
plan.tail = edgeIndexFullScan;
plan.root = edgeIndexFullScan;
} else { // 如果是点的话,生成的是 TagIndexFullScan
auto* tagIndexFullScan = TagIndexFullScan::make(qctx,
nullptr,
from,
lookupCtx->space.id,
{},
lookupCtx->idxReturnCols,
lookupCtx->schemaId,
lookupCtx->isEmptyResultSet);
tagIndexFullScan->setYieldColumns(lookupCtx->yieldExpr);
plan.tail = tagIndexFullScan;
plan.root = tagIndexFullScan;
}
plan.tail->setColNames(lookupCtx->idxColNames);
// 我们没有指定 where 语句,所以不会有 filter 算子
if (lookupCtx->filter) {
plan.root = Filter::make(qctx, plan.root, lookupCtx->filter);
}
// 会有 Project 算子生成:对输出列做一个映射
plan.root = Project::make(qctx, plan.root, lookupCtx->yieldExpr);
// 这里是 distinct 关键字,我们没有指定,默认是没有这个算子的
if (lookupCtx->dedup) {
plan.root = Dedup::make(qctx, plan.root);
}
return plan;
}
8. explain 验证生成的执行计划
通过我们上述的介绍,执行计划已经生成了。那么,我们是不是可以通过 explain
或者 profile
来验证我们分析生成的执行计划就是 Project→TagIndexFullScan→Start
呢。下面是我们通过 explain
生成的执行计划,它验证了我们分析的源码和生成的执行计划是一致的。 大喜