nGQL 简明教程 vol.02 执行计划详解与调优

pczhy
发布于 2023-2-28 15:16
浏览
0收藏

第一期​ 🔗中,我们简单了解了一些 nGQL 的常用语句。本文旨在帮助 NebulaGraph 新手快速了解查询语句调优,读懂查询计划。

一直以来,NebulaGraph 社区里最热门之一的话题都是“我如何表达这样的查询最好?“、”我这个查询还有优化空间吗?“这一类的话题。今天,我就来试着介绍下如何理解查询语句的执行与优化过程,帮助大家更好地脚踩在地上去写自己的查询语句。

同时,这篇文章也是 nGQL 简明教程系列的第二期。在你通过本文了解如何面向性能写查询语句后,就能更好地理解在第三期进行的图建模内容。

一个查询的一生

先从一个查询语句从进入 NebulaGraph 到返回查询结果的全过程讲起。在开始之前,建议阅读下文末的延伸阅读的架构和索引内容。

简单来说,一个查询语句被从 GraphClient 发送给 graphd 之后,经历了:

  1. 在 Parser 中被解析成抽象语法树 AST;
  2. 在 Validator、Planner 中被写成执行计划图,图中的每一个顶点 PlanNode 对应着一种算子;
  3. 通过 Optimizer 中的优化规则 RBO 改写执行计划图;
  4. 优化过的计划图被执行引擎从图的叶子节点开始执行直到根部。

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

举一个例子,这是一个实现「查询年龄大于 34 岁的三跳好友」的查询语句:

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;

这条查询语句经过了解析、验证、优化之后,最终的执行计划是,Start -> Loop -> Start -> GetNeighbors -> Project -> Dedup -> Loop -> GetNeighbors -> Project -> GetVertices -> Project -> LeftJoin -> Filter -> Project -> Dedup -> Sort,或者如下图所示:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

了解这个优化过程和最终执行计划是了解什么是调优查询、面向性能设计图建模的关键。

回到上面的执行计划,这个计划图由添加了 PROFILE FORMAT="DOT" 的执行结果中的 digraph 部分,借助 Graphviz 渲染而得。值得注意的是,FORMAT="DOT" 省略之后的输出结果是表格形式的,且有更多信息会展示出来,后面我们会解读。

(root@nebula) [basketballplayer]> PROFILE FORMAT="DOT" 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;
+-----------------+-----+
| name            | age |
+-----------------+-----+
| "Tony Parker"   | 36  |
| "Manu Ginobili" | 41  |
| "Tim Duncan"    | 42  |
+-----------------+-----+
Got 3 rows (time spent 8885/19871 us)

