原文链接:https://neo4j.com/docs/java-reference/current/transaction-management/
1. 概述
为了充分维护数据完整性并确保良好的事务行为,Neo4j 支持 ACID 的四大属性:
- 原子性,如果事务的任何部分失败,数据库状态保持不变。
- 一致性,任何事务都会使数据库处于一致状态。
- 隔离性,在一个事务期间,修改过的数据不能被其他操作访问。
- 持久性,DBMS 始终可以恢复已提交事务的结果。
具体来说:
- 访问图、索引或模式的所有数据库操作都必须在事务中执行。
- 默认隔离级别是读提交(read-commited)隔离级别。
- 通过遍历检索得到的数据结果不被保护,其他事务可以修改。
- 可能会发生不可重复读(例如,仅获取写锁并保持到事务结束)。
- 可以手动获取节点和关系上的写锁,以实现更高级别的隔离 --序列化隔离级别(serialization isolation )。
- 在节点和关系级别获取锁。
- 死锁检测内置于核心事务管理中。
1. 执行周期
有些数据库操作必须在事务中执行以确保 ACID 属性。具体来说,访问图、索引或模式的操作就属于这类操作。事务是单线程的、受限的和独立的。多个事务可以在单个线程中启动,并且它们彼此保持独立。
处理事务的交互周期如下所示:
- 开始事务。
- 执行数据库操作。
- 提交或回滚事务。
完成每个事务非常重要。事务在完成之前不会释放它所获得的锁或内存。
Neo4j 中事务的惯用用法是使用 try-with-resources 语句并将事务声明为资源之一,然后启动事务并尝试执行图操作。 try 块中的最后一个操作应该提交或回滚事务,具体取决于业务逻辑。在这种情况下,try-with-resources 被用作防止异常的保护措施和额外的安全机制,以确保无论语句块内发生什么事情都可以关闭事务。所有未提交的事务将在语句结束时作为资源清理的一部分回滚。如果事务已明确提交或回滚,则不需要资源清理,事务关闭将是一个空操作。
在事务中执行的所有修改都保存在内存中,这意味着需要将非常大的更新拆分为多个事务以避免内存不足。
2. 隔离级别
Neo4j 中的事务使用读提交隔离级别,这意味着它们会在数据提交后立即看到数据,但不会看到其他尚未提交的事务中的数据。这种类型的隔离比序列化弱,但提供了显着的性能优势,同时足以满足绝大多数情况。
此外,Neo4j Java API 支持显式锁定节点和关系。使用锁可以通过显式获取和释放锁来模拟更高级别隔离的效果。例如,如果在公共节点或关系上获取写锁,则所有事务都将在该锁上进行序列化 – 相当于序列化隔离级别的效果。
2.1. Cypher 中丢失的更新
在 Cypher 中,在某些情况下可以获取写锁来模拟改进的隔离级别。想象一下当多个并发 Cypher 查询在增加同一属性值的情况。由于已提交读隔离级别的限制,增量可能不会产生确定性的最终值。如果存在“直接依赖“”,Cypher 会在读取前自动获取写锁。“直接依赖”是指在 SET 语句的右侧表达式中有对依赖的属性的读取操作,或者一个Map中有对键-值对的中值的读取。
例如以下查询,如果由一百个并发客户端运行,则很可能不会将属性 n.prop 增加到 100,除非在读取属性值之前获取了写锁。这是因为所有查询都会在它们自己的事务中读取 n.prop 的值,并且不会从任何其他尚未提交的事务中看到已经增加值。在最坏的情况下,如果所有线程在任何其他线程提交其事务之前完成读取,则最终值将可能就是1(假设n.prop的初始值是0)。
示例 1. Cypher 可以获取写锁
下面的例子需要一个写锁,Cypher 会自动获取一个:
MATCH (n:X {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:X {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_ 语句,它确保在读取操作之前获取写锁,并且不会由于该特定节点上所有并发查询的强制序列化而丢失更新。
3. 默认的加锁行为
- 在节点或关系上添加、更改或删除属性时,将对特定节点或关系进行写锁定。
- 创建或删除节点时,将为特定节点获取写锁。
- 创建或删除关系时,将对特定关系及其两端的节点进行写锁定。
锁将被添加到事务中并在事务完成时释放。
4. 死锁
既然使用了锁,就有可能会发生死锁,但Neo4j 会在它们发生之前检测出来(由获取锁导致的死锁)并抛出异常。在这个异常被抛出之前,事务会被标记为回滚。事务获取的所有锁仍将被持有,但会在事务完成时释放(在前面指出的 finally 块中)。一旦锁被释放,等待锁(由被死锁的事务持有)的其他事务就可以继续进行。如果需要,用户可以重试导致死锁的事务执行的工作。
频繁的死锁往往意味着并发写入请求的正在以某种方式发生,即不可能在执行它们的同时满足预期的隔离和一致性。解决方案是确保并发更新以合理的方式发生。例如,给定两个特定节点(A 和 B),为每个事务以随机顺序添加或删除这两个节点的关系,当有两个或更多事务同时执行时将导致死锁。一种选择是确保更新总是以相同的顺序发生(首先是 A,然后是 B)。另一种选择是确保每个线程/事务不会像其他并发事务那样对节点或关系进行任何有冲突的写入。例如,可以通过让单个线程执行特定类型的所有更新来实现。
使用 Neo4j 管理的锁之外的其他同步导致的死锁仍然可能发生。由于 Neo4j API 中的所有操作都是线程安全(thread safe)的,除非另有说明,因此不需要外部同步。其他需要同步的代码应该以这样的方式同步,即它永远不会在同步的块中执行任何 Neo4j 操作。
4.1.死锁处理示例
下面,您将找到如何在过程、服务器扩展或使用 嵌入式Neo4j时处理死锁的示例。
完整源代码可以在 DeadlockDocTest.java 中找到。
在处理代码中的死锁时,您可能需要解决几个问题:
- 只进行有限数量的重试,如果达到阈值则失败。
- 在每次尝试之间暂停以允许其他事务在再次尝试之前完成。
- 重试循环不仅可用于死锁,还可用于其他类型的瞬态错误。
下面是一个示例,说明如何实现这一点。
示例 6. 使用重试循环处理死锁
这个例子展示了如何使用重试循环来处理死锁:
Throwable txEx = null;
int RETRIES = 5;
int BACKOFF = 3000;
for ( int i = 0; i < RETRIES; i++ )
{
try ( Transaction tx = databaseService.beginTx() )
{
Object result = doStuff(tx);
tx.commit();
return result;
}
catch ( Throwable ex )
{
txEx = ex;
// Add whatever exceptions to retry on here
if ( !(ex instanceof DeadlockDetectedException) )
{
break;
}
}
// Wait so that we don't immediately get into the same deadlock
if ( i < RETRIES - 1 )
{
try
{
Thread.sleep( BACKOFF );
}
catch ( InterruptedException e )
{
throw new TransactionFailureException( "Interrupted", e );
}
}
}
if ( txEx instanceof TransactionFailureException )
{
throw ((TransactionFailureException) txEx);
}
else if ( txEx instanceof Error )
{
throw ((Error) txEx);
}
else
{
throw ((RuntimeException) txEx);
}
5. 删除语义
删除节点或关系时,将自动删除该实体的所有属性,但节点的关系不会删除。 Neo4j 强制执行一个约束(提交时),即所有关系都必须具有有效的开始节点和结束节点。实际上,这意味着尝试删除仍然具有关系的节点将在提交时引发异常。但是,只要在提交事务时不存在关系,就可以选择删除节点和附加关系的顺序。
删除语义可以总结如下:
- 当一个节点或关系被删除时,它的所有属性都将被删除。
- 当事务提交时,被删除的节点不能有任何关系。
- 可以获取对尚未提交的、删除的关系或节点的引用。
- 在节点或关系被删除(但尚未提交)之后,对节点或关系的任何写操作都将引发异常。
- 在提交后尝试获取对已删除节点或关系的新引用或旧引用,将引发异常。
6. 创建唯一的节点
在许多用例中,实体之间需要一定程度的唯一性。例如,系统中可能只存在一个具有特定电子邮件地址的用户。如果多个并发线程都尝试创建用户,则会创建重复项。
以下是确保唯一性的主要策略,它们都适用于跨集群和单实例部署。
6.1.单线程
通过使用单个线程,没有两个线程甚至会尝试同时创建特定实体。在集群中,外部单线程客户端可以执行这样的操作。
6.2.获取或创建
定义唯一性约束并使用 Cypher MERGE 子句是获取或创建唯一节点的最有效方法。有关更多信息,请参阅使用Cypher和唯一性约束获取或创建唯一节点。
7. 事务事件
可以注册一个 neo4j.org.graphdb.event.TransactionEventListener 来接收 Neo4j 数据库事务事件。一旦它在 org.neo4j.dbms.api.DatabaseManagementService 实例上注册,它就会接收它注册的数据库的事务事件。侦听器会收到有关已执行的任何写操作、并将被提交的事务的通知。如果 Transaction#commit() 没有被调用,或者事务被 Transaction#rollback() 回滚,它将被回滚并且没有事件被发送到侦听器。
在提交事务之前,会调用侦听器的 beforeCommit 方法,并使用事务中所做修改的整个差异。此时事务仍在运行,因此仍然可以进行更改。该方法也可能抛出异常,这将阻止事务被提交。如果事务回滚,则会调用侦听器的 afterRollback 方法。
侦听器的执行顺序未定义 ,不能保证任何一个侦听器所做的更改会被其他侦听器收到。
如果 beforeCommit 在所有注册的侦听器中成功执行,则提交事务并使用相同的事务数据调用 afterCommit 方法。此调用还包括从 beforeCommit 返回的对象。
在 afterCommit 事务已关闭,访问 org.neo4j.graphdb.event.TransactionData 之外的任何内容都需要打开一个新事务。 Neo4j.org.graphdb.event.TransactionEventListener 收到有关通过 org.neo4j.graphdb.event.TransactionData 可访问的任何更改的事务的通知。某些索引和架构更改不会触发这些事件。
以下示例显示如何为特定数据库注册侦听器,并在事务更改集之上执行基本操作。
完整源代码可以在 TransactionEventListenerExample.java 中找到。
示例 7. TransactionEventListener 注册事务事件侦听器并检查更改集:
public static void main( String[] args ) throws IOException
{
FileUtils.deleteDirectory( HOME_DIRECTORY );
var managementService = new DatabaseManagementServiceBuilder( HOME_DIRECTORY ).build();
var database = managementService.database( DEFAULT_DATABASE_NAME );
var countingListener = new CountingTransactionEventListener();
managementService.registerTransactionEventListener( DEFAULT_DATABASE_NAME, countingListener );
var connectionType = RelationshipType.withName( "CONNECTS" );
try ( var transaction = database.beginTx() )
{
var startNode = transaction.createNode();
var endNode = transaction.createNode();
startNode.createRelationshipTo( endNode, connectionType );
transaction.commit();
}
}
private static class CountingTransactionEventListener implements TransactionEventListener<CreatedEntitiesCounter>
{
@Override
public CreatedEntitiesCounter beforeCommit( TransactionData data, Transaction transaction, GraphDatabaseService databaseService ) throws Exception
{
return new CreatedEntitiesCounter( size( data.createdNodes() ), size( data.createdRelationships() ) );
}
@Override
public void afterCommit( TransactionData data, CreatedEntitiesCounter entitiesCounter, GraphDatabaseService databaseService )
{
System.out.println( "Number of created nodes: " + entitiesCounter.getCreatedNodes() );
System.out.println( "Number of created relationships: " + entitiesCounter.getCreatedRelationships() );
}
@Override
public void afterRollback( TransactionData data, CreatedEntitiesCounter state, GraphDatabaseService databaseService )
{
}
}
private static class CreatedEntitiesCounter
{
private final long createdNodes;
private final long createdRelationships;
public CreatedEntitiesCounter( long createdNodes, long createdRelationships )
{
this.createdNodes = createdNodes;
this.createdRelationships = createdRelationships;
}
public long getCreatedNodes()
{
return createdNodes;
}
public long getCreatedRelationships()
{
return createdRelationships;
}
}