并发数据访问

隔离级别

Neo4j 支持以下隔离级别

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

默认 读取节点/关系的事务不会阻止另一个事务在第一个事务完成之前写入该节点/关系。这种隔离类型比可序列化隔离级别弱,但提供了显著的性能优势,同时足以满足绝大多数情况。

可序列化 (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 不会自动获取写锁。这意味着如果您运行多个并发查询来读写相同的属性,则可能会出现丢失更新的情况,因为其他并发查询可以同时修改属性值。

例如,如果您同时运行一百个并发客户端的以下查询,属性 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.dummy) 来获取节点的写锁,然后再读取所需的值 (n.prop)。一旦获取,写锁将确保在事务提交或回滚之前,没有其他并发查询可以修改该节点。虚拟属性仅用于获取写锁,因此在获取锁后可以立即将其删除。

示例 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. 过程输出
名称 类型 描述

模式

字符串

与事务对应的锁模式。

资源类型

字符串

被锁定资源的资源类型。

资源ID

整数

被锁定资源的资源ID。

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

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

  1. 要获取当前正在执行的查询的 ID,请从 SHOW TRANSACTIONS 命令中获取 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

创建关系*

如果节点稀疏:NODE 锁。

如果节点密集:NODE DELETE 防御锁。

更新关系属性

RELATIONSHIP

删除关系*

如果节点稀疏:NODE 锁。

如果节点密集:NODE DELETE 防御锁。

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

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

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

密集节点的锁

本节《密集节点的锁》描述了 standardalignedhigh_limit 存储格式的行为。block 格式具有类似但不完全相同的功能。

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

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

在提交时,关系会插入到其关系链中当前无竞争(即未被其他事务修改)的位置,并且周围的关系会被独占锁定。

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

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

配置锁获取超时

执行中的事务可能会在等待其他事务释放某些锁时卡住。要终止该事务并移除锁,请将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 锁,锁定特定标签或关系类型上的索引和约束。

SCHEMA_NAME

Schema 名称

锁定 Schema 名称以避免重复。

碰撞是可能的,因为哈希是字符串化的。这只会影响并发性而非正确性。

NODE_RELATIONSHIP_GROUP_DELETE

节点ID

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

NODE

节点ID

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

DEGREES

节点ID

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

RELATIONSHIP_DELETE

关系ID

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

RELATIONSHIP_GROUP

节点ID

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

RELATIONSHIP

关系

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

删除语义

删除节点或关系时,该实体所有属性将自动删除,但节点的关系不会被删除。Neo4j 强制(在提交时)所有关系必须具有有效的起始节点和结束节点。实际上,这意味着尝试删除仍连接有关系节点将在提交时抛出异常。然而,只要事务提交时不存在任何关系,就可以选择删除节点和连接关系的顺序。

删除语义可以总结如下

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

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

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

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

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

© . All rights reserved.