并发数据访问

隔离级别

Neo4j 支持以下隔离级别:

读已提交 (read-committed) 隔离级别

默认 读取节点/关系的事务不会阻止另一个事务在第一个事务完成之前写入该节点/关系。这种隔离级别弱于可串行化隔离级别 (serializable isolation level),但在绝大多数情况下已足够,且具备显著的性能优势。

可串行化 (serializable) 隔离级别

对节点和关系进行显式锁定。使用锁可以通过显式获取和释放锁来模拟更高隔离级别的效果。例如,如果在公共节点或关系上获取了写锁,则所有事务都会在该锁上进行串行化,从而达到可串行化隔离级别的效果。有关如何手动获取写锁的更多信息,请参阅 丢失更新

异常

根据隔离级别的不同,当多个事务并发读取或写入相同数据时,可能会出现不同的异常。

此处列出的所有异常仅可能在“读已提交”隔离级别下发生。

丢失更新

在 Cypher 中,有时可以通过获取写锁来模拟更好的隔离效果。考虑多个并发 Cypher 查询递增属性值的情况。由于读已提交隔离级别的限制,递增操作可能不会产生确定性的最终结果。

Cypher 在某些情况下会自动获取写锁,但在其他情况下则不会。当 Cypher 查询使用 SET 子句更新属性时,它是否会对正在更新的节点或关系获取写锁,取决于它对正在读取的属性是否存在直接依赖。

自动获取写锁

当 Cypher 查询对正在读取的属性存在直接依赖时,Cypher 会在读取该属性之前自动获取写锁。这种情况发生在使用 SET 子句更新节点或关系的属性,且 SET 子句的右侧依赖于被读取的属性时。例如,在以下查询中,SET 的右侧在表达式中或字面量映射的键值对的值中包含了一个被读取的依赖属性。

示例 1. 使用表达式递增属性
MATCH (n:Example {id: 42})
SET n.prop = n.prop + 1

此查询将属性 n.prop 递增 1。在这种情况下,Cypher 会在读取 n.prop 的值之前,自动对节点 n 获取写锁。这确保了在查询运行时,没有其他并发查询能够修改节点 n,从而防止了丢失更新。

示例 2. 使用映射字面量递增属性
MATCH (n)
SET n += {prop: n.prop + 1}

此查询同样将属性 n.prop 递增 1,但它使用了映射字面量。在这种情况下,Cypher 也会在读取 n.prop 的值之前,对节点 n 获取写锁。

无直接依赖导致无法自动获取写锁

当查询对正在读取的属性没有直接依赖时,Cypher 不会自动获取写锁。这意味着如果您运行多个并发查询来读取和写入同一个属性,就有可能因为允许其他并发查询同时修改该属性值而导致丢失更新。

例如,如果您由 100 个并发客户端运行以下查询,除非在读取属性值之前获取了写锁,否则 n.prop 的值极大概率不会增加到 100。这是因为所有查询都在各自的事务中读取 n.prop 的值,并且无法看到任何尚未提交的其他事务产生的递增值。在最坏的情况下,如果所有线程都在任何线程提交事务之前执行了读取操作,最终值可能会低至 1。

示例 3. 变量依赖于较早语句中读取属性的结果
MATCH (n)
WITH n.prop AS p
// ... operations depending on p, producing k
SET n.prop = k + 1
示例 4. 同一查询中读取和写入的属性之间存在循环依赖
MATCH (n)
SET n += {propA: n.propB + 1, propB: n.propA + 1}
变通方案

为了在更复杂的情况下也能确保确定性行为,有必要对相关节点显式获取写锁。Cypher 没有对此的直接支持,但可以通过写入临时属性来绕过此限制。例如,以下查询通过在读取所需值 (n.prop) 之前写入一个哑 (dummy) 属性 (n.dummy),为节点获取了写锁。一旦获取,写锁确保在事务提交或回滚之前,没有其他并发查询可以修改该节点。哑属性仅用于获取写锁,因此在获取锁后可以立即删除。

示例 5. 使用哑属性获取写锁
MATCH (n:Example {id: 42})
SET n._dummy_ = true
REMOVE n._dummy_
WITH n.prop AS p
// ... operations depending on p, producing k
SET n.prop = k + 1

不可重复读

不可重复读是指同一事务多次读取同一数据但得到不一致的结果。如果在查询中两次读取相同的数据,而期间数据被另一个并发查询修改,这种情况很容易发生。

