并发数据访问
隔离级别
Neo4j 支持以下隔离级别
- 读已提交 (read-committed) 隔离级别
-
默认 读取节点/关系的事务不会阻止另一个事务在第一个事务完成之前写入该节点/关系。这种隔离类型比可序列化隔离级别弱,但提供了显著的性能优势,同时足以满足绝大多数情况。
- 可序列化 (serializable) 隔离级别
-
节点和关系的显式锁定。通过获取和显式释放锁,可以模拟更高级别隔离的效果。例如,如果对公共节点或关系施加了写锁,那么所有事务都将根据该锁进行序列化——从而产生可序列化隔离级别的效果。有关如何手动获取写锁的更多信息,请参阅丢失更新。
异常
根据隔离级别,当多个事务并发读写相同数据时,可能会出现不同的异常。
此处列出的所有异常只能在读已提交隔离级别下发生。
丢失更新
在 Cypher 中,可以通过获取写锁来在某些情况下模拟改进的隔离。考虑多个并发 Cypher 查询递增属性值的情况。由于读已提交隔离级别的限制,递增操作可能不会产生确定的最终值。
Cypher 在某些情况下会自动获取写锁,但在其他情况下则不会。当 Cypher 查询使用 SET
子句更新属性时,它可能会或可能不会在被更新的节点或关系上获取写锁,这取决于是否直接依赖于被读取的属性。
自动获取写锁
当 Cypher 查询直接依赖于被读取的属性时,Cypher 会在读取属性之前自动获取写锁。这种情况发生在查询使用 SET
子句更新节点或关系上的属性,并且 SET
子句的右侧依赖于被读取的属性时。例如,在以下查询中,SET
的右侧在表达式或字面量映射中的键值对值中包含依赖属性读取。
MATCH (n:Example {id: 42})
SET n.prop = n.prop + 1
此查询将属性 n.prop
递增 1。在这种情况下,Cypher 会在读取 n.prop
的值之前自动在节点 n
上获取写锁。这确保了在当前查询运行时,没有其他并发查询可以修改节点 n
,从而防止丢失更新。
MATCH (n)
SET n += {prop: n.prop + 1}
此查询也将属性 n.prop
递增 1,但它使用映射字面量进行。在这种情况下,Cypher 也会在读取 n.prop
的值之前在节点 n
上获取写锁。
没有直接依赖来获取写锁
当查询与正在读取的属性没有直接依赖时,Cypher 不会自动获取写锁。这意味着如果您运行多个并发查询来读写相同的属性,则可能会出现丢失更新的情况,因为其他并发查询可以同时修改属性值。
例如,如果您同时运行一百个并发客户端的以下查询,属性 n.prop
很可能不会递增到 100,除非在读取属性值之前获取写锁。这是因为所有查询都在自己的事务中读取 n.prop
的值,并且看不到任何尚未提交的其他事务中递增的值。在最坏的情况下,如果所有线程都在提交事务之前执行读取,则最终值可能低至 1。
MATCH (n)
WITH n.prop AS p
// ... operations depending on p, producing k
SET n.prop = k + 1
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
不可重复读
不可重复读是指同一个事务多次读取相同数据却得到不一致的结果。如果在一个查询中两次读取相同的数据,而数据在读取期间被另一个并发查询修改,则这种情况很容易发生。
例如,以下查询显示,两次读取同一属性可能会得到不一致的结果。如果同时运行其他查询,则无法保证 p1
和 p2
具有相同的值。
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
解决此问题的最简单方法是只读取每个属性一次,并在查询中需要时保留它。
丢失和重复读取
在扫描过程中,如果另一个并发查询将实体的属性更改到扫描位置之前,该实体可能会再次出现在索引中。类似地,如果属性更改为先前扫描的位置,则该实体可能根本不会出现。
此异常只能在扫描索引或索引部分的操作符上发生,例如NodeIndexScan
或DirectedRelationshipIndexSeekByRange
。
在以下查询中,预期每个具有属性 prop
的节点 n
恰好出现一次。但是,在索引扫描期间修改 prop
属性的并发更新可能导致节点在结果集中出现多次或根本不出现。
MATCH (n:Example) WHERE n.prop IS NOT NULL
RETURN n
锁
当发生写事务时,Neo4j 会在更新时获取锁以保持数据一致性。
锁在 Neo4j 中用于确保数据一致性和隔离级别。它们不仅保护逻辑实体(如节点和关系),还保护内部数据结构的完整性。
锁由用户运行的查询自动获取。它们确保节点/关系被锁定到特定的事务,直到该事务完成。换句话说,一个事务对节点或关系的锁定会暂停其他事务同时修改相同的节点或关系。因此,锁可以防止事务之间共享资源的并发修改。
默认锁定行为
锁被添加到事务中并在事务完成时释放。如果事务回滚,锁会立即释放。
以下是不同操作的默认锁定行为
-
当在节点或关系上添加、更改或移除属性时,会在特定节点或关系上获取写锁。
-
创建或删除节点时,会获取特定节点的写锁。
-
创建或删除关系时,会获取特定关系及其两端节点的写锁。
要查看由执行查询(带有 queryId
)的事务持有的所有活动锁,请使用 CALL dbms.listActiveLocks(queryId)
过程。您需要是管理员才能运行此过程。
名称 | 类型 | 描述 |
---|---|---|
|
|
与事务对应的锁模式。 |
|
|
被锁定资源的资源类型。 |
|
|
被锁定资源的资源ID。 |
以下示例显示了由执行给定查询的事务持有的活动锁。
-
要获取当前正在执行的查询的 ID,请从
SHOW TRANSACTIONS
命令中获取currentQueryId
SHOW TRANSACTIONS YIELD currentQueryId, currentQuery
-
运行
CALL dbms.listActiveLocks
,传入感兴趣的currentQueryId
(本例中为query-614
)CALL dbms.listActiveLocks( "query-614" )
╒════════╤══════════════╤════════════╕ │"mode" │"resourceType"│"resourceId"│ ╞════════╪══════════════╪════════════╡ │"SHARED"│"SCHEMA" │0 │ └────────┴──────────────┴────────────┘ 1 row
锁竞争
如果应用程序需要在同一节点/关系上执行并发更新,则可能出现锁竞争。在这种情况下,要完成事务,事务必须等待其他事务持有的锁被释放。如果两个或多个事务尝试并发修改相同数据,则会增加死锁的可能性。在较大的图中,两个事务并发修改相同数据的可能性较小,因此死锁的可能性会降低。即便如此,即使在大型图中,如果两个或更多事务尝试并发修改相同数据,也可能发生死锁。
获取的锁类型
下表显示了根据图修改获取的锁类型
修改 | 获得的锁 |
---|---|
创建节点 |
无锁 |
更新节点标签 |
|
更新节点属性 |
|
删除节点 |
|
创建关系* |
如果节点稀疏: 如果节点密集: |
更新关系属性 |
|
删除关系* |
如果节点稀疏: 如果节点密集: 对于稀疏和密集节点均为 |
*适用于源节点和目标节点。
通常会获取额外的锁以维护索引和其他内部结构,具体取决于事务如何影响图中的其他数据。对于这些额外的锁,不能就将获取哪些锁或不获取哪些锁做出任何假设或保证。
密集节点的锁
本节《密集节点的锁》描述了 |
如果一个节点在任何时候拥有 50 个或更多关系,则认为它是密集节点(即即使将来关系少于 50 个,它仍将被视为密集节点)。如果一个节点从未拥有超过 50 个关系,则认为它是稀疏节点。您可以通过设置db.relationship_grouping_threshold
配置参数来配置节点被视为密集节点的关系计数阈值。
在 Neo4j 中创建或删除关系时,密集节点在事务期间不会被独占锁定。相反,内部共享锁会阻止节点的删除,并且会获取共享度锁以与这些节点的并发标签更改同步,以确保正确的计数更新。
在提交时,关系会插入到其关系链中当前无竞争(即未被其他事务修改)的位置,并且周围的关系会被独占锁定。
换句话说,关系修改在事务中执行操作时会获取粗粒度共享节点锁,然后在提交期间获取精确的独占关系锁。
稀疏节点和密集节点的锁定非常相似。稀疏节点最大的竞争是节点度数(即关系数量)的更新。密集节点将此数据存储在并发数据结构中,因此在几乎所有关系修改情况下都可以避免独占节点锁。
配置锁获取超时
执行中的事务可能会在等待其他事务释放某些锁时卡住。要终止该事务并移除锁,请将db.lock.acquisition.timeout
设置为一个正的时间间隔值(例如 10s
),表示获取任何特定锁的最大时间间隔,超过该时间间隔事务将失败。将 db.lock.acquisition.timeout
设置为 0
(默认值)会禁用锁获取超时。
此功能无法动态设置。
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中同时运行以下两个查询将导致死锁,因为它们试图并发修改相同的节点属性
: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;
: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 的 |
代码中的死锁处理
在代码中处理死锁时,您可能需要解决几个问题
-
仅执行有限次数的重试,如果达到阈值则失败。
-
每次尝试之间暂停,以允许其他事务完成,然后再试一次。
-
重试循环不仅对死锁有用,对其他类型的瞬态错误也很有用。
有关如何在过程、服务器扩展中处理死锁或在使用嵌入式 Neo4j 时处理死锁的示例,请参阅Neo4j Java 参考中的事务管理。
避免死锁
死锁很可能通过重试事务来解决。然而,这会负面影响数据库的总事务吞吐量,因此了解避免死锁的策略很有用。
Neo4j 通过内部排序操作来辅助事务。有关内部锁的更多信息,请参阅下文。但是,这种内部排序仅适用于在创建或删除关系时获取的锁。因此,鼓励用户在 Neo4j 不提供内部辅助的情况下(例如在更新属性时获取锁)对其操作进行排序。这可以通过确保更新以相同的顺序发生来实现。例如,如果始终以相同的顺序(例如 A→B→C
)获取三个锁 A
、B
和 C
,那么事务将永远不会在等待锁 A
释放时持有锁 B
,因此不会发生死锁。
另一种选择是通过不同时修改相同的实体来避免锁竞争。
为避免死锁,内部锁应按以下顺序获取
内部锁类型在不同的 Neo4j 版本之间可能会在不通知的情况下更改。此处列出锁类型仅为提供内部锁定机制的思路。 |
锁类型 | 锁定实体 | 描述 | ||
---|---|---|---|---|
|
令牌ID |
Schema 锁,锁定特定标签或关系类型上的索引和约束。 |
||
|
Schema 名称 |
锁定 Schema 名称以避免重复。
|
||
|
节点ID |
在事务创建阶段在节点上获取的锁,以防止该节点和/或关系组的删除。这与 |
||
|
节点ID |
节点上的锁,用于防止并发更新节点记录(即添加/删除标签,设置属性,添加/删除关系)。请注意,更新关系只有在需要更新关系链/关系组链的头部时才需要节点上的锁,因为那是节点记录中唯一的数据部分。 |
||
|
节点ID |
用于锁定节点以避免在添加或删除关系时并发更改标签。否则,此类更新将导致计数存储不一致。 |
||
|
关系ID |
锁定关系以在删除期间独占访问。 |
||
|
节点ID |
锁定给定密集节点的完整关系组链。这不会锁定节点,与 |
||
|
关系 |
关系上的锁,或者更具体地说,是关系记录上的锁,用于防止并发更新。 |