51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

Spring 事务最佳实践

概览 {#概览}

本文将带你了解各种 Spring 事务的最佳实践,以保证底层业务的数据完整性。

数据完整性至关重要。如果没有适当的事务处理,应用就很容易出现竞赛条件,从而给底层业务带来可怕的后果。

模拟竞赛条件 {#模拟竞赛条件}

以一个实际问题为例,说明在构建基于 Spring 的应用时,应该如何处理事务。

使用以下 Service 层和 Dao 层组件来实现转账服务:

TransferService 和 AccountRepository

使用最简单的 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);
}

getBalanceaddBalance 方法都使用 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;
    }
}

考虑有两个用户,AliceBob

| 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

因此,即使以原子方式进行读写操作,问题也依然存在。

这里的问题是由 "丢失更新" 异常引起的,OracleSQL ServerPostgreSQLMySQL 的默认隔离级别都无法解决这种异常:

丢失更新

虽然多个并发用户可以读取 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 TransactionInterceptor 调用栈

虽然 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/

赞(3)
未经允许不得转载:工具盒子 » Spring 事务最佳实践