Spring事务最佳实践 - Vlad


在本文中,我将向您展示各种 Spring Transaction事务最佳实践,它们可以帮助您实现底层业务需求所需的数据完整性保证。
数据完整性至关重要,因为如果没有适当的事务处理,您的应用程序可能容易受到可能对底层业务产生可怕后果的竞争条件的影响。

模拟 Flexcoin 转账竞争条件
Flexcoin因为竞争条件而破产的,一些黑客利用这种竞争条件窃取了 Flexcoin 可用的所有 BTC 资金。

我们以这现实生活中的转账问题作为示例,说明在构建基于 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注解来标记读写事务方法,并且,如果没有事务已经启动并传播到该方法调用,那么读写事务上下文将是为此方法执行创建。


失败的原子性
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方法不是在单个数据库事务的上下文中执行的。

由于我们忘记添加@Transactional到该transfer方法,Spring 不会在调用此方法之前启动事务上下文,因此,我们最终将运行三个连续的数据库事务:

  • 一个用于getBalance选择 Alice 帐户余额的方法调用
  • addBalance用于从爱丽丝账户中扣款的第一个调用
  • 另一个用于第二次addBalance调用,记入 Bob 的帐户

AccountRepository方法之所以以事务方式执行是由于我们添加到addBalance方法的注释@Transactional。

服务层的主要目标是定义给定工作单元的事务边界。

如果服务要调用多个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会认为账户余额是它之前读取的余额,而实际上,余额已经被另一笔成功提交的交易改变了。
为了防止丢失更新异常,我们可以尝试多种解决方案:

  • 我们可以使用乐观锁定,
  • 我们可以通过使用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 注解背后的魔力
当从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,事务将被回滚。否则,如果一切顺利,事务就被提交。

结论
在开发一个重要的应用程序时,了解 Spring 事务的工作方式非常重要。首先,您需要确保围绕逻辑工作单元正确声明事务边界。

其次,您必须知道何时使用默认隔离级别以及何时使用更高的隔离级别。