运行时概念
在 Cypher® 中,有三种运行时:插槽式、流水线式和并行式。通常,默认运行时(企业版中的流水线式运行时)提供最佳查询性能。但是,每个运行时都各有优缺点,在某些情况下,决定使用哪种运行时是最大限度提高查询效率的重要一步。
本指南将逐步介绍三种可用的 Cypher 运行时背后的概念。对于不熟悉阅读 Cypher 查询生成的执行计划的读者,建议首先阅读有关 理解执行计划 的部分。
示例图
以下图用于本页面的查询
该图包含两种类型的节点:Stop
和 Station
。火车服务上的每个 Stop
都 CALLS_AT
一个 Station
,并且具有 arrives
和 departs
属性,它们给出火车在 Station
的时间。沿着 Stop
的 NEXT
关系将给出服务的下一个 Stop
。
要重新创建该图,请对空的 Neo4j 数据库运行以下查询
CREATE (pmr:Station {name: 'Peckham Rye'}),
(dmk:Station {name: 'Denmark Hill'}),
(clp:Station {name: 'Clapham High Street'}),
(wwr:Station {name: 'Wandsworth Road'}),
(clj:Station {name: 'Clapham Junction'}),
(s1:Stop {arrives: time('17:19'), departs: time('17:20')}),
(s2:Stop {arrives: time('17:12'), departs: time('17:13')}),
(s3:Stop {arrives: time('17:10'), departs: time('17:11')}),
(s4:Stop {arrives: time('17:06'), departs: time('17:07')}),
(s5:Stop {arrives: time('16:58'), departs: time('17:01')}),
(s6:Stop {arrives: time('17:17'), departs: time('17:20')}),
(s7:Stop {arrives: time('17:08'), departs: time('17:10')}),
(clj)<-[:CALLS_AT]-(s1), (wwr)<-[:CALLS_AT]-(s2),
(clp)<-[:CALLS_AT]-(s3), (dmk)<-[:CALLS_AT]-(s4),
(pmr)<-[:CALLS_AT]-(s5), (clj)<-[:CALLS_AT]-(s6),
(dmk)<-[:CALLS_AT]-(s7),
(s5)-[:NEXT {distance: 1.2}]->(s4),(s4)-[:NEXT {distance: 0.34}]->(s3),
(s3)-[:NEXT {distance: 0.76}]->(s2), (s2)-[:NEXT {distance: 0.3}]->(s1),
(s7)-[:NEXT {distance: 1.4}]->(s6)
插槽式运行时
插槽式运行时是 Neo4j 社区版的默认运行时。Neo4j 企业版用户必须在其查询前面加上 CYPHER runtime = slotted
,才能使查询使用插槽式运行时运行。例如
EXPLAIN
CYPHER runtime = slotted
MATCH (:Station { name: 'Denmark Hill' })<-[:CALLS_AT]-(d:Stop)
((:Stop)-[:NEXT]->(:Stop))+
(a:Stop)-[:CALLS_AT]->(:Station { name: 'Clapham Junction' })
RETURN count(*)
此查询将生成以下执行计划
Planner COST Runtime SLOTTED Runtime version 5.25 +-------------------+----+------------------------------------------------------------------------+----------------+ | Operator | Id | Details | Estimated Rows | +-------------------+----+------------------------------------------------------------------------+----------------+ | +ProduceResults | 0 | `count(*)` | 1 | | | +----+------------------------------------------------------------------------+----------------+ | +EagerAggregation | 1 | count(*) AS `count(*)` | 1 | | | +----+------------------------------------------------------------------------+----------------+ | +Filter | 2 | not anon_1 = anon_5 AND anon_0.name = $autostring_0 AND anon_0:Station | 0 | | | +----+------------------------------------------------------------------------+----------------+ | +Expand(All) | 3 | (d)-[anon_1:CALLS_AT]->(anon_0) | 0 | | | +----+------------------------------------------------------------------------+----------------+ | +Filter | 4 | d:Stop | 0 | | | +----+------------------------------------------------------------------------+----------------+ | +Repeat(Trail) | 5 | (a) (...){1, *} (d) | 0 | | |\ +----+------------------------------------------------------------------------+----------------+ | | +Filter | 6 | isRepeatTrailUnique(anon_7) AND anon_2:Stop | 6 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Expand(All) | 7 | (anon_4)<-[anon_7:NEXT]-(anon_2) | 6 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Filter | 8 | anon_4:Stop | 11 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Argument | 9 | anon_4 | 13 | | | +----+------------------------------------------------------------------------+----------------+ | +Filter | 10 | a:Stop | 0 | | | +----+------------------------------------------------------------------------+----------------+ | +Expand(All) | 11 | (anon_6)<-[anon_5:CALLS_AT]-(a) | 0 | | | +----+------------------------------------------------------------------------+----------------+ | +Filter | 12 | anon_6.name = $autostring_1 | 1 | | | +----+------------------------------------------------------------------------+----------------+ | +NodeByLabelScan | 13 | anon_6:Station | 10 | +-------------------+----+------------------------------------------------------------------------+----------------+
插槽式运行时生成的物理计划是逻辑计划的一对一映射,其中每个逻辑运算符都映射到相应的物理运算符,并且运算符是逐行处理的。使用插槽式运行时时,查询中的每个变量都会获得一个专门的“插槽”,运行时使用它来访问映射到给定变量的数据,因此得名“插槽式”。
插槽式运行时使用大多数数据库中传统的执行模型,称为迭代器模型或“Volcano”模型。这是一个基于拉取的进程,其中树中的每个运算符都使用虚拟调用函数从其子运算符“拉取”数据行。通过这种方式,数据从执行计划的底部拉取到顶部,生成类似火山喷发的流量。
注意事项
插槽式运行时是 Neo4j 中引入的第一个高性能运行时,它取代了原来的(更慢的)解释型运行时,后者现已弃用。
插槽式运行时是一个解释型运行时,这意味着它逐个运算符地解释规划器发送的逻辑计划。一般来说,这是一种方便灵活的方法,能够处理所有运算符和查询。插槽式运行时在概念上类似于解释型编程语言,因为它具有较短的规划阶段,因为它不需要在执行之前生成查询的所有代码(与编译型运行时不同,将在 下面 详细讨论)。[1]
一般来说,Neo4j 企业版用户不需要使用插槽式运行时。但是,在某些情况下,插槽式运行时的快速规划阶段可能有用。例如,如果您使用的是生成短查询且未缓存的应用程序(即从不或很少重复),那么插槽式运行时可能更可取,因为其规划时间更快。
但是,插槽式运行时也有一些局限性。每个运算符之间连续调用虚拟函数会消耗 CPU 周期,导致查询执行速度变慢。此外,迭代器模型会导致数据局部性差,这会导致查询执行速度变慢。这是因为从不同运算符中拉取单个行的过程难以有效利用 CPU 缓存。
流水线式运行时
流水线式运行时是 Neo4j 企业版的默认运行时。这意味着,除非 Neo4j 企业版用户指定其他运行时,否则查询将使用流水线式运行时运行。
要指定查询应使用流水线式运行时,请在查询前面加上 CYPHER runtime = pipelined
。例如
EXPLAIN
CYPHER runtime = pipelined
MATCH (:Station { name: 'Denmark Hill' })<-[:CALLS_AT]-(d:Stop)
((:Stop)-[:NEXT]->(:Stop))+
(a:Stop)-[:CALLS_AT]->(:Station { name: 'Clapham Junction' })
RETURN count(*)
生成的执行计划与插槽式运行时生成的执行计划存在显著差异
Planner COST Runtime PIPELINED Runtime version 5.25 Batch size 128 +-------------------+----+------------------------------------------------------------------------+----------------+---------------------+ | Operator | Id | Details | Estimated Rows | Pipeline | +-------------------+----+------------------------------------------------------------------------+----------------+---------------------+ | +ProduceResults | 0 | `count(*)` | 1 | In Pipeline 3 | | | +----+------------------------------------------------------------------------+----------------+---------------------+ | +EagerAggregation | 1 | count(*) AS `count(*)` | 1 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Filter | 2 | not anon_1 = anon_5 AND anon_0.name = $autostring_0 AND anon_0:Station | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Expand(All) | 3 | (d)-[anon_1:CALLS_AT]->(anon_0) | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Filter | 4 | d:Stop | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +NullifyMetadata | 14 | | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Repeat(Trail) | 5 | (a) (...){1, *} (d) | 0 | Fused in Pipeline 2 | | |\ +----+------------------------------------------------------------------------+----------------+---------------------+ | | +Filter | 6 | isRepeatTrailUnique(anon_7) AND anon_2:Stop | 6 | | | | | +----+------------------------------------------------------------------------+----------------+ | | | +Expand(All) | 7 | (anon_4)<-[anon_7:NEXT]-(anon_2) | 6 | | | | | +----+------------------------------------------------------------------------+----------------+ | | | +Filter | 8 | anon_4:Stop | 11 | | | | | +----+------------------------------------------------------------------------+----------------+ | | | +Argument | 9 | anon_4 | 13 | Fused in Pipeline 1 | | | +----+------------------------------------------------------------------------+----------------+---------------------+ | +Filter | 10 | a:Stop | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Expand(All) | 11 | (anon_6)<-[anon_5:CALLS_AT]-(a) | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Filter | 12 | anon_6.name = $autostring_1 | 1 | | | | +----+------------------------------------------------------------------------+----------------+ | | +NodeByLabelScan | 13 | anon_6:Station | 10 | Fused in Pipeline 0 | +-------------------+----+------------------------------------------------------------------------+----------------+---------------------+
计划的最右边一列显示它被划分为四个不同的 **管道**。为了理解什么是管道,首先需要理解使用管道运行时的查询与在插槽运行时运行的查询不同,它们不是逐行执行的。相反,管道运行时允许物理操作符消费和生成大约 100 到 1000 行的 **批次**(称为 **小块**),这些批次被写入包含数据和任务的 **缓冲区**,用于一个管道。反过来,管道可以被定义为一系列操作符,这些操作符已被融合在一起,以便它们可以在运行时由同一任务一起执行。
因此,当使用管道运行时,逻辑操作符不会映射到相应的物理操作符。相反,逻辑操作符树被转换为包含管道和缓冲区的执行图。
在这个执行图中,查询执行从 `pipeline 0` 开始,它最终会生成一个要写入 `pipeline 1` 缓冲区的小块。一旦 `pipeline 1` 有数据可以处理,它就可以开始执行,并依次为下一个管道写入数据以供处理,依此类推。通过这种方式,数据被推送到执行图中。
注意事项
管道运行时是一个基于推送的执行模型,其中数据从叶子操作符推送到其父操作符。与基于拉取的模型(插槽运行时使用)不同,当使用基于推送的执行模型时,数据可以保存在局部变量中,这有几个好处:它可以启用 CPU 寄存器的直接使用,提高 CPU 缓存的使用率,并避免基于拉取模型中使用的昂贵的虚拟函数调用。
管道运行时非常适合事务性用例,在这种情况下,大量查询在系统中并行运行。这涵盖了大多数使用场景,因此它是默认的 Neo4j 运行时。
管道运行时是一个组合模型,它可以使用解释型或编译型运行时。但是,因为它主要使用后者,所以它被认为是一个编译型运行时。与解释型运行时不同,编译型运行时有一个代码生成阶段,然后是执行阶段,这通常会导致更长的查询规划时间,但更短的执行时间。
如上所述 所述,在极少数情况下,Neo4j Enterprise Edition 的用户可能会从不为其查询使用管道运行时中受益。但是,对于大多数查询,管道运行时是一个更有效的运行时,能够处理所有操作符和查询。
并行运行时
插槽运行时和管道运行时都在分配给一个 CPU 内核的单个线程中执行查询。当使用这两个运行时时,仍然可以通过在单独的 CPU 线程中同时运行多个查询来实现并行性(广泛定义为当两个或多个操作集可以在单个数据库环境中同时处理时)。另一种选择是在同一个查询中使用 CALL {…} IN CONCURRENT TRANSACTIONS
同时运行多个事务。
但是,在某些情况下,主要是执行图形分析时,单个查询使用多个内核来提高其性能是有益的。这可以通过使用并行运行时来实现,并行运行时是多线程的,并允许查询潜在地利用运行 Neo4j 的服务器上的所有可用内核。
要指定查询应该使用并行运行时,请在前面加上 `CYPHER runtime = parallel`。例如
EXPLAIN
CYPHER runtime = parallel
MATCH (:Station { name: 'Denmark Hill' })<-[:CALLS_AT]-(d:Stop)
((:Stop)-[:NEXT]->(:Stop))+
(a:Stop)-[:CALLS_AT]->(:Station { name: 'Clapham Junction' })
RETURN count(*)
这是生成的执行计划
Planner COST Runtime PARALLEL Runtime version 5.25 Batch size 128 +-----------------------------+----+------------------------------------------------------------------------+----------------+---------------------+ | Operator | Id | Details | Estimated Rows | Pipeline | +-----------------------------+----+------------------------------------------------------------------------+----------------+---------------------+ | +ProduceResults | 0 | `count(*)` | 1 | In Pipeline 6 | | | +----+------------------------------------------------------------------------+----------------+---------------------+ | +EagerAggregation | 1 | count(*) AS `count(*)` | 1 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Filter | 2 | NOT anon_1 = anon_5 AND anon_0.name = $autostring_0 AND anon_0:Station | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Expand(All) | 3 | (d)-[anon_1:CALLS_AT]->(anon_0) | 0 | Fused in Pipeline 5 | | | +----+------------------------------------------------------------------------+----------------+---------------------+ | +Filter | 4 | d:Stop | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +NullifyMetadata | 14 | | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Repeat(Trail) | 5 | (a) (...){1, *} (d) | 0 | Fused in Pipeline 4 | | |\ +----+------------------------------------------------------------------------+----------------+---------------------+ | | +Filter | 6 | isRepeatTrailUnique(anon_8) AND anon_7:Stop | 6 | | | | | +----+------------------------------------------------------------------------+----------------+ | | | +Expand(All) | 7 | (anon_9)<-[anon_8:NEXT]-(anon_7) | 6 | Fused in Pipeline 3 | | | | +----+------------------------------------------------------------------------+----------------+---------------------+ | | +Filter | 8 | anon_9:Stop | 11 | | | | | +----+------------------------------------------------------------------------+----------------+ | | | +Argument | 9 | anon_9 | 13 | Fused in Pipeline 2 | | | +----+------------------------------------------------------------------------+----------------+---------------------+ | +Filter | 10 | a:Stop | 0 | | | | +----+------------------------------------------------------------------------+----------------+ | | +Expand(All) | 11 | (anon_6)<-[anon_5:CALLS_AT]-(a) | 0 | Fused in Pipeline 1 | | | +----+------------------------------------------------------------------------+----------------+---------------------+ | +Filter | 12 | anon_6.name = $autostring_1 | 1 | | | | +----+------------------------------------------------------------------------+----------------+ | | +PartitionedNodeByLabelScan | 13 | anon_6:Station | 10 | Fused in Pipeline 0 | +-----------------------------+----+------------------------------------------------------------------------+----------------+---------------------+
并行运行时生成的物理计划与管道运行时生成的物理计划之间的一个关键区别是,通常,当使用并行运行时时,会生成更多管道(在这种情况下,是七个而不是管道运行时运行同一个查询产生的四个)。这是因为,当在并行运行时中执行查询时,拥有更多可以并行运行的任务效率更高,而当在管道运行时中运行单线程执行时,将多个管道融合在一起效率更高。
另一个重要区别是,并行运行时使用分区操作符(在本例中为 PartitionedNodeByLabelScan
)。这些操作符首先对检索到的数据进行分段,然后并行对每个段进行操作。
并行运行时与管道运行时共享相同的架构,这意味着它会将逻辑计划转换为与上面描述的相同类型的执行图。但是,当使用并行运行时时,每个管道任务可以在单独的线程中执行。与管道运行时另一个相似之处是,在并行运行时上运行的查询将从生成第一个管道开始,该管道最终将在后续管道的输入缓冲区中生成一个“小块”。但是,当使用管道运行时时,一次只能有一个管道前进,而并行运行时允许管道并发地生成“小块”。因此,随着每个任务的完成,将有越来越多的输入“小块”可用于任务,这意味着可以利用越来越多的工作程序来执行查询。
为了进一步解释并行运行时的工作原理,需要定义一些新的术语。
-
**工作程序**:执行工作单元以评估传入查询的线程。
-
**任务**:一个工作单元。一个任务在一个输入“小块”上执行一个管道,并生成一个输出“小块”。如果任何条件阻止任务完成,它可以作为“续行”重新安排以在以后恢复。
-
**续行**:未完成执行的任务,必须再次安排。
-
**调度程序**:负责决定接下来要处理哪个工作单元。调度是分散的,每个工作程序都有自己的调度程序实例。
考虑以下基于同一个示例查询的执行图
执行图显示执行从 `pipeline 0` 开始,`pipeline 0` 包含操作符 `PartitionedNodeByLabelScan`,可以在处理不同数据“小块”的所有可用线程上同时执行。一旦 `pipeline 0` 生成了至少一个完整的数据“小块”,任何线程都可以开始执行 `pipeline 1`,而其他线程可以继续执行 `pipeline 0`。更准确地说,一旦有一个管道的数据可用,调度程序就可以继续执行下一个管道,同时并发地执行前面的管道。在本例中,`pipeline 5` 以聚合结束(由 EagerAggregation
操作符执行),这意味着最后一个管道(`6`)必须在所有前面的管道为所有前面的数据“小块”完全完成之前才能开始。
注意事项
在大多数情况下,如果有多个 CPU 内核可用,预期长时间运行的查询将在并行运行时上运行得明显更快。虽然无法定义查询在并行运行时上运行将受益的的确切持续时间(因为这取决于数据模型、查询结构、系统负载和可用内核的数量),但可以假设一般规则是,任何查询如果运行时间超过大约 500 毫秒,那么它就是一个很好的候选者。
这意味着并行运行时适合 **分析性**、**全局图形** 查询。这些查询通常不锚定到特定的起始节点,因此会处理图形的很大一部分,以便从中获得有价值的见解。
但是,如果满足以下任一条件,从锚定特定节点开始的查询可能会从在并行运行时上运行中受益。
-
锚定的起始节点是密集连接的节点或超级节点。
-
查询从锚定的节点扩展到图形的很大一部分。
因此,没有关于何时应该在并行运行时上运行查询的固定规则,但这些指南提供了有关用户很有可能从尝试使用它中受益的场景的一些有用信息。
与旨在作为大多数查询最有效规划方法的管道运行时不同,并行运行时的用例更具体,在某些情况下,无法或不适合使用它。最值得注意的是,并行运行时 **只支持读取查询**。它也不支持不被认为是线程安全的(即不能从多个线程安全运行)的过程函数。
此外,并非所有查询在使用并行运行时都会运行得更快。例如,一个 **局部图形** 查询,它从锚定一个节点开始,然后继续只匹配图形的一小部分,可能不会在并行运行时上运行得更快(它甚至可能在使用并行运行时执行时运行得更慢,因为它的调度和在多个线程上执行查询所需的额外簿记)。作为一个一般的经验法则,对于完成时间不到半秒的查询,并行运行时可能没有好处。
对于包含子句的查询,并行运行时也可能比管道运行时性能更差,在这些子句中使用 ORDER BY 对已编制索引的属性进行排序。这是因为并行运行时无法利用属性索引进行排序,因此必须在返回任何结果之前重新对选定属性上的聚合结果进行排序。
最后,虽然单个查询在运行并行运行时时可能运行得更快,但由于运行许多并发查询,数据库的整体吞吐量可能会下降。
因此,并行运行时不适合具有高吞吐量工作负载的事务处理查询。但是,它非常适合分析用例,在这种用例中,数据库运行相对较少但要求苛刻的读取查询。
概述
一般来说,如果满足以下条件,应该考虑使用并行运行时。
-
全局图形读取查询被构建为针对图形的很大一部分。
-
查询的速度很重要。
-
服务器有许多 CPU 和足够的内存。
-
数据库上的并发工作负载很低。
有关并行运行时的更多信息,包括有关查询、过程、函数、配置设置以及在 Aura 上使用并行运行时的更多详细信息,请参见 并行运行时:参考 页面。
总结
下表总结了 Cypher 中可用的三种不同运行时之间最重要的区别。
插槽 | 管道 | 并行 | |
---|---|---|---|
执行模型 |
拉取 |
推送 |
推送 |
物理操作符消耗 |
逐行 |
批处理 |
批处理 |
处理器线程 |
单线程 |
单线程 |
多线程 |
运行时类型 |
解释型 |
编译型或解释型 |
编译型或解释型 |
支持的查询类型 |
读写 |
读写 |
仅读 |