Execution Plan (optimize time 1391 us)

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  plan
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  digraph exec_plan {
      rankdir=BT;
      "Sort_14"[label="{Sort_14|outputVar: \{\"colNames\":\[\"name\",\"age\"\],\"type\":\"DATASET\",\"name\":\"__Sort_14\"\}|inputVar: __Dedup_13}", shape=Mrecord];
      "Dedup_13"->"Sort_14";
      "Dedup_13"[label="{Dedup_13|outputVar: \{\"colNames\":\[\"name\",\"age\"\],\"type\":\"DATASET\",\"name\":\"__Dedup_13\"\}|inputVar: __Project_12}", shape=Mrecord];
      "Project_12"->"Dedup_13";
      "Project_12"[label="{Project_12|outputVar: \{\"colNames\":\[\"name\",\"age\"\],\"type\":\"DATASET\",\"name\":\"__Project_12\"\}|inputVar: __Filter_11}", shape=Mrecord];
      "Filter_11"->"Project_12";
      "Filter_11"[label="{Filter_11|outputVar: \{\"colNames\":\[\"JOIN_DST_VID\",\"__COL_0\",\"__COL_1\",\"DST_VID\"\],\"type\":\"DATASET\",\"name\":\"__Filter_11\"\}|inputVar: __LeftJoin_10}", shape=Mrecord];
      "LeftJoin_10"->"Filter_11";
      "LeftJoin_10"[label="{LeftJoin_10|outputVar: \{\"colNames\":\[\"JOIN_DST_VID\",\"__COL_0\",\"__COL_1\",\"DST_VID\"\],\"type\":\"DATASET\",\"name\":\"__LeftJoin_10\"\}|inputVar: \{\"rightVar\":\{\"__Project_9\":0\},\"leftVar\":\{\"__Project_7\":0\}\}}", shape=Mrecord];
      "Project_9"->"LeftJoin_10";
      "Project_9"[label="{Project_9|outputVar: \{\"colNames\":\[\"__COL_0\",\"__COL_1\",\"DST_VID\"\],\"type\":\"DATASET\",\"name\":\"__Project_9\"\}|inputVar: __GetVertices_8}", shape=Mrecord];
      "GetVertices_8"->"Project_9";
      "GetVertices_8"[label="{GetVertices_8|outputVar: \{\"colNames\":\[\],\"type\":\"DATASET\",\"name\":\"__GetVertices_8\"\}|inputVar: __Project_7}", shape=Mrecord];
      "Project_7"->"GetVertices_8";
      "Project_7"[label="{Project_7|outputVar: \{\"colNames\":\[\"JOIN_DST_VID\"\],\"type\":\"DATASET\",\"name\":\"__Project_7\"\}|inputVar: __GetNeighbors_6}", shape=Mrecord];
      "GetNeighbors_6"->"Project_7";
      "GetNeighbors_6"[label="{GetNeighbors_6|outputVar: \{\"colNames\":\[\],\"type\":\"DATASET\",\"name\":\"__GetNeighbors_6\"\}|inputVar: __VAR_0}", shape=Mrecord];
      "Loop_5"->"GetNeighbors_6";
      "Loop_5"[shape=diamond];
      "Dedup_4"->"Loop_5";
      "Loop_5"->"Start_1"[label="Do", style=dashed];
      "Start_0"->"Loop_5";
      "Dedup_4"[label="{Dedup_4|outputVar: \{\"colNames\":\[\],\"type\":\"DATASET\",\"name\":\"__VAR_0\"\}|inputVar: __Project_3}", shape=Mrecord];
      "Project_3"->"Dedup_4";
      "Project_3"[label="{Project_3|outputVar: \{\"colNames\":\[\"_vid\"\],\"type\":\"DATASET\",\"name\":\"__Project_3\"\}|inputVar: __GetNeighbors_2}", shape=Mrecord];
      "GetNeighbors_2"->"Project_3";
      "GetNeighbors_2"[label="{GetNeighbors_2|outputVar: \{\"colNames\":\[\],\"type\":\"DATASET\",\"name\":\"__GetNeighbors_2\"\}|inputVar: __VAR_0}", shape=Mrecord];
      "Start_1"->"GetNeighbors_2";
      "Start_1"[label="{Start_1|outputVar: \{\"colNames\":\[\],\"type\":\"DATASET\",\"name\":\"__Start_1\"\}|inputVar: }", shape=Mrecord];
      "Start_0"[label="{Start_0|outputVar: \{\"colNames\":\[\],\"type\":\"DATASET\",\"name\":\"__Start_0\"\}|inputVar: }", shape=Mrecord];
  }
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 查询计划

为了理解一个查询在整个生命周期中,如何反映到执行层面,以及它们的性能代价是多少,我们从认识它的执行计划开始入手。

以前面的图遍历(图拓展)GO 语句为例,它经历了如下节点:

  1. GetNeighbors 是执行计划中最重要的节点,GetNeighbors 算子会在运行期访问存储服务,拿到通过起点和指定边类型一步拓展后终点的 ID;
  2. 多步拓展通过 Loop 节点实现,Start 到 Loop 之间是 Loop 的子计划,当满足条件时 Loop 子计划会被循环执行,最后一步拓展节点在 Loop 外实现;
  3. Project 节点用来获取当前拓展的终点 ID;
  4. Dedup 节点对终点 ID 去重后作为下一步拓展的起点;
  5. GetVertices 节点负责取终点 tag 的属性;
  6. Filter 做条件过滤;
  7. LeftJoin 的作用是合并 GetNeighbors 和 GetVertices 的结果;
  8. Sort 做排序;

