运行时概念
在 Cypher® 中,有三种类型的运行时:Slotted(插槽式)、Pipelined(流水线式)和 Parallel(并行式)。通常,默认运行时(企业版中的 Pipelined 运行时)能提供最佳的查询性能。然而,每种运行时都有各自的优缺点,在某些场景下,决定使用哪种运行时是最大化查询效率的重要一步。
本指南将逐步介绍这三种 Cypher 运行时背后的概念。对于不熟悉 Cypher 查询执行计划的读者,建议先阅读理解执行计划一节。
示例图
本页面的查询将使用以下图数据
该图包含两种类型的节点:Stop(站点)和 Station(车站)。每个火车服务的 Stop 都会 CALLS_AT 一个 Station,并具有 arrives(到达)和 departs(出发)属性,表示火车在车站的时间。通过 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)
Slotted 运行时
Slotted 运行时是 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 2026.03
+-------------------+----+------------------------------------------------------------------------+----------------+
| 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 |
+-------------------+----+------------------------------------------------------------------------+----------------+
Slotted 运行时生成的物理计划与逻辑计划是一一对应的,每个逻辑运算符映射到一个对应的物理运算符,并且运算符逐行处理数据。使用 Slotted 运行时,查询中的每个变量都有一个专用的“插槽(slot)”,运行时使用它来访问映射到该变量的数据,因此得名“Slotted”。
Slotted 运行时使用了大多数数据库采用的传统执行模型,即迭代器模型或“火山(Volcano)”模型。这是一个基于拉取(pull-based)的过程,树中的每个运算符通过虚函数调用从其子运算符“拉取”数据行。通过这种方式,数据从执行计划的底部被拉取到顶部,从而产生类似火山喷发的数据流。
注意事项
Slotted 运行时是 Neo4j 引入的第一个高性能运行时,取代了现已退役的原始(且较慢的)解释型运行时。
Slotted 运行时是一种解释型运行时,意味着它会逐个运算符地解释规划器发送的逻辑计划。通常,这是一种方便且灵活的方法,能够处理所有运算符和查询。Slotted 运行时在概念上类似于解释型编程语言,其规划阶段较短,因为它不需要在执行前生成查询的所有代码(与编译型运行时不同——详见下文)。[1]
通常,Neo4j 企业版用户不需要使用 Slotted 运行时。但在某些场景下,Slotted 运行时快速的规划阶段可能很有用。例如,如果您使用的应用程序会生成大量不被缓存的短查询(即从不或极少重复执行),那么由于其更快的规划时间,Slotted 运行时可能更合适。
然而,Slotted 运行时存在局限性。每个运算符之间持续调用虚函数会消耗 CPU 周期,导致查询执行变慢。此外,迭代器模型可能导致数据局部性差,从而降低查询速度。这是因为从不同运算符逐行拉取数据的过程使得 CPU 缓存难以得到高效利用。
Pipelined 运行时企业版
Pipelined 运行时是 Neo4j 企业版和所有 Aura 层的默认运行时。这意味着除非 Neo4j Aura 或企业版用户指定不同的运行时,否则查询将使用 Pipelined 运行时执行。
EXPLAIN
MATCH (:Station { name: 'Denmark Hill' })<-[:CALLS_AT]-(d:Stop)
((:Stop)-[:NEXT]->(:Stop))+
(a:Stop)-[:CALLS_AT]->(:Station { name: 'Clapham Junction' })
RETURN count(*)
生成的执行计划与 Slotted 运行时产生的计划有显著差异
Planner COST
Runtime PIPELINED
Runtime version 2026.03
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 |
+-------------------+----+------------------------------------------------------------------------+----------------+---------------------+
计划的最右侧一列显示它已被划分为四个不同的流水线(pipelines)。要理解什么是流水线,首先需要明白使用 Pipelined 运行时的查询与 Slotted 运行时不同,它们不是一次执行一行。相反,Pipelined 运行时允许物理运算符消耗和产生批次(batches)(每批约 100 到 1000 行,称为 morsels),这些批次被写入包含流水线数据和任务的缓冲区(buffers)中。流水线可以定义为一系列已融合在一起的运算符,以便运行时可以在同一个任务中一起执行它们。
因此,使用 Pipelined 运行时,逻辑运算符不会一一映射到对应的物理运算符。相反,逻辑运算符树被转换为包含流水线和缓冲区的执行图。
在此执行图中,查询从 pipeline 0 开始,最终产生一个 morsel 并写入 pipeline 1 的缓冲区。一旦 pipeline 1 有了要处理的数据,它就可以开始执行,进而为下一个流水线写入数据,依此类推。通过这种方式,数据沿着执行图被推送。
注意事项
Pipelined 运行时是一种基于推送(push-based)的执行模型,数据从叶运算符推送到其父运算符。与拉取模型(Slotted 使用的模型)不同,基于推送的执行模型可以将数据保留在局部变量中,这有几个好处:它能直接利用 CPU 寄存器,改善 CPU 缓存的使用,并避免了拉取模型中代价高昂的虚函数调用。
Pipelined 运行时非常适合事务性用例,即系统中并行运行大量查询的场景。这涵盖了大多数使用场景,因此它是 Neo4j 的默认运行时。
Pipelined 运行时是一个混合模型,可以使用解释型或编译型运行时。然而,因为它主要使用后者,所以它被视为一种编译型运行时。与解释型运行时不同,编译型运行时有一个代码生成阶段,随后是执行阶段,这通常导致较长的查询规划时间,但执行时间较短。
如上文所述,极少数情况下 Neo4j 企业版用户可能不希望使用 Pipelined 运行时。但对于大多数查询而言,Pipelined 运行时是一种更高效的、能够处理所有运算符和查询的运行时。
Parallel 运行时企业版
Slotted 和 Pipelined 运行时都在分配给单个 CPU 核心的单个线程中执行查询。在使用这两个运行时的情况下,通过在不同的 CPU 线程中并发运行多个查询(这是 OLTP 在线事务处理用例中的典型场景),仍然可以实现并行(广义上定义为在单个数据库环境中同时处理两组或多组操作)。另一个选择是使用 CALL {…} IN CONCURRENT TRANSACTIONS 在同一个查询中并发运行多个事务。
然而,在某些场景下,特别是进行图分析时,单个查询使用多个核心来提高性能是有利的。这可以通过使用 Parallel 运行时来实现,它是多线程的,并允许查询利用运行 Neo4j 的服务器上的所有可用核心。
要指定查询使用 Parallel 运行时,请在查询前加上 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 2026.03
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 |
+-----------------------------+----+------------------------------------------------------------------------+----------------+---------------------+
Parallel 运行时生成的物理计划与 Pipelined 运行时的一个关键区别在于,通常情况下,使用 Parallel 运行时会产生更多的流水线(在此例中为 7 个,而 Pipelined 运行时仅产生 4 个)。这是因为在 Parallel 运行时中执行查询时,拥有更多可以并行运行的任务更有效;而在 Pipelined 运行时进行单线程执行时,将多个流水线融合在一起则更高效。
另一个重要的区别是 Parallel 运行时使用分区运算符(在此例中为 PartitionedNodeByLabelScan)。这些运算符首先对检索到的数据进行分段,然后并行地对每个段进行操作。
Parallel 运行时与 Pipelined 运行时共享相同的架构,这意味着它会将逻辑计划转换为上述类型的执行图。然而,使用 Parallel 运行时,每个流水线任务可以在单独的线程中执行。与 Pipelined 运行时的另一个相似之处是,Parallel 运行时的查询会先生成第一个流水线,最终在后续流水线的输入缓冲区中产生 morsel。但与 Pipelined 运行时一次只能推进一个流水线不同,Parallel 运行时允许流水线并发产生 morsels。因此,随着每个任务的完成,会有越来越多的输入 morsels 可供后续任务使用,这意味着可以使用更多的 worker 来执行查询。
为了进一步解释 Parallel 运行时的工作原理,需要定义一些新术语:
-
Worker(工作线程):执行工作单元以评估传入查询的线程。
-
Task(任务):工作单元。任务在输入 morsel 上执行一条流水线并产生一个输出 morsel。如果任何条件阻止了任务完成,它可以被重新调度为 Continuation(延续)以便稍后恢复。
-
Continuation(延续):未完成执行并需要再次调度的任务。
-
Scheduler(调度器):负责决定接下来处理哪个工作单元。调度是分散的,每个 worker 都有自己的调度器实例。
考虑下方的执行图,基于同样的示例查询:
执行图显示执行始于 pipeline 0,它包含 PartitionedNodeByLabelScan 运算符,可以在所有可用线程上同时对不同的数据 morsels 进行执行。一旦 pipeline 0 产生至少一个完整的 morsel 数据,任何线程都可以开始执行 pipeline 1,而其他线程可以继续执行 pipeline 0。更具体地说,一旦有来自某个流水线的数据,调度器就可以在并发执行早期流水线的同时推进到下一个流水线。在此例中,pipeline 5 以聚合结束(由 EagerAggregation 运算符执行),这意味着最后一个流水线(6)在所有前面的 morsel 数据处理完之前无法开始。
注意事项
在大多数拥有多个 CPU 核心的情况下,长时间运行的查询在 Parallel 运行时上通常会快得多。虽然无法定义查询在多长时才适合使用 Parallel 运行时(因为这取决于数据模型、查询结构、系统负载和可用核心数),但通常可以认为任何耗时超过约 500 毫秒的查询都是不错的候选者。
这意味着 Parallel 运行时适用于分析型、图全局(graph-global)查询。这些查询通常不锚定在特定的起始节点,而是处理图的很大一部分以获取有价值的见解。
然而,对于从锚定特定节点开始的查询,如果满足以下任一条件,也可能受益于使用 Parallel 运行时:
-
锚定的起始节点是一个连接密集的节点或超级节点。
-
查询从锚定节点扩展到图的很大一部分。
因此,对于何时应使用 Parallel 运行时没有固定规则,但这些指导原则提供了一些有用的信息,说明了用户很可能会受益的场景。
与被设计为大多数查询最有效规划方法的 Pipelined 运行时不同,Parallel 运行时的使用场景更具体,有些情况下它是不可能或无益的。最显著的是,Parallel 运行时仅支持读查询。它也不支持被认为非线程安全的存储过程函数(即在多个线程中运行不安全)。
此外,并非所有查询使用 Parallel 运行时都会更快。例如,一个从锚定节点开始且仅匹配图的一小部分的图局部(graph-local)查询,在 Parallel 运行时上可能不会运行得更快(由于调度和在多线程上执行查询所需的额外记录,甚至可能变慢)。经验法则通常是,对于完成时间少于半秒的查询,Parallel 运行时可能没有好处。
对于包含使用 ORDER BY 对索引属性进行排序的子句的查询,Parallel 运行时可能比 Pipelined 运行时性能更差。这是因为 Parallel 运行时无法利用属性索引进行排序,因此必须在返回结果前对聚合结果重新排序。
最后,虽然单个查询在运行 Parallel 运行时可能会更快,但由于运行许多并发查询,数据库的整体吞吐量可能会下降。
因此,Parallel 运行时不适用于高吞吐量的工作负载中的事务处理查询。然而,它非常适合数据库仅运行少量但高需求的读查询的分析场景。
概述
通常,如果满足以下条件,应考虑使用 Parallel 运行时:
-
图全局(Graph-global)读查询旨在覆盖图的很大一部分。
-
查询速度至关重要。
-
服务器拥有多个 CPU 和足够的内存。
-
数据库上的并发负载较低。
有关 Parallel 运行时的更多信息(包括关于查询、过程、函数、配置设置的详细信息,以及在 Aura 上使用 Parallel 运行时),请参阅并行运行时:参考页面。
总结
下表总结了 Cypher 中三种不同运行时之间最重要的区别:
| Slotted | Pipelined | Parallel | |
|---|---|---|---|
执行模型 |
拉取 (Pull) |
推送 (Push) |
推送 (Push) |
物理运算符消耗 |
逐行处理 |
批处理 |
批处理 |
处理器线程 |
单线程 |
单线程 |
多线程 |
运行时类型 |
解释型 |
编译型或解释型 |
编译型或解释型 |
支持的查询类型 |
读和写 |
读和写 |
只读 |