简介
在这篇文章中,我将向你展示各种Spring事务最佳实践,它们可以帮助你实现基础业务需求所要求的数据完整性保证。
数据完整性是最重要的,因为如果没有正确的事务处理,你的应用程序可能会受到竞赛条件的影响,这可能会对底层业务产生可怕的后果。
仿真Flexcoin的竞赛条件
我们之前的实现是使用普通的JDBC构建的,但是我们可以使用Spring来模拟同样的场景,这对于绝大多数的Java开发者来说无疑是更熟悉的。这样一来,我们要用一个现实生活中的问题作为例子,说明我们在构建基于Spring的应用程序时应该如何处理交易。
因此,我们将使用以下服务层和数据访问层组件来实现我们的传输服务。


为了证明不按照业务需求处理事务会发生什么,让我们使用最简单的数据访问层实现。
@Repository
@Transactional(readOnly = true)
public interface AccountRepository extends JpaRepository<Account, Long> {
@Query(value = """
SELECT balance
FROM account
WHERE iban = :iban
""",
nativeQuery = true)
long getBalance(@Param("iban") String iban);
@Query(value = """
UPDATE account
SET balance = balance + :cents
WHERE iban = :iban
""",
nativeQuery = true)
@Modifying
@Transactional
int addBalance(@Param("iban") String iban, @Param("cents") long cents);
}
getBalance 和addBalance 方法都使用Spring@Query 注解来定义可以读取或写入指定账户余额的本地SQL查询。
因为读操作比写操作多,所以在每个类的层面上定义
@Transactional(readOnly = true)注解是很好的做法。这样,默认情况下,没有用
@Transactional注释的方法将在只读事务的背景下执行,除非现有的读写事务已经与当前执行的处理Thread相关联。然而,当我们想要改变数据库状态时,我们可以使用
@Transactional注解来标记读写事务性方法,在没有事务已经开始并传播到这个方法调用的情况下,将为这个方法的执行创建一个读写事务上下文。
破坏原子性
A 来自 ,代表Atomicity,它允许事务将数据库从一个一致的状态转移到另一个一致的状态。因此,Atomicity允许我们在同一个数据库事务的背景下注册多个语句。ACID
在Spring中,这可以通过@Transactional 注解来实现,所有应该与关系型数据库交互的公共服务层方法都应该使用该注解。
如果你忘了这么做,业务方法可能会跨越多个数据库事务,从而影响到原子性。
例如,让我们假设我们这样实现transfer 方法。
@Service
public class TransferServiceImpl implements TransferService {
@Autowired
private AccountRepository accountRepository;
@Override
public boolean transfer(
String fromIban, String toIban, long cents) {
boolean status = true;
long fromBalance = accountRepository.getBalance(fromIban);
if(fromBalance >= cents) {
status &= accountRepository.addBalance(
fromIban, (-1) * cents
) > 0;
status &= accountRepository.addBalance(
toIban, cents
) > 0;
}
return status;
}
}
考虑到我们有两个用户,Alice和Bob。
| iban | balance | owner |
|-----------|---------|-------|
| Alice-123 | 10 | Alice |
| Bob-456 | 0 | Bob |
当运行并行执行的测试案例时。
@Test
public void testParallelExecution()
throws InterruptedException {
assertEquals(10L, accountRepository.getBalance("Alice-123"));
assertEquals(0L, accountRepository.getBalance("Bob-456"));
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
startLatch.await();
transferService.transfer(
"Alice-123", "Bob-456", 5L
);
} catch (Exception e) {
LOGGER.error("Transfer failed", e);
} finally {
endLatch.countDown();
}
}).start();
}
startLatch.countDown();
endLatch.await();
LOGGER.info(
"Alice's balance {}",
accountRepository.getBalance("Alice-123")
);
LOGGER.info(
"Bob's balance {}",
accountRepository.getBalance("Bob-456")
);
}
我们会得到以下账户余额日志条目。
Alice's balance: -5
Bob's balance: 15
所以,我们有麻烦了!鲍勃成功地得到了比爱丽丝原本在她账户中的钱更多的钱。
我们得到这个竞赛条件的原因是,transfer 方法不是在单个数据库事务的背景下执行的。
由于我们忘记了在transfer 方法中添加@Transactional ,Spring在调用这个方法之前不会启动一个事务上下文,由于这个原因,我们最终会连续运行三个数据库事务。
- 一个是选择Alice账户余额的
getBalance方法调用 - 一个是第一次调用
addBalance,从Alice的账户中扣款 - 另一个是为第二个
addBalance调用,该调用为Bob的账户充值。
之所以AccountRepository 方法是以事务方式执行的,是因为我们在类中添加了@Transactional 注释和addBalance 方法定义。
服务层的主要目标是定义特定工作单元的事务边界。
如果服务要调用几个
Repository方法,那么有一个横跨整个工作单元的单一事务上下文是非常重要的。
依赖于事务默认值
因此,让我们通过向transfer 方法添加@Transactional 注释来解决第一个问题。
@Transactional
public boolean transfer(
String fromIban, String toIban, long cents) {
boolean status = true;
long fromBalance = accountRepository.getBalance(fromIban);
if(fromBalance >= cents) {
status &= accountRepository.addBalance(
fromIban, (-1) * cents
) > 0;
status &= accountRepository.addBalance(
toIban, cents
) > 0;
}
return status;
}
现在,当重新运行testParallelExecution 测试案例时,我们将得到以下结果。
Alice's balance: -50
Bob's balance: 60
所以,即使读写操作是以原子方式进行,问题也没有得到解决。
我们这里的问题是由丢失更新异常引起的,Oracle、SQL Server、PostgreSQL或MySQL的默认隔离级别并没有阻止这种异常。


