通过理解基数来优化 Cypher 查询
基数问题是导致 Cypher 查询缓慢或不正确的最常见原因。因此,理解基数并利用这种理解来管理基数问题是 Cypher 查询优化以及总体查询正确性的关键组成部分。
我们将使用内置的电影图谱作为示例(在 Neo4j 浏览器中使用 :play movies
来创建数据集)。
请注意,查询规划器可以优化某些操作。它可能会改变某些操作的顺序、扩展的顺序,或我们用作起始节点的节点等等。即使计划不如预期,或在某些情况下为您规避了问题,在优化查询时最好仍然注意基数。
Cypher 操作按行执行,并生成结果行
要理解 Cypher 中的基数,需要首先理解 Cypher 执行的两个重要方面
-
Cypher 操作按照输入流的每条记录/行* 执行
-
Cypher 操作生成结果记录/行* 的流
*
虽然“记录”在技术上更准确(Neo4j 不使用表,所以它实际上没有行),但“行”通常更常见,并且是查询计划输出中使用的术语。从现在开始我们将使用“行”。
这是同一原理的两个方面:行流既是 Cypher 操作的输入,也是其输出。
你可以在 PROFILE 查询计划中看到行流如何在 Cypher 操作之间流动,从匹配扩展和展开中增加,并通过过滤、聚合和限制而减少。
流中的行越多,下一个操作执行的工作量就越多,导致数据库命中次数和查询执行时间增加。
例如,以电影图谱上的这个简单查询为例,查找《黑客帝国》中的所有演员
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
如果我们决定在此时返回数据(RETURN movie.title as title, actor.name as name
),我们将得到以下结果(无特定顺序)
标题 | 名称 |
---|---|
“黑客帝国” |
“基努·里维斯” |
“黑客帝国” |
“雨果·维文” |
“黑客帝国” |
“劳伦斯·菲什伯恩” |
“黑客帝国” |
“凯瑞-安·莫斯” |
“黑客帝国” |
“埃米尔·艾弗瑞姆” |
结果流中有 5 行。
如果我们不返回数据,而是决定根据这些匹配结果做更多的事情,这些行将成为查询中下一个操作的输入。
该操作将针对这些行中的每一行执行。
Cypher 中的基数是什么?为什么它很重要?
通常,基数是指操作之间流动的行数。
请记住,操作是按行执行的,根据您的查询,对于您正在操作的变量,可能会有多行出现相同的值。
例如,如果您正在从一个节点变量向外执行扩展,如果同一个特定节点出现在多行上,您可能会多次冗余地执行相同的操作。
管理基数就是确保当您对值执行操作时,如果可能,您首先缩小基数,以避免那些冗余操作。
为什么这很重要?
-
因为我们希望查询快速;我们希望做最少必要的工作,而不是对相同的值多次冗余地执行相同的操作。
-
因为我们希望查询正确;我们不想看到不必要的重复结果行,或最终创建重复的图元素。
基数问题可能导致冗余和浪费的操作
请注意,在我们上述的矩阵查询结果中,相同的值出现在多行中,因为它们存在于多个匹配路径中。
在上述查询结果中,电影《黑客帝国》是所有结果的相同起始节点,因此,同一节点存在于流的所有行中。每个不同的演员只出现在流的一行中。
如果我们从 actor
(匹配中演员的变量)执行匹配,该匹配将仅针对每个不同的演员执行一次。
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
MATCH (actor)-[:ACTED_IN]->(otherMovie)
...
然而,如果我们从 movie
(匹配中电影的变量)执行匹配,该匹配将对同一个《黑客帝国》节点冗余地执行 5 次。
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
MATCH (movie)<-[:DIRECTED]-(director)
...
基数问题可能导致数据重复
请记住,操作是按行执行的。这包括 CREATE 和 MERGE 操作。
设想一下,如果我们想在演员和导演之间创建一个新的 :WORKED_ON 关系,指向他们参与制作的电影。
单独看《黑客帝国》这部电影,一种错误的方法可能看起来像这样
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
MATCH (movie)<-[:DIRECTED]-(director)
CREATE (actor)-[:WORKED_ON {role:'actor'}]->(movie)
CREATE (director)-[:WORKED_ON {role:'director'}]->(movie)
如果我们查看结果,我们会看到每个演员和《黑客帝国》之间有两个 :WORKED_ON 关系,以及每个导演和《黑客帝国》之间有 5 个 :WORKED_ON 关系。
为什么?因为上面的前两个匹配导致了 5 个演员和 2 个导演的笛卡尔积,总共 10 行。
每个不同的导演将出现在其中 5 行上(每个演员一次),每个不同的演员将出现在其中 2 行上(每个导演一次)。CREATE 操作将在这 10 行中的每一行上执行,导致冗余关系。
虽然我们可以通过使用 MERGE 而不是 CREATE 来解决这个问题(这将只创建预期数量的关系),但我们在这个过程中仍然执行了冗余操作。
我们如何管理基数?
我们主要通过查询中的聚合和操作重新排序来管理基数,有时也通过使用 LIMIT(在有意义的情况下)。
聚合
关于 聚合 的重要部分是,非聚合变量的组合会变得唯一。如果一个操作对这些唯一变量执行,那么就不应该有浪费的执行。
我们以上述查询为例,使用聚合来减少基数
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
WITH movie, collect(actor) as actors
MATCH (movie)<-[:DIRECTED]-(director)
WITH movie, actors, collect(director) as directors
...
在第二行,我们执行一个 collect() 聚合。唯一一个非聚合变量 movie
成为唯一的GROUP BY键。这里的基数降至单行,因为该行只包含《黑客帝国》节点和演员列表。
因此,下一个 MATCH 的后续扩展操作将只对《黑客帝国》节点执行一次,而不是像以前那样执行 5 次。
但是,如果我们想从 actor
执行额外的匹配呢?
在这种情况下,我们可以在匹配之后 UNWIND 我们的集合
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
WITH movie, collect(actor) as actors
MATCH (movie)<-[:DIRECTED]-(director)
WITH movie, collect(director) as directors
UNWIND actors as actor
MATCH (actor)-[:ACTED_IN]->(other)
WHERE other <> movie
...
模式推导会有所帮助
模式推导是一种用扩展结果填充列表的方法。如果您的预期结果包含连接节点的集合,它是保持基数低并使查询稍微简洁一些的好方法。
MATCH (movie:Movie {title:'The Matrix'})
WITH movie, [(movie)<-[:DIRECTED]-(director) | director] as directors
MATCH (movie)<-[:ACTED_IN]-(actor:Person)-[:ACTED_IN]->(other)
...
重新排序查询以提早聚合
Cypher 新手(特别是那些有 SQL 背景的人)经常尝试在 RETURN 语句中执行许多操作(例如限制、聚合等)。
在 Cypher 中,我们鼓励在有意义的情况下尽早执行这些操作,因为它可以保持基数低并防止浪费的操作。
这是一个延迟执行聚合的例子,尽管我们通过使用 COLLECT(DISTINCT …)
得到了正确答案
MATCH (movie:Movie)
OPTIONAL MATCH (movie)<-[:ACTED_IN]-(actor)
OPTIONAL MATCH (movie)<-[:DIRECTED]-(director)
RETURN movie, collect(distinct actor) as actors, collect(distinct director) as directors
在 Neo4j 3.3.5 中,此查询的 PROFILE 显示有 621 次数据库命中。
我们最终确实得到了正确答案,但我们执行的连续匹配或可选匹配越多,基数问题就越有可能呈倍数级地累积。
如果我们重新排序查询,在每个 OPTIONAL MATCH 之后执行 COLLECT(),或使用模式推导,我们就能减少不必要的工作,因为我们的扩展操作发生在每部电影上,从而保持基数尽可能低并消除冗余操作。
MATCH (movie:Movie)
WITH movie, [(movie)<-[:DIRECTED]-(director) | director] as directors, [(movie)<-[:ACTED_IN]-(actor) | actor] as actors
RETURN movie, actors, directors
在 Neo4j 3.3.5 中,此查询的 PROFILE 显示有 331 次数据库命中。
当然,对于这种小图、小结果集且操作少的查询,当我们查看时间差异时,差异可以忽略不计。
然而,随着图数据量的增长,以及图查询和结果复杂性的增加,保持基数低并避免倍数级数据库命中次数成为快速优化的查询和可能超出可接受执行时间查询之间的区别。
使用 DISTINCT 或聚合来重置基数
有时在查询过程中,我们想从某个节点扩展,对我们扩展到的节点执行操作,然后从原始节点集扩展到不同的节点集。如果我们不小心,我们可能会遇到基数问题。
考虑前面创建 :WORKED_ON 关系的尝试
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
MATCH (movie)<-[:DIRECTED]-(director)
CREATE (actor)-[:WORKED_ON {role:'actor'}]->(movie)
CREATE (director)-[:WORKED_ON {role:'director'}]->(movie)
这个查询导致了关系重复,即使我们使用 MERGE,我们仍然会做比所需更多的工作。
一个解决方案是首先处理一组节点,然后处理下一组。走向这种解决方案的第一步可能看起来像这样
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
CREATE (actor)-[:WORKED_ON {role:'actor'}]->(movie)
WITH movie
MATCH (movie)<-[:DIRECTED]-(director)
CREATE (director)-[:WORKED_ON {role:'director'}]->(movie)
尽管我们每个演员得到了 1 个 :WORKED_ON 关系,但我们仍然看到每个导演有 5 个 :WORKED_ON 关系。
为什么?因为基数不会自动重置。即使我们中间有 WITH movie
,我们仍然有 5 行,每个演员一行(即使 actor 变量不再在作用域内),每行都将《黑客帝国》作为 movie
。
为了解决这个问题,我们需要使用 DISTINCT 或聚合来重置基数,使每个不同的 movie
只有一行。
MATCH (movie:Movie)<-[:ACTED_IN]-(actor)
CREATE (actor)-[:WORKED_ON {role:'actor'}]->(movie)
WITH DISTINCT movie
MATCH (movie)<-[:DIRECTED]-(director)
CREATE (director)-[:WORKED_ON {role:'director'}]->(movie)
通过使用 WITH DISTINCT movie
,我们确保流中没有重复项,从而最小化基数。
以下查询也将正常工作,因为当我们聚合时,非聚合变量会变得唯一
MATCH (movie:Movie)<-[:ACTED_IN]-(actor)
CREATE (actor)-[:WORKED_ON {role:'actor'}]->(movie)
WITH movie, count(movie) as size
MATCH (movie)<-[:DIRECTED]-(director)
CREATE (director)-[:WORKED_ON {role:'director'}]->(movie)
变长路径中的唯一节点
变长模式匹配在某些情况下可能代价高昂,因为 Cypher 尝试查找所有匹配给定模式的可能路径。
当您只对模式末尾的唯一节点感兴趣时,这种行为是浪费的,因为您不需要从不同路径到达的同一个节点的多个副本,并且继续处理这些结果可能会导致基数问题。
您可以告诉您的查询,您只对 DISTINCT 节点感兴趣,满足一些小条件,规划器将优化扩展操作(这在查询计划中显示为 VarLengthExpand(Pruning)
)。
您需要对扩展设置上限,并在匹配后使用 WITH DISTINCT
或 RETURN DISTINCT
子句以利用此优化。
PROFILE
MATCH (:Person{name:'Keanu Reeves'})-[:ACTED_IN*..5]-(movie)
WITH DISTINCT movie
...
尽管修剪变量扩展可能比常规扩展操作更快,但它仍然必须找到所有可能的路径,即使我们只保留唯一结果。
即使在连接适度的图上,例如电影图谱,如果对关系类型和方向没有严格的约束,变长路径匹配在更高(或没有)上限的情况下,如果所有可能路径的排列组合急剧增加,其开销仍然会越来越高,达到可能导致此类查询挂起的地步。
如果您在这些情况下需要唯一的连接节点,您可能需要借助 APOC 过程,以更有效的方式遍历图的路径扩展,这更适合此用例。
当 LIMIT 出现在写操作之后时要小心
LIMIT 是惰性的,因为它一旦接收到要限制的结果数量,就会停止对之前操作的处理。
尽管这可以使其非常高效,但当您在 LIMIT 之前执行写操作时,它可能会产生意想不到的效果,即查询将只处理达到限制数量所需的结果(尽管写操作和 LIMIT 之间的 Eager 操作和聚合应该是安全的,因为在此点之前的所有处理都应该按预期执行)。
让我们使用上述示例的修改版本,但我们不使用 DISTINCT 或聚合来减少基数,而是使用 LIMIT 1
,因为这保证能让我们得到一行。
MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)
CREATE (actor)-[:WORKED_ON {role:'actor'}]->(movie)
WITH movie
LIMIT 1
MATCH (movie)<-[:DIRECTED]-(director)
CREATE (director)-[:WORKED_ON {role:'director'}]->(movie)
虽然这似乎有道理,但由于 LIMIT 是惰性的,它将只拉取足够的结果以满足限制,然后就不会再拉取更多行了。
结果是,即使《黑客帝国》中有 5 个演员和 2 个导演,这个查询将只创建 3 个关系:1 个是针对演员的,剩下的 2 个是针对导演的。第一个匹配被找到,第一个关系被创建,然后由于达到了限制,针对演员的进一步匹配(和关系创建)没有被处理。
如果我们在使用 LIMIT 之前添加 collect(actor) as actors
或类似的聚合,我们将引入一个 EagerAggregation 操作(如 EXPLAIN 查询计划中所示),这将在达到 LIMIT 之前处理该部分查询的所有输入行,从而确保创建了我们预期的 7 个关系。
这里的要点是:要注意您在查询中使用 LIMIT 的位置,特别是当 LIMIT 之前有写操作发生时。
如果您需要确保在应用 LIMIT 之前所有行都执行了写操作,请在查询计划中通过聚合引入 Eager 操作,或改用 LIMIT 的替代方案。
请注意,此处所示的 LIMIT 惰性行为正在审查中——Cypher 的未来版本可能会调整其行为。
如果可能,尽早使用 LIMIT
尽管与基数没有直接关系,但如果您在查询中使用 LIMIT,如果可能,尽早使用 LIMIT 会更有利,而不是在最后使用。
考虑这里的区别
MATCH (movie:Movie)
OPTIONAL MATCH (movie)<-[:ACTED_IN]-(actor)
WITH movie, collect(actor) as actors
OPTIONAL MATCH (movie)<-[:DIRECTED]-(director)
WITH movie, actors, collect(director) as directors
RETURN movie, actors, directors
LIMIT 1
在 Neo4j 3.3.5 中,此查询的 PROFILE 显示有 331 次数据库命中。
MATCH (movie:Movie)
WITH movie
LIMIT 1
OPTIONAL MATCH (movie)<-[:ACTED_IN]-(actor)
WITH movie, collect(actor) as actors
OPTIONAL MATCH (movie)<-[:DIRECTED]-(director)
WITH movie, actors, collect(director) as directors
RETURN movie, actors, directors
在 Neo4j 3.3.5 中,此查询的 PROFILE 显示有 11 次数据库命中。
我们避免做最终执行 LIMIT 时会被丢弃的工作。
此页面有帮助吗?