而这些节点就是不同的算子。


认识算子

在 NebulaGraph 博客的代码解读文章中已经有很多算子被提及、解释过,这里列举其中部分常见的算子:

注:这里没有提及 GET SUBGRAPH / FIND PATH 中的算子。

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

从这些算子的含义中,你已经知道图数据库的查询具体落到查询引擎 graphd 内部的最小所需操作了。而性能调优的关键就在于如何规划、优化查询语句是如何被拆解为算子的执行计划


认识优化规则

对于任何给定的查询,执行计划并不是唯一确定的。相反,在最简单、直接的计划基础之上,优化器 Optimizer 会进行很多性能上的优化。

NebulaGraph 目前的优化器是完全的基于规则的优化 RBO,这些预设的规则的代码都在 https://github.com/vesoft-inc/nebula/tree/master/src/graph/optimizer 的 rules 里,它们都是针对执行计划模式的修改规则。对 RBO 有兴趣的小伙伴可以读下文末的延伸阅读的源码介绍。

值得一提的是,2022 年开始在大部分优化规则的代码中增加了很多好理解的 ASCII Art 图形注释,结合优化规则的命名本身的自解释性,我们可以快速理解优化规则的匹配和转换逻辑。

首先,这些规则的转换 transform 代码都在 .cpp 文件里,ASCII Art 图形注释在同名对应的 .h 里!

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

 GetEdgesTransformRule

我们看下这个规则名字的意思是转换 GetEdges 的规则。看起来不够明确,来看看 GetEdgesTransformRule.h

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

结合 .cpp 中 GetEdgesTransformRule::match 和 GetEdgesTransformRule::transform 的代码,我们可以确定这个优化规则是将 MATCH ()-[e]->() RETURN e LIMIT 3 原本按照 ScanVertices 去扫描点变为 ScanEdges 扫描边。

因为,这个规则的背景是在没有点、边索引的 LIMIT 情况下,无起点 VID / 属性条件的查询是可以通过扫点、边数据下推 LIMIT 的。这里 MATCH ()-[e]->() RETURN e LIMIT 3 扫描边的查询因为只需要返回边 RETURN e,所以直接扫描边 ScanEdges 是更高效的。

PushLimitDownScanEdgesRule

我们看看这个规则,在 PushLimitDownScanEdgesRule.h 里有注释

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

这个规则很简单,当 ScanEdges 算子的下游是 Limit 算子的时候,把 Limit 的过滤条件嵌入到 ScanEdges 之中。这一步的意义是什么呢?这里涉及到一个优化规则里常见的概念,计算下推。

计算下推

在计算存储分离的数据库系统中,涉及到读取数据的算子需要从存储层远程捞取数据再做进一步处理。而这个 RPC 的数据传输常常成为性能的瓶颈。然而,如果下一步计算要做的事情是对数据的按条件剪枝,例如 FilterLimitTopN 等等。这时候,如果存储层捞数据时考虑了条件剪枝情况,则可以大大地减少数据传输量。

上面提到的 PushLimitDownScanEdgesRule.h 的优化规则就是一个典型的 Limit 下推(Push Down)的规则。

 PushLimitDownProjectRule

类似的,我们看看这个规则。PushLimitDownProjectRule.h 里有这样一段注释,这个规则只是把 Project 算子之后的 Limit 算子的顺序调换了一下。我们可以想象在这个变换之后,Limit 就和 ScanEdges 等其他可以下推 Limit 的算子相邻了,像 PushLimitDownScanEdgesRule 的规则变换也可以做了。

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