例如,以下查询显示两次读取同一属性可能会产生不一致的结果。如果有其他查询在并发运行,则不能保证 p1p2 具有相同的值。

示例 6. 不可重复读
MATCH (n:Example {id: 42})
WITH n.prop AS p1
// another concurrent query changes the value of n.prop here.
WITH *, n.prop AS p2
RETURN p1, p2

解决此问题的最简单方法是每个属性只读取一次,并在查询中根据需要保留该值。

丢失读取和重复读取

当扫描 索引 时,即使实体存在于索引中,也可能被观察多次或完全跳过。即使对于支持 属性唯一性约束 的索引也是如此。

在扫描过程中,如果另一个并发查询将实体的属性更改为扫描位置之前的位置,该实体可能会再次出现在索引中。同样,如果属性被更改为之前已扫描过的位置,该实体可能完全不会出现在结果中。

这种异常仅会出现在扫描索引或索引部分的运算符中,例如 NodeIndexScanDirectedRelationshipIndexSeekByRange

在以下查询中,具有属性 prop 的每个节点 n 预期只会出现一次。然而,如果在索引扫描过程中发生修改 prop 属性的并发更新,则可能导致节点在结果集中多次出现或完全不出现。

示例 7. 丢失读取和重复读取
MATCH (n:Example) WHERE n.prop IS NOT NULL
RETURN n

当发生写事务时,Neo4j 会通过加锁来在更新过程中保持数据一致性。

Neo4j 使用锁来确保数据一致性和隔离级别。它们不仅保护逻辑实体(如节点和关系),还保护内部数据结构的完整性。

锁是由用户运行的查询自动获取的。它们确保节点/关系在事务完成之前被锁定给特定的事务。换句话说,一个事务对节点或关系的锁定会暂停其他事务同时修改该节点或关系。因此,锁可以防止事务之间对共享资源的并发修改。

默认锁定行为

锁会添加到事务中,并在事务完成时释放。如果事务回滚,锁会立即释放。

以下是不同操作的默认锁定行为:

  • 在节点或关系上添加、更改或删除属性时,会获取该特定节点或关系的写锁。

  • 创建或删除节点时,会获取该特定节点的写锁。

  • 创建或删除关系时,会获取该特定关系及其两个节点的写锁。

要查看执行 queryId 查询的事务所持有的所有活动锁,请使用 CALL dbms.listActiveLocks(queryId) 过程。您需要拥有管理员权限才能运行此过程。

表 1. 过程输出
名称 类型 描述

mode

字符串

对应于事务的锁模式。

resourceType

字符串

被锁定资源的资源类型。

resourceId

整数

被锁定资源的资源 ID。

示例 8. 查看查询的活动锁

以下示例显示了执行特定查询的事务所持有的活动锁。

  1. 要获取当前执行查询的 ID,请从 SHOW TRANSACTIONS 命令中 yield currentQueryId

    SHOW TRANSACTIONS YIELD currentQueryId, currentQuery
  2. 运行 CALL dbms.listActiveLocks 并传入相关的 currentQueryId(本例中为 query-614)。

    CALL dbms.listActiveLocks( "query-614" )
╒════════╤══════════════╤════════════╕
│"mode"  │"resourceType"│"resourceId"│
╞════════╪══════════════╪════════════╡
│"SHARED"│"SCHEMA"      │0           │
└────────┴──────────────┴────────────┘
1 row

锁争用

如果应用程序需要在相同的节点/关系上执行并发更新,可能会出现锁争用。在这种情况下,事务必须等待其他事务持有的锁被释放才能完成。如果两个或多个事务试图同时修改相同的数据,则会增加 死锁 的可能性。在大型图中,两个事务同时修改相同数据的可能性较小,因此死锁的可能性会降低。话虽如此,即使在大型图中,如果两个或多个事务试图同时修改相同的数据,也可能发生死锁。

已获取锁的类型

下表显示了根据图修改操作获取的锁类型:

表 2. 图修改获取的锁
修改 获取的锁

创建节点

无锁

更新节点标签

NODE

更新节点属性

NODE

删除节点

NODE

创建关系*

如果节点是稀疏 (sparse) 的:NODE 锁。

如果节点是密集 (dense) 的:NODE DELETE 预防锁。

更新关系属性

RELATIONSHIP

删除关系*

如果节点是稀疏 (sparse) 的:NODE 锁。

如果节点是密集 (dense) 的:NODE DELETE 预防锁。

对于稀疏和密集节点,均为 RELATIONSHIP 锁。

