运行时概念
在 Cypher® 中,有三种运行时类型:槽式(slotted)、流水线式(pipelined)和并行(parallel)。一般来说,默认运行时(企业版中的流水线式运行时)提供最佳查询性能。然而,每种运行时都有其优缺点,在某些场景下,选择使用哪种运行时是最大化查询效率的重要一步。
这是对三种可用 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 2025.05 +-------------------+----+------------------------------------------------------------------------+----------------+ | 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 | +-------------------+----+------------------------------------------------------------------------+----------------+
槽式运行时生成的物理计划是逻辑计划的一对一映射,其中每个逻辑运算符映射到一个相应的物理运算符,并且运算符逐行处理。使用槽式运行时,查询中的每个变量都会获得一个专用的“槽位”,运行时使用该槽位访问映射到给定变量的数据,因此得名“槽式”。
槽式运行时采用大多数数据库传统的执行模型,称为迭代器或“火山”模型。这是一种拉取式(pull-based)处理,树中的每个运算符都通过使用虚拟调用函数从其子运算符“拉取”数据行。通过这种方式,数据从执行计划的底部向上拉取,生成类似火山喷发的数据流。
考量
槽式运行时是 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 2025.05 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
有数据可处理,它就可以开始执行,并依次为下一个流水线写入数据,依此类推。通过这种方式,数据沿着执行图被推送。
考量
流水线式运行时是一种推送式(push-based)执行模型,数据从叶子运算符推送到其父运算符。与拉取式模型(槽式运行时使用)不同,使用推送式执行模型时,数据可以保存在局部变量中,这具有多项优势;它支持直接使用 CPU 寄存器,改善 CPU 缓存的使用,并避免拉取式模型中昂贵的虚拟函数调用。
流水线式运行时非常适合事务性用例,其中系统上并行运行大量查询。这涵盖了大多数使用场景,因此,它是默认的 Neo4j 运行时。
流水线式运行时是一种组合模型,既可以使用解释型运行时,也可以使用编译型运行时。然而,由于它主要使用后者,因此被视为编译型运行时。与解释型运行时不同,编译型运行时有一个代码生成阶段,然后是执行阶段,这通常会导致更长的查询规划时间,但更短的执行时间。
如上所述,在极少数情况下,Neo4j 企业版的用户可能受益于不为其查询使用流水线式运行时。然而,对于大多数查询,流水线式运行时是一种更高效的运行时,能够处理所有运算符和查询。
并行运行时
槽式和流水线式运行时都在分配给单个 CPU 核心的单线程中执行查询。在使用这两种运行时时,通过在单独的 CPU 线程中并发运行多个查询,仍然可以实现并行性(广义上定义为在单个数据库环境中可以同时处理两个或更多操作集)(这是 OLTP(联机事务处理)用例中的典型场景)。另一种替代方案是使用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 2025.05 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
)。这些运算符首先对检索到的数据进行分段,然后并行处理每个段。
并行运行时与流水线式运行时共享相同的架构,这意味着它将逻辑计划转换为如上所述的相同类型的执行图。然而,当使用并行运行时时,每个流水线任务可以在单独的线程中执行。与流水线式运行时的另一个相似之处在于,在并行运行时运行的查询将首先生成第一个流水线,该流水线最终将在后续流水线的输入缓冲区中生成一个碎块。但是,在使用流水线式运行时一次只能推进一个流水线的情况下,并行运行时允许流水线同时生成碎块。因此,随着每个任务的完成,越来越多的输入碎块将可用于任务,这意味着可以利用越来越多的工作器来执行查询。
为了进一步解释并行运行时的工作原理,需要定义一组新术语:
-
工作器:一个执行工作单元以评估传入查询的线程。
-
任务:一个工作单元。一个任务在一个输入碎块上执行一个流水线并产生一个输出碎块。如果任何条件阻止任务完成,它可以被重新调度为“延续”(Continuation)以在以后恢复。
-
延续:一个未完成执行且必须再次调度运行的任务。
-
调度器:负责决定接下来要处理哪个工作单元。调度是去中心化的,每个工作器都有自己的调度器实例。
考虑以下基于相同示例查询的执行图:
执行图显示执行从 pipeline 0
开始,它由 PartitionedNodeByLabelScan
运算符组成,并且可以在所有可用线程上同时执行,处理不同的数据碎块。一旦 pipeline 0
产生了至少一个完整的数据碎块,任何线程都可以开始执行 pipeline 1
,而其他线程可以继续执行 pipeline 0
。更具体地说,一旦从一个流水线获得数据,调度器可以继续执行下一个流水线,同时并发执行之前的流水线。在此示例中,pipeline 5
以聚合结束(由EagerAggregation
运算符执行),这意味着最后一个流水线 (`6`) 在所有先行流水线完全处理完所有先行数据碎块之前无法开始。
考量
在大多数情况下,如果存在多个 CPU 核心,长时间运行的查询有望在并行运行时上显著加快。虽然无法精确定义查询从并行运行时中受益的具体持续时间(因为它取决于数据模型、查询结构、系统负载和可用核心数量),但可以作为一般规则假定,任何耗时超过大约 500 毫秒的查询都是一个很好的候选。
这意味着并行运行时适用于**分析性**、**图全局**查询。这些查询通常不锚定到特定的起始节点,因此处理图的大部分以从中获取有价值的见解。
然而,以锚定特定节点开始的查询可能受益于在并行运行时中运行,如果以下任一条件成立:
-
锚定的起始节点是密集连接的节点或超级节点。
-
查询从锚定节点扩展到图的大部分。
因此,没有固定规则来确定何时应使用并行运行时运行查询,但这些指南提供了一些有用的信息,说明了用户很可能受益于尝试使用它的场景。
与流水线式运行时(被设计为大多数查询最有效的规划方法)不同,并行运行时的用例更具体,在某些情况下无法或不适合使用它。最值得注意的是,并行运行时**只支持读取查询**。它也不支持被认为是非线程安全的存储过程函数(即从多个线程运行不安全)。
此外,并非所有查询都会因使用并行运行时而运行得更快。例如,以锚定节点开始并仅匹配图的一小部分的**图局部**查询,在并行运行时上可能不会运行得更快(由于其调度以及在多个线程上执行查询所需的额外记录,甚至可能运行得更慢)。作为一般经验法则,并行运行时可能对耗时少于半秒的查询没有益处。
对于包含使用ORDER BY
对已索引属性进行排序的子句的查询,并行运行时也可能比流水线式运行时表现更差。这是因为并行运行时无法利用属性索引进行排序,因此必须在返回任何结果之前,对所选属性上的聚合结果进行重新排序。
最后,尽管在运行并行运行时时单个查询可能运行得更快,但由于运行大量并发查询,数据库的整体吞吐量可能会下降。
因此,并行运行时不适用于高吞吐量工作负载的事务处理查询。然而,它非常适合数据库运行相对较少但要求较高的读取查询的分析用例。
概述
通常,如果满足以下条件,则应考虑并行运行时:
-
图全局读取查询旨在针对图的大部分。
-
查询速度很重要。
-
服务器有多个 CPU 和足够的内存。
-
数据库上的并发工作负载较低。
有关并行运行时的更多信息,包括查询、存储过程、函数、配置设置以及在 Aura 上使用并行运行时的更多详细信息,请参阅“并行运行时:参考”页面。
总结
下表总结了 Cypher 中可用的三种不同运行时之间最重要的区别:
槽式 | 流水线式 | 并行 | |
---|---|---|---|
执行模型 |
拉取 |
推送 |
推送 |
物理运算符消耗 |
逐行 |
批处理 |
批处理 |
处理器线程 |
单线程 |
单线程 |
多线程 |
运行时类型 |
解释型 |
编译型或解释型 |
编译型或解释型 |
支持的查询类型 |
读写 |
读写 |
只读 |