美文网首页
Spring事务最佳实践

Spring事务最佳实践

作者: 邢_3941 | 来源:发表于2022-06-24 09:19 被阅读0次

    介绍

    在本文中,我将向您展示各种 Spring Transaction 最佳实践,它们可以帮助您实现底层业务需求所需的数据完整性保证。

    数据完整性至关重要,因为如果没有适当的事务处理,您的应用程序可能容易受到可能对底层业务产生可怕后果的[竞争条件的影响。]

    模拟 Flexcoin 竞争条件

    在[本文中],我解释了 Flexcoin 是如何因为竞争条件而破产的,一些黑客利用这种竞争条件窃取了 Flexcoin 可用的所有 BTC 资金。

    我们之前的实现是使用纯 JDBC 构建的,但我们可以使用 Spring 模拟相同的场景,这对于绝大多数 Java 开发人员来说肯定更熟悉。这样,我们将使用现实生活中的问题作为示例,说明在构建基于 Spring 的应用程序时应该如何处理事务。

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

    image.png

    为了演示当事务没有根据业务需求处理时会发生什么,让我们使用最简单的数据访问层实现:

    @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注释的更多详细信息,也请查看这篇文章

    妥协的原子性

    AfromACID代表原子性,它允许事务将数据库从一个一致状态移动到另一个一致状态。因此,原子性允许我们在同一个数据库事务的上下文中注册多个语句。

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

    由于我们忘记添加@Transactionaltransfer方法,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 的默认隔离级别无法阻止该异常:


    image.png

    虽然多个并发用户可以读取账户余额5,但只有第一个用户UPDATE会将余额从 更改50。第二个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 CommittedRepeatable 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 注解背后的魔力

    transfertestParallelExecution集成测试调用方法时,堆栈跟踪如下所示:

    "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类:

    image.png

    虽然这个 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只读节点,而不是[主节点]

    相关文章

      网友评论

          本文标题:Spring事务最佳实践

          本文链接:https://www.haomeiwen.com/subject/emsvvrtx.html