*适用于源节点和目标节点。

通常会获取额外的锁来维护索引和其他内部结构,具体取决于事务对图中其他数据的影响。对于这些额外的锁,无法就将会或不会获取哪些锁做出假设或保证。

密集节点的锁

在创建或删除关系时,Neo4j 不会在事务期间独占锁定密集节点。相反,内部共享锁可以防止节点被删除,并且会获取共享度锁 (shared degree locks) 以与这些节点的并发标签更改同步,从而确保正确的计数更新。

standard, aligned, 和 high_limit 存储格式

如果一个节点在任何时候拥有 50 个或更多关系,则认为该节点是密集型的。即使后来关系数少于 50 个,它仍被视为密集型。
如果一个节点从未拥有超过 50 个关系,则认为它是稀疏型的。
您可以通过设置 db.relationship_grouping_threshold 配置参数来配置节点被视为密集型时的关系计数阈值。

block 格式

当一个节点针对特定关系类型的关系数量超过某个内部大小阈值(通常约为 50 个该类型的关系)时,该节点被视为密集型。然而,这也取决于连接到这些关系的属性的数量和大小。因此,一个节点可能对一种关系类型(例如 A)是密集型的,而对另一种关系类型(例如 B)是稀疏型的。

在提交时,关系以允许并发修改的方式插入到基础数据结构中。例如,多个事务可以同时创建、更新或删除连接到相同密集节点的关系。在极少数情况下,为了确保数据一致性,此过程可能会以排序方式获取额外的排他锁。

换句话说,关系修改在事务中执行操作时会获取粗粒度的共享节点锁,然后在提交期间获取精确的排他锁。

稀疏节点和密集节点的锁定非常相似。稀疏节点的主要争用点是节点度(即关系数量)的更新。密集节点将此数据存储在并发数据结构中,因此在几乎所有关系修改的情况下都可以避免排他节点锁。

配置锁获取超时

正在执行的事务可能会在等待其他事务释放锁时卡住。要终止该事务并移除锁,请将 db.lock.acquisition.timeout 设置为一个正的时间间隔值(例如 10s),表示在事务失败之前应获取任何特定锁的最长时间间隔。将 db.lock.acquisition.timeout 设置为 0(这是默认值)将禁用锁获取超时。

此功能无法动态设置。

示例 9. 将超时时间设置为十秒
db.lock.acquisition.timeout=10s

死锁

由于使用了锁,因此可能会发生死锁。当两个事务因试图同时修改对方持有的节点或关系而被对方阻塞时,就会发生死锁。在这种情况下,两个事务都无法继续。当 Neo4j 检测到死锁时,事务会以短暂错误代码 Neo.TransientError.Transaction.DeadlockDetected 终止。从 5.25 版本开始,错误消息还包含 GQLSTATUS 代码 50N05 和状态描述 error: general processing exception - deadlock detected. Deadlock detected while trying to acquire locks. See log for more details.

事务获取的所有锁仍然保持,但在事务完成时会释放。一旦锁被释放,等待导致死锁的事务持有的锁的其他事务就可以继续执行。如有必要,您可以随后重试导致死锁的事务执行的工作。

频繁发生死锁表明并发写请求的发生方式使得无法在符合预期隔离和一致性的同时执行它们。解决方案是确保并发更新合理地进行。例如,给定两个特定的节点 (A 和 B),如果每个事务以随机顺序向这两个节点添加或删除关系,则当两个或多个事务同时执行此操作时会导致死锁。一种选择是确保更新总是按相同的顺序进行(先 A 后 B)。另一种选择是确保每个线程/事务不会对某个节点或关系进行与其他并发事务冲突的写入。例如,可以让单个线程执行特定类型的所有更新。

因使用非 Neo4j 管理的锁而导致的同步问题仍可能引起死锁。其他需要同步的代码应以从不在同步块内执行任何 Neo4j 操作的方式进行同步。

死锁检测

例如,在 Cypher-shell 中同时运行以下两个查询将导致死锁,因为它们试图并发修改相同的节点属性。

示例 10. 事务 A
:begin
MATCH (n:Test) SET n.prop = 1
WITH collect(n) as nodes
CALL apoc.util.sleep(5000)
MATCH (m:Test2) SET m.prop = 1;
示例 11. 事务 B
:begin
MATCH (n:Test2) SET n.prop = 1
WITH collect(n) as nodes
CALL apoc.util.sleep(5000)
MATCH (m:Test) SET m.prop = 1;

系统将抛出以下错误消息:

The transaction will be rolled back and terminated. Error: ForsetiClient[transactionId=6698, clientId=1] can't acquire ExclusiveLock{owner=ForsetiClient[transactionId=6697, clientId=3]} on NODE(27), because holders of that lock are waiting for ForsetiClient[transactionId=6698, clientId=1].
 Wait list:ExclusiveLock[
Client[6697] waits for [ForsetiClient[transactionId=6698, clientId=1]]]

Cypher 的 MERGE 子句为了确保数据唯一性会无序获取锁,这可能会阻止 Neo4j 的内部排序操作以避免死锁的方式对事务进行排序。因此,在可能的情况下,鼓励使用 Cypher 的 CREATE 子句,它不会无序获取锁。

代码中的死锁处理

在代码中处理死锁时,您可能需要解决以下几个问题:

  • 仅进行有限次数的重试,并在达到阈值时失败。

  • 在每次尝试之间暂停,以便另一个事务在再次尝试之前完成。

  • 重试循环不仅对死锁有用,对其他类型的短暂错误也很有用。

有关如何在过程、服务器扩展或使用 Neo4j 嵌入式数据库时处理死锁的示例,请参阅 Neo4j Java 参考手册中的事务管理

避免死锁

大多数情况下,通过重试事务可以解决死锁。然而,这将对数据库的总事务吞吐量产生负面影响,因此了解避免死锁的策略很有用。

Neo4j 通过内部排序操作来辅助事务。请参阅下文了解有关内部锁的更多信息。但是,这种内部排序仅适用于创建或删除关系时获取的锁。因此,在 Neo4j 不进行内部辅助的情况下(例如在获取属性更新锁时),鼓励用户对操作进行排序。这可以通过确保更新以相同的顺序进行来实现。例如,如果始终按相同顺序(例如 A→B→C)获取三个锁 ABC,则事务永远不会在等待释放锁 A 的同时持有锁 B,从而不会发生死锁。

另一种选择是通过不并发修改相同的实体来避免锁争用。

为避免死锁,应按以下顺序获取内部锁:

内部锁类型可能会在不同的 Neo4j 版本之间进行更改,恕不另行通知。此处列出锁类型仅是为了说明内部锁定机制。

锁类型 锁定实体 描述

LABELRELATIONSHIP_TYPE

令牌 ID

模式锁 (Schema locks),用于锁定特定标签或关系类型上的索引和约束。

SCHEMA_NAME

模式名称

锁定模式名称以避免重复。

由于哈希是字符串化的,因此可能会发生冲突。这仅影响并发性,不影响正确性。

NODE_RELATIONSHIP_GROUP_DELETE

节点 ID

在事务创建阶段获取节点锁,以防止删除该节点和/或关系组。这不同于 NODE 锁,旨在允许标签和属性更改与关系修改同时进行。

NODE

节点 ID

节点上的锁,用于防止对节点记录的并发更新(即添加/删除标签、设置属性、添加/删除关系)。请注意,更新关系仅在需要更新关系链/关系组链的头部时才需要节点锁,因为那是节点记录中唯一的数据部分。

DEGREES

节点 ID

用于锁定节点,以避免在添加或删除关系时发生并发标签更改。否则,此类更新会导致计数存储不一致。

RELATIONSHIP_DELETE

关系 ID

在删除期间锁定关系以进行独占访问。

RELATIONSHIP_GROUP

节点 ID

锁定给定密集节点的完整关系组链。与 NODE_RELATIONSHIP_GROUP_DELETE 锁不同,这不会锁定节点。

RELATIONSHIP

关系

关系上的锁,更具体地说是关系记录上的锁,用于防止并发更新。

删除语义

删除节点或关系时,该实体的所有属性将自动被移除,但节点的某些关系不会被移除。Neo4j 在提交时强制执行约束,即所有关系必须具有有效的起始节点和结束节点。实际上,这意味着尝试删除仍附带有关系的其他节点时,在提交时会抛出异常。但是,只要在事务提交时不存在任何关系,您可以选择删除节点及其附加关系的顺序。

删除语义总结如下:

  • 节点或关系被删除时,其所有属性都将被移除。

  • 已删除的节点在事务提交时不得有任何附加关系。

  • 可以获取尚未提交的已删除关系或节点的引用。

  • 在节点或关系被删除(但尚未提交)后对其进行任何写操作都会抛出异常。

  • 提交后尝试获取已删除节点或关系的新引用或使用旧引用都会抛出异常。