介绍
在本文中,我将向您展示各种 Spring Transaction 最佳实践,它们可以帮助您实现底层业务需求所需的数据完整性保证。
数据完整性至关重要,因为如果没有适当的事务处理,您的应用程序可能容易受到可能对底层业务产生可怕后果的[竞争条件的影响。]
模拟 Flexcoin 竞争条件
在[本文中],我解释了 Flexcoin 是如何因为竞争条件而破产的,一些黑客利用这种竞争条件窃取了 Flexcoin 可用的所有 BTC 资金。
我们之前的实现是使用纯 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
将在只读事务的上下文中执行,除非现有的读写事务已经与当前处理的执行线程相关联。但是,当我们想改变数据库状态时,我们可以使用
@Transactional
注解来标记读写事务方法,并且,如果没有事务已经启动并传播到该方法调用,那么读写事务上下文将是为此方法执行创建。有关
@Transactional
注释的更多详细信息,也请查看这篇文章。
妥协的原子性
A
fromACID
代表原子性,它允许事务将数据库从一个一致状态移动到另一个一致状态。因此,原子性允许我们在同一个数据库事务的上下文中注册多个语句。
在 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
方法不是在单个数据库事务的上下文中执行的。
由于我们忘记添加@Transactional
该transfer
方法,Spring 不会在调用此方法之前启动事务上下文,因此,我们最终将运行三个连续的数据库事务:
* 一个用于`getBalance`选择 Alice 帐户余额的方法调用
* `addBalance`用于从爱丽丝账户中扣款的第一个电话
* 另一个用于第二次`addBalance`通话,记入 Bob 的帐户
方法之所以以AccountRepository
事务方式执行是由于@Transactional
我们添加到类和addBalance
方法定义中的注释。
服务层的主要目标是定义给定工作单元的事务边界。
如果服务要调用多个
Repository
方法,那么拥有跨越整个工作单元的单个事务上下文非常重要。
依赖交易默认值
@Transactional
因此,让我们通过向方法添加注释来解决第一个问题transfer
:
@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
会认为账户余额是它之前读取的余额,而实际上,余额已经被另一笔成功提交的交易改变了。
为了防止丢失更新异常,我们可以尝试多种解决方案:
- 我们可以使用乐观锁定,如[本文所述]
- 我们可以通过使用
FOR UPDATE
指令锁定 Alice 的帐户记录来使用悲观锁定方法,如[本文所述] - 我们可以使用更严格的隔离级别
根据底层关系数据库系统,这就是如何使用更高的隔离级别来防止丢失更新异常:
| 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 注解背后的魔力
transfer
从testParallelExecution
集成测试调用方法时,堆栈跟踪如下所示:
"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 处理事务上下文的方式:
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
抛出 a,则事务回滚。否则,如果一切顺利,则提交事务。
结论
在开发一个重要的应用程序时,了解 Spring 事务的工作方式非常重要。首先,您需要确保围绕逻辑工作单元正确声明事务边界。
其次,您必须知道何时使用默认隔离级别以及何时使用更高的隔离级别。
根据该标志,您甚至可以将事务路由到连接到副本节点的read-only
只读节点,而不是[主节点]