当多个并发用户可以读取5 的账户余额时,只有第一个UPDATE 会将余额从5 改为0 。第二个UPDATE 会认为账户余额是它之前读取的那个,而实际上,余额已经被另一个设法提交的事务所改变。
为了防止丢失更新的异常现象,我们可以尝试各种解决方案。
根据底层的关系数据库系统,这就是如何使用更高的隔离级别来防止丢失更新的异常现象。
| Isolation Level | Oracle | SQL Server | PostgreSQL | MySQL |
|-----------------|--------|------------|------------|-------|
| Read Committed | Yes | Yes | Yes | Yes |
| Repeatable Read | N/A | No | No | Yes |
| Serializable | No | No | No | No |
由于我们在Spring例子中使用的是PostgreSQL,让我们把隔离级别从默认的Read Committed 改为Repeatable Read 。
正如我在这篇文章中所解释的,你可以在@Transactional 注释层设置隔离级别。
@Transactional(isolation = Isolation.REPEATABLE_READ)
public boolean transfer(
String fromIban, String toIban, long cents) {
boolean status = true;
long fromBalance = accountRepository.getBalance(fromIban);
if(fromBalance >= cents) {
status &= accountRepository.addBalance(
fromIban, (-1) * cents
) > 0;
status &= accountRepository.addBalance(
toIban, cents
) > 0;
}
return status;
}
而且,在运行testParallelExecution 集成测试时,我们将看到,丢失更新的异常情况将被阻止。
Alice's balance: 0
Bob's balance: 10
虽然默认隔离级别在很多情况下都没有问题,但这并不意味着你应该在任何可能的用例中专门使用它。
如果一个给定的业务用例需要严格的数据完整性保证,那么你可以使用更高的隔离级别或更复杂的并发控制策略,比如乐观的锁定机制。
Spring @Transactional注解背后的魔力
当从testParallelExecution 集成测试中调用transfer 方法时,堆栈跟踪的情况是这样的。
"Thread-2"@8,005 in group "main": RUNNING
transfer:23, TransferServiceImpl
invoke0:-1, NativeMethodAccessorImpl
invoke:77, NativeMethodAccessorImpl
invoke:43, DelegatingMethodAccessorImpl
invoke:568, Method {java.lang.reflect}
invokeJoinpointUsingReflection:344, AopUtils
invokeJoinpoint:198, ReflectiveMethodInvocation
proceed:163, ReflectiveMethodInvocation
proceedWithInvocation:123, TransactionInterceptor$1
invokeWithinTransaction:388, TransactionAspectSupport
invoke:119, TransactionInterceptor
proceed:186, ReflectiveMethodInvocation
invoke:215, JdkDynamicAopProxy
transfer:-1, $Proxy82 {jdk.proxy2}
lambda$testParallelExecution$1:121
在调用transfer 方法之前,有一连串的AOP(Aspect-Oriented Programming)Aspects被执行,对我们来说最重要的是TransactionInterceptor ,它扩展了TransactionAspectSupport 类。


虽然这个Spring Aspect的入口点是TransactionInterceptor ,但最重要的动作发生在它的基类,TransactionAspectSupport 。
例如,Spring是这样处理事务性上下文的。
protected Object invokeWithinTransaction(
Method method,
@Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = tas != null ?
tas.getTransactionAttribute(method, targetClass) :
null;
final TransactionManager tm = determineTransactionManager(txAttr);
...
PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
final String joinpointIdentification = methodIdentification(
method,
targetClass,
txAttr
);
TransactionInfo txInfo = createTransactionIfNecessary(
ptm,
txAttr,
joinpointIdentification
);
Object retVal;
try {
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo);
...
return retVal;
}
服务方法的调用被invokeWithinTransaction 方法所包裹,该方法启动了一个新的交易上下文,除非一个交易上下文已经被启动并传播到这个交易方法。
如果抛出一个RuntimeException ,事务就会回滚。否则,如果一切顺利,事务将被提交。