并发数据访问

隔离级别

Neo4j 支持以下隔离级别

读提交隔离级别

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

可序列化隔离级别

节点和关系的显式锁定。使用锁允许通过显式获取和释放锁来模拟更高隔离级别的效果。例如,如果在公共节点或关系上获取写锁,则所有事务都将在此锁上序列化,从而产生可序列化隔离级别的效果。有关如何手动获取写锁的更多信息,请参阅丢失更新

异常

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

此处列出的所有异常都只能在读取已提交隔离级别下发生。

丢失更新

在 Cypher 中,可以在某些情况下获取写锁来模拟改进的隔离。考虑多个并发 Cypher 查询递增属性值的情况。由于读取已提交隔离级别的限制,增量可能不会导致确定性的最终值。如果存在直接依赖关系,Cypher 会在读取之前自动获取写锁。直接依赖关系是指SET的右侧在表达式中具有依赖属性读取或文字映射中键值对的值。

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

示例 1. Cypher 可以获取写锁

以下示例需要写锁,并且 Cypher 会自动获取一个

MATCH (n:Example {id: 42})
SET n.prop = n.prop + 1
示例 2. Cypher 可以获取写锁

此示例也需要写锁,并且 Cypher 会自动获取一个

MATCH (n)
SET n += {prop: n.prop + 1}

由于在一般情况下确定此类依赖关系的复杂性,因此 Cypher 不涵盖以下任何示例用例

示例 3. 复杂的 Cypher

变量取决于先前语句中读取属性的结果

MATCH (n)
WITH n.prop AS p
// ... operations depending on p, producing k
SET n.prop = k + 1
示例 4. 复杂的 Cypher

在同一查询中读取和写入的属性之间的循环依赖关系

MATCH (n)
SET n += {propA: n.propB + 1, propB: n.propA + 1}

为了确保在更复杂的情况下也能获得确定性行为,有必要在有问题的节点上显式获取写锁。在 Cypher 中,对此没有显式支持,但可以通过写入临时属性来解决此限制。

示例 5. 显式获取写锁

此示例通过在读取请求的值之前写入虚拟属性来获取节点的写锁

MATCH (n:Example {id: 42})
SET n._LOCK_ = true
WITH n.prop AS p
// ... operations depending on p, producing k
SET n.prop = k + 1
REMOVE n._LOCK_

在读取n.prop之前存在SET n._LOCK_语句确保在读取操作之前获取锁,并且由于对该特定节点上所有并发查询的强制序列化,不会丢失任何更新。

不可重复读

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

示例 6. 不可重复读

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

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

示例 7. 丢失和重复读取

在以下查询中,预期每个具有属性prop的节点n都恰好出现一次。但是,在索引扫描期间修改prop属性的并发更新可能会导致节点在结果集中多次出现或根本不出现。

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

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

频繁出现死锁表明并发写请求以无法在同时满足预期隔离性和一致性的方式发生。解决方案是确保并发更新合理地进行。例如,给定两个特定节点(A 和 B),为每个事务以随机顺序添加或删除这两个节点的关系会导致两个或多个事务同时执行时发生死锁。一种选择是确保更新始终以相同的顺序发生(先 A 后 B)。另一种选择是确保每个线程/事务对节点或关系没有任何与其他并发事务冲突的写入操作。例如,可以通过让单个线程执行特定类型的所有更新来实现这一点。

由除 Neo4j 管理的锁之外的其他同步使用引起的死锁仍然可能发生。需要同步的其他代码应以一种方式进行同步,即它永远不会在同步块中执行任何 Neo4j 操作。

死锁检测

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

事务 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;
事务 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 不提供内部协助的情况下(例如,当为属性更新获取锁时)对操作进行排序。这是通过确保更新以相同的顺序发生来完成的。例如,如果三个锁ABC始终以相同的顺序获取(例如A→B→C),则事务永远不会在等待释放锁A的同时持有锁B,因此不会发生死锁。

另一种选择是避免锁争用,方法是不同时修改相同的实体。

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

内部锁类型可能会在不同的 Neo4j 版本之间未经任何通知而更改。此处仅列出锁类型是为了了解内部锁定机制。

锁类型 锁定实体 描述

LABELRELATIONSHIP_TYPE

令牌 ID

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

SCHEMA_NAME

模式名称

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

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

NODE_RELATIONSHIP_GROUP_DELETE

节点 ID

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

NODE

节点 ID

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

DEGREES

节点 ID

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

RELATIONSHIP_DELETE

关系 ID

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

RELATIONSHIP_GROUP

节点 ID

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

RELATIONSHIP

关系

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

删除语义

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

删除语义可以概括如下

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

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

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

  • 在已删除(但尚未提交)节点或关系上执行任何写操作都将抛出异常。

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