展示各种Spring事务最佳实践

351 阅读7分钟

简介

在这篇文章中,我将向你展示各种Spring事务最佳实践,它们可以帮助你实现基础业务需求所要求的数据完整性保证。

数据完整性是最重要的,因为如果没有正确的事务处理,你的应用程序可能会受到竞赛条件的影响,这可能会对底层业务产生可怕的后果。

仿真Flexcoin的竞赛条件

我们之前的实现是使用普通的JDBC构建的,但是我们可以使用Spring来模拟同样的场景,这对于绝大多数的Java开发者来说无疑是更熟悉的。这样一来,我们要用一个现实生活中的问题作为例子,说明我们在构建基于Spring的应用程序时应该如何处理交易。

因此,我们将使用以下服务层和数据访问层组件来实现我们的传输服务。

TransferService and AccountRepositoryTransferService and AccountRepository

为了证明不按照业务需求处理事务会发生什么,让我们使用最简单的数据访问层实现。

@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);
}

getBalanceaddBalance 方法都使用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的默认隔离级别并没有阻止这种异常。

Lost Update AnomalyLost Update Anomaly

当多个并发用户可以读取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 集成测试中调用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 TransactionInterceptorSpring TransactionInterceptor

虽然这个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 ,事务就会回滚。否则,如果一切顺利,事务将被提交。