美文网首页
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