知识库

通过了解基数来调优 Cypher 查询

基数问题是导致 Cypher 查询缓慢或结果错误的最常见原因。因此,理解基数并利用这一认知来管理基数问题,是 Cypher 查询调优以及保证查询正确性的关键环节。

关于以下示例的说明

我们将使用内置的 Movies 图数据库作为示例(在 Neo4j 浏览器中使用 :play movies 来创建数据集)。

请注意,查询规划器(Query Planner)可以优化某些操作。它可能会改变某些操作的执行顺序,更改扩展的顺序,或者改变起始节点等。即使计划可能不尽如人意,或者在某些情况下绕过了问题,在调优查询时关注基数仍然是最佳实践。

Cypher 操作按行执行,并生成结果行

要理解 Cypher 中的基数,首先需要理解 Cypher 执行的两个重要方面:

  1. Cypher 操作针对操作输入流中的每一条记录/行*执行

  2. Cypher 操作会生成记录/行*的结果流

* 虽然“记录”(record)在技术上更准确(Neo4j 不使用表,因此实际上没有行),但“行”(row)通常更易于理解,并且是查询计划输出中使用的术语。我们从此处开始将使用“行”。

这两个方面是同一原则的体现:行流既是 Cypher 操作的输入,也是其输出。

您可以在 PROFILE 查询计划 中看到行流如何在 Cypher 操作之间流动,通过匹配扩展(match expansions)和展开(unwinds)增加,并通过过滤、聚合和限制(limits)减少。

流中的行数越多,下一个操作所执行的工作量就越大,从而增加数据库命中数(db hits)和查询执行时间。

举个例子,执行以下针对电影图的简单查询,查找《黑客帝国》(The Matrix)中的所有演员:

MATCH (movie:Movie {title:'The Matrix'})<-[:ACTED_IN]-(actor)

如果我们决定此时返回数据(RETURN movie.title as title, actor.name as name),我们将得到以下结果(顺序不限):

标题 名称 (name)

"The Matrix"

"Keanu Reeves"

"The Matrix"

"Hugo Weaving"

"The Matrix"

"Laurence Fishburne"

"The Matrix"

"Carrie-Anne Moss"

"The Matrix"

"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 执行额外的匹配呢?

在这种情况下,我们可以在匹配后 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
...

模式推导(Pattern comprehension)可以提供帮助

模式推导是一种使用扩展结果填充列表的方法。如果您期望的结果包含相关节点的集合,这是一种保持基数较低且使查询更简洁的好方法。

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)

来自可变长度路径的唯一(DISTINCT)节点

可变长度模式匹配在某些情况下成本很高,因为 Cypher 试图找到匹配给定模式的所有可能路径。

当您只对模式末端的唯一节点感兴趣时,这种行为是浪费的,因为您不需要从不同路径到达的相同节点的多个副本,并且继续处理这些结果很可能会导致基数问题。

您可以告知查询您只对 DISTINCT 节点感兴趣,并且通过满足一些小条件,规划器将优化扩展操作(这在查询计划中显示为 VarLengthExpand(Pruning))。

您需要在扩展上设置上限,并在匹配后使用 WITH DISTINCTRETURN DISTINCT 子句以利用此优化。

PROFILE
MATCH (:Person{name:'Keanu Reeves'})-[:ACTED_IN*..5]-(movie)
WITH DISTINCT movie
...
关于可变长度模式匹配局限性的说明

虽然剪枝后的可变扩展可能比常规扩展操作更快,但它仍然必须找到所有可能的路径,即使我们只保留唯一结果。

即使在连接适中的图(如电影图)上,如果没有对关系类型和方向的严格约束,如果所有可能路径的排列组合激增,可变长度路径匹配在高(或无)上限时仍可能变得极其昂贵,甚至导致查询挂起。

如果在这些情况下您需要唯一的连接节点,您可能需要转向 APOC 过程(APOC Procedures)来进行路径扩展,这些过程以更有效、更适合此用例的方式遍历图。

当 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 个用于导演。第一个匹配被找到,第一个关系被创建,然后由于达到了限制,后续的演员匹配(以及关系创建)没有被处理。

如果我们在此前添加了 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 时会被丢弃的工作。

© . This site is unofficial and not affiliated with Neo4j, Inc.