PushFilterDownGetNbrsRule

再介绍一个 Filter 下推的例子,从规则注释里的转换图示可以读出,当 GetNeighbors 之后再双条件交集 Filter 的时候,取一个条件下推到 GetNeighbors,再进行另一个条件的 Filter。这个规则既减少了 GetNeighbors 数据的传输量、Filter 输入的运算量,又少了一次集合运算。

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

下一步

  • 感兴趣的同学可以试着进一步看看所有的规则。
  • 可以试着在多个 graphd 集群上,更改其中一个 graphd 的配置,关闭 enable_optimizer,把同一个 Query 分别在开启了优化和关闭优化的 graphd 中执行,比较执行计划的区别。
  • 如果你发现更优的规则,欢迎到论坛、GitHub 提交优化建议、PR!

简单的介绍就到这里,接下来我们从一些实例出发来进一步实操执行计划调优吧。


nGQL 执行计划调优解读实例

希望这些具体问题能够给大家带来启发。

 观察基于索引与数据的扫描

我在索引详解中解释过 NebulaGraph 的索引和传统数据库中索引的区别。简单来说,因为以类似于邻接表的方式存储数据,NebulaGraph 要额外创建索引来解决全扫描或者根据属性条件扫描点、边这些类似于表结构数据库中的场景。

另外,在无过滤条件 LIMIT 采样扫描的场景下,NebulaGraph 从 v3.0 开始允许无索引的查询情形(因为点边数据扫描支持了 Limit 下推,不再像之前那么昂贵)。在没有索引可以被选择的情况下,Planner 也会选择直接扫点或者边的数据,这个差别可以从 explainprofile 中看出来。

这里用 basketballplayer 数据集中自带的索引来举例,这个数据集只在 player 这个 tag 上有两个索引:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

无索引查询

先看一个没有索引的场景查询:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区


