通过理解基数来优化 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
),我们将获得以下结果(无特定顺序)。
标题 | 姓名 |
---|---|
"黑客帝国" |
"基努·里维斯" |
"黑客帝国" |
"雨果·维文" |
"黑客帝国" |
"劳伦斯·菲什伯恩" |
"黑客帝国" |
"凯瑞-安·莫斯" |
"黑客帝国" |
"Emil Eifrem" |
这些是结果流中的 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
成为唯一的分组键。基数在这里下降到一行,因为该行只有黑客帝国节点和演员列表。
因此,后续的从下一个 MATCH 展开的操作将只对黑客帝国节点执行一次,而不是像以前那样执行 5 次。
但是如果我们想从 actor
执行额外的匹配怎么办?
在这种情况下,我们可以在匹配之后展开我们的集合。
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 行,每行对应一个演员(即使演员变量不再在范围内),其中 The Matrix 作为每个演员的 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 会尝试找到与给定模式匹配的所有可能的路径。
当你只对模式末端的唯一节点感兴趣时,这种行为是浪费的,因为你不需要从不同路径到达的相同节点的多个副本,并且继续使用这些结果可能会导致基数问题。
你可以告诉你的查询你只对唯一节点感兴趣,并且通过满足几个较小的条件,计划程序将优化扩展操作(这在查询计划中显示为 VarLengthExpand(Pruning)
)。
你需要一个扩展的上限,并且在匹配之后有一个 WITH DISTINCT
或 RETURN DISTINCT
子句才能利用此优化。
PROFILE
MATCH (:Person{name:'Keanu Reeves'})-[:ACTED_IN*..5]-(movie)
WITH DISTINCT movie
...
尽管修剪可变扩展可能比常规扩展操作更快,但它仍然必须找到所有可能的路径,即使我们只保留唯一的结果。
即使在连接性中等程度的图(如电影图)上,如果对关系类型和方向没有严格的限制,可变长度路径匹配在较高(或无)上限时仍然可能变得越来越昂贵,如果所有可能路径的排列组合激增,则可能会导致查询挂起。
如果你在这些情况下需要唯一连接的节点,你可能需要转向 APOC 过程,以便以更有效的方式遍历图,更适合这种用例。
在写入操作之后使用 LIMIT 时要小心
LIMIT 是惰性的,因为它一旦收到要限制的结果数量,就会停止对先前操作的处理。
虽然这可以使其非常高效,但当你在 LIMIT 之前执行写入操作时,它可能会有意外的效果,因为查询只处理达到限制数量所需的尽可能多的结果(尽管 Eager 操作和写入操作和 LIMIT 之间的聚合应该是安全的,因为在此之前的处理应该按预期执行)。
让我们使用上面示例的修改版本,但不是使用 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 是惰性的,它只会提取足够多的结果来满足限制,然后就不会提取更多行。
因此,即使 The Matrix 有 5 个演员和 2 个导演,此查询也只会创建 3 个关系:1 个是针对演员的,其余 2 个是针对导演的。找到了第一个匹配,创建了第一个关系,然后由于达到了限制,没有处理演员的进一步匹配(和关系创建)。
如果我们在使用 LIMIT 之前添加了 collect(actor) as actors
或类似的聚合,我们就会引入 EagerAggregation 操作(如 EXPLAINed 查询计划中所示),这将对所有输入行进行处理,直到达到 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 时会丢弃的操作。
此页面是否有帮助?