概览 {#概览}
本文将带你了解各种 Spring 事务的最佳实践,以保证底层业务的数据完整性。
数据完整性至关重要。如果没有适当的事务处理,应用就很容易出现竞赛条件,从而给底层业务带来可怕的后果。
模拟竞赛条件 {#模拟竞赛条件}
以一个实际问题为例,说明在构建基于 Spring 的应用时,应该如何处理事务。
使用以下 Service 层和 Dao 层组件来实现转账服务:
使用最简单的 Dao 层实现来说明不按业务要求处理事务会发生什么情况:
@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
的方法将在只读事务的上下文中执行,除非已有的读写事务已经与当前执行的处理线程相关联。当要改变数据库状态时,可以使用
@Transactional
注解来标记读写事务方法,如果没有事务已经启动并传播到此方法调用中,则会为此方法的执行创建一个读写事务上下文。
牺牲原子性 {#牺牲原子性}
ACID 中的 A 代表原子性(Atomicity),它允许一个事务将数据库从一个一致状态移动到另一个一致状态。因此,原子性允许在同一个数据库事务中注册多个语句。
在 Spring 中,这可以通过 @Transactional
注解来实现,所有要与关系数据库交互的公共 Service 层方法都应使用该注解。
如果忘记了这一点,业务方法可能会跨越多个数据库事务,从而影响原子性。
例如,假设这样实现 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:-5
- Bob:15
所以,问题大了!Bob 成功地拿到了比 Alice 账户上原来更多的钱。
出现这种竞赛条件的原因是,transfer
方法不是在单个数据库事务的上下文中执行的。
由于忘记在 transfer
方法中添加 @Transactional
,Spring 不会在调用此方法前启动事务上下文,因此,最终会连续运行三个数据库事务:
- 一个用于调用
getBalance
方法,该方法select
Alice 的账户余额 - 一个用于第一个
addBalance
调用,从 Alice 的账户中扣款 - 另一个是第二个
addBalance
调用,加款到 Bob 的账户中
AccountRepository
方法之所以以事务方式执行,是因为在类和 addBalance
方法定义中添加了 @Transactional
注解。
Service 层的主要目标是定义特定工作单元的事务边界。
如果 Service 要调用多个 Repository 方法,那么在整个工作单元中使用单个事务上下文就非常重要。
依赖 @Transactional 的默认值 {#依赖-transactional-的默认值}
通过在 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:-50
- Bob:60
因此,即使以原子方式进行读写操作,问题也依然存在。
这里的问题是由 "丢失更新" 异常引起的,Oracle 、SQL Server 、PostgreSQL 或 MySQL 的默认隔离级别都无法解决这种异常:
虽然多个并发用户可以读取 5 的账户余额,但只有第一个 UPDATE
会将余额从 5 改为 0 ,第二个 UPDATE
会认为账户余额是它之前读取的余额,而实际上余额已被成功提交的其他事务更改。
要防止 "丢失更新" 异常,有多种解决方案:
- 使用乐观锁(版本号)
- 使用悲观锁,即使用
FOR UPDATE
指令锁定 Alice 的账户记录 - 使用更严格的隔离级别
根据底层的关系数据库系统,可以使用更高的隔离级别来防止 "丢失更新"(Lost Update)异常,具体如下:
| 隔离级别 | Oracle | SQL Server | PostgreSQL | MySQL | |-----------------------|-----------|------------|------------|-----------| | Read Committed(读已提交) | Allowed | Allowed | Allowed | Allowed | | Repeatable Read(可重复读) | N/A | Prevented | Prevented | Allowed | | Serializable(序列化) | Prevented | Prevented | Prevented | Prevented |
本例中使用的是 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:0
- Bob:10
虽然默认隔离级别在很多情况下都没有问题,但这并不意味着你应该在任何可能的用例中都使用默认隔离级别。
如果给定的业务用例需要严格的数据完整性保证,那么可以使用更高的隔离级别或更复杂的并发控制策略,如乐观锁机制。
Spring @Transactional 注解的背后 {#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(面向切面的编程)切面会被执行,其中最重要的是 TransactionInterceptor
,它继承了 TransactionAspectSupport
类:
虽然 Spring Aspect 的入口点是 TransactionInterceptor
,但最重要的操作都发生在它的父类 TransactionAspectSupport
中。
例如,Spring 就是这样处理事务上下文(Transactional Context)的:
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;
}
Service方法调用由 invokeWithinTransaction
方法封装,该方法会启动一个新的事务上下文,除非该事务上下文已经启动并传播到此事务方法。
如果出现 RuntimeException
,事务就会回滚。否则,如果一切顺利,事务将被提交。
总结 {#总结}
在开发复杂应用时,了解 Spring 事务的工作原理非常重要。首先,需要确保在逻辑工作单元周围正确声明事务边界。
其次,你必须知道什么时候该使用默认隔离级别,什么时候该使用更高的隔离级别。
根据 read-only
属性,甚至可以将事务路由到数据库的从节点,以实现读写分离。具体的实现方式你可以参阅 这篇文章。
Ref:https://vladmihalcea.com/spring-transaction-best-practices/