这个例子里我们关注从 Storage 里取数据的算子,以及算子取得数据的行数:

  • ScanVertices,扫描点(其中 limit: 1
  • rows: 3,扫描得到 3 行数据

ScanVertices 比较好理解,在没有索引的情况下,只能去扫描点数据(非索引)了,limit: 1 表示它也被下推了,不会取全量数据。

其次,rows: 3,为什么不是 1 呢?这是因为数据分别在 3 个 storaged 之中,同时被扫描,所以在每一个 storaged 中都 limit 1 才能保证最终数据能满足需求,而这里刚好三个 storaged 中都有数据分布,最终得到的数据量就是 3 了。

有索引查询,LIMIT 无下推

下面,我们来查 player 上的数据,前面的 SHOW TAG INDEXES 里看到,player 上是存在索引的:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

可以看出:

  • 捞数据的算子是 IndexScanLimit 并没有下推 limit: 9223372036854775807
  • 取得的数据行数是 rows: 52

由于当前 NebulaGraph 社区版并没有进行 MATCH 带索引查询的 Limit 下推,所以在社区版中这种无条件查询比有索引的情况下开销更加昂贵。

btw,MATCH 起点查询策略的代码在 src/graph/planner/PlannersRegister.cpp 中,如果你感兴趣的话可以去读读看。下面代码仅供参考:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

有索引查询,LIMIT 下推

值得庆幸的是,现在 LOOKUP 与 MATCH 等价的查询中的索引查询算子是支持 Limit 下推的优化规则的:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区


我们可以看到:

  • 数据捞取算子是 TagIndexFullScan,有 limit: 1 下推
  • 因为索引扫描是所有分区 fan-out 扫描(区别于 ScanVertices 只是所有 storage 扫描,LOOKUP 的算子扫得更细),这里 limit: 1 下推扫得的数据量是 rows: 10

综上,我们对“无 VID 的起点查询”的不同情况有了更具体的认识。

 观察 filter 下推

我们先来看看这个查询,它沿着指定起点向外做图扩展,根据边的属性条件过滤,返回目的点 ID:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

它的执行计划是:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

可以看到,这个计划里,GetNeighboors 算子取得了所有的 player100 的出边(以及边属性),然后再通过单独 filter 算子。

我们关注:

  • filter: 是空的
  • properties(EDGE).degree>1 作为 filter 算子的条件。

这里,我们有没有可能把 filter 下推呢?答案是可以的,只要在 WHERE 条件中提供边类型的信息,优化条件就可以把它下推到 GetNeighbors 算子:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

这时候 WHERE follow.degree > 1 把 follow 这个信息传递给了优化规则,让它可以:

  • 在 GetNeighbors 算子里提供 filter: (follow.degree>1) 的信息
  • 去掉 filter 算子

这样,优化之后的查询不仅少了一个 graphd 里的算子执行过程,GetNeighbors 捞取的数据也因为传递了 filter 参数涉及的数据可能比全量的数据少了。

通过这个例子,我们可以推断出这样的一个规律:显式表达尽可能多的已知信息往往是有帮助的。在做规则优化、数据查询时,模糊查询常常是更昂贵的,确定的信息会带来更多的剪枝、过滤、短路,并且这些信息在查询中越明确、越早提供性能也会越好。

下面是另一个例子:

 优化原则:减少模糊,增加确定,越早越好

这里我给出两对比较查询:

  1. GO FROM "player100" OVER follow YIELD dst(edge) 对比 GO FROM "player100" OVER follow YIELD follow._dst
  2. MATCH (:player)-[e]-(:player) RETURN e 与 MATCH (:player)-[e:follow]-(:player) RETURN e

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

在 1. 中,两条语句的差异不是前面例子的算子不同,而是算子传递的数据量有不同:dst(edge) 语句中没有指定 edge 的类型,对整个 edge 做了函数 dst() 运算,这使得 GetNeighbors 算子的 edgeProps 输出是所有的属性——_dst_rank_src_typedegree;反观 follow._dst 的表达使得 edgeProps 输出只有边的 _dst,这印证了在已知返回 follow 边属性的情况下,提前(而不是等到语句解析到 dst(edge) 之后)且直接用 follow._dst 表达带来了数据传输上的优化。

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

类似的,在 2. 中,两个查询的 Traverse 算子的返回属性也是不同的,两者在存储层做数据扫描的时候代价是不同的(同样注意 edgeProps 的区别)。在当前的 basketballplayer 图中,我们清楚地知道 player 与 player 之间,只有一种边,那就是 follow,在这个信息 e:follow 被写入查询时,我们因此获得更高效的查询计划。

小练习:等价的多种表达的代价

这是一个用户提的一个问题:如何表达两点之间是否存在直连边?

对于这么简单的表达,大家应该直接能想到应该有好多方法表达:

  • GO
  • FIND PATH
  • MATCH
  • FETCH PROP

这里,利用 FETCH PROP 表达双向边,似乎是两个查询,我们先省略掉,对于剩下的三个表达,哪一种更适合呢?我们先写出来看看

  • GO FROM "player100" over * BIDIRECT WHERE id($$) == "player101" YIELD edge AS e
  • FIND ALL PATH FROM "player100" TO "player101" OVER * BIDIRECT UPTO 1 STEPS YIELD path AS p
  • MATCH (n)--(m) WHERE id(n) == "player100" AND id(m) == "player101" RETURN count(*)

GO / MATCH 与 FIND PATH

它们都能获得我们想要的信息,感觉上,MATCH 和 GO 会比 FIND PATH 的查询更快一些,因为它们都是从单一起点拓展,而 FIND PATH 是从两端同时查询,在一跳的情况下,这其实是冗余的动作。验证一下的话,我们可以看到

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

FIND PATH 中果然有两个 GetNeighbors 算子。

GO 与 MATCH

一般来说,如果表达能力等价,GO 都是更推荐的查询。因为在 MATCH 查询中,做边拓展的算子 Traverse 默认不只取边上的信息,还会同时取两端的信息。我们可以通过 explain 和 profile 来对比两个查询:

  • GO FROM "player100" over * BIDIRECT WHERE id($$) == "player101" YIELD edge AS e
  • MATCH (n)--(m) WHERE id(n) == "player100" AND id(m) == "player101" RETURN count(*)

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

反观 GO 的查询,GetNeighbors 算子只需要沿着起点进行边边扫描就好。然而,这个查询的计划里居然还有 GetVertices 算子取获取点的属性,这看起来完全是不必要的。

更优的 GO 表达

下面,我们看看还有没有优化的余地,这里,我们用到了 $$ 引用属性,它是一个很好的语义表达,方便我们取点信息。但因为 $$ 可以用来获取点的属性(比如 $$.player.age),所以优化规则中对 $$ 的函数 id() 会引入了 GetVertices 算子。因此,在没有优化计划让表达语句做出更优执行计划之前,我们可以按照”减少模糊,增加确定“的思路,把查询语句潜在涉及取点属性的表达绕过。

在架构设计文章告诉我们,一条边存储了起点、终点的 ID 信息,当我们只关心边是否存在,不关心对端顶点属性时,这个查询只扫边就可以了。

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

所以,要绕开 id($$) 这个表达的一个方法就是用 edge。在双向探索的情况下,id($$) 等价的表达是 dst(edge) == "player101" OR src(edge) == "player101"。所以,这句查询可以改写成:


我们看看优化之后的查询计划:GetNeighbors->Filter->Project,显然,它是最优的了!

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

 索引的命中

在本篇结束之前,我还想再给一个索引查询的例子。首先,假设环境里只存在 player() 和 player.name 上的索引,没有 player.age 上的索引。


nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

在 NebulaGraph 中,只有不具备 ID 条件的起点才涉及索引,而这类起点查询其实是一个典型的非图库查询。相反,点的拓展和 NebulaGraph 索引是无关的。

下面的例子只涉及起点查询,类似于 SQL 中 SELECT * FROM player WHERE ... 的表达。这三个查询分别是:

  1. MATCH (n:player) WHERE n.player.age > 50 RETURN n
  2. MATCH (n:player) WHERE n.player.name > "T" RETURN n
  3. MATCH (n:team) WHERE n.team.name > "T" RETURN n

之中只有 1. 和 2. 是允许的查询,3. 因为查询涉及数据全扫描而被 NebulaGraph 禁止,因为不存在 team 之中的索引,这样的全扫描是很昂贵的:

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

虽然都能被执行,但 1. 与 2. 的情况又有不同:1. 中的过滤条件 n.player.age > 50 涉及未被索引的字段,这个过滤是无法被下推到 IndexScan 算子的,这意味着所有的 player 都会被扫描到 graphd 中,然后再进一步进行 Filter 算子的过滤计算。

下图是索引文中介绍的点索引在 RocksDB 中的数据结构。

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区

nGQL 简明教程 vol.02 执行计划详解与调优-鸿蒙开发者社区


通过两个查询的 profile 分析,我们可以很清楚地看到:

  • IndexScan 在 1. 查询中没有 columnHints 信息,扫描的行数为 rows: 52(这是所有的 player 顶点数量)
  • IndexScan 在 2. 查询中有 columnHints 信息,扫描的行数只有 rows: 6

所以,我们在真的需要这种从属性反查图探索起点的时候,要根据实际查询需求去斟酌索引的创建。

小结

在理解了 NebulaGraph 的基本架构设计、存储格式、查询的简单调用流程和常见的优化规则之后,结合 PROFILE / EXPLAIN,我们可以一点点去设计更适合不同场景的图建模与图查询。

优化原则:减少模糊,增加确定,越早越好


本文转载自公众号:Nebula Graph Community

分类
标签
已于2023-2-28 15:16:38修改
收藏
回复
举报
回复
    相关推荐