51工具盒子

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

Spring 事务最佳实践

概览 {#概览}

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

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

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

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

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

TransferService 和 AccountRepository

使用最简单的 Dao 层实现来说明不按业务要求处理事务会发生什么情况:

@Repository
@Transactional(readOnly = true)
public interface AccountRepository extends JpaRepository<Account, Long> {
@Query(value = &quot;&quot;&quot;
    SELECT balance
    FROM account
    WHERE iban = :iban
    &quot;&quot;&quot;,
    nativeQuery = true)
long getBalance(@Param(&quot;iban&quot;) String iban);

@Query(value = &quot;&quot;&quot; UPDATE account SET balance = balance + :cents WHERE iban = :iban &quot;&quot;&quot;, nativeQuery = true) @Modifying @Transactional int addBalance(@Param(&quot;iban&quot;) String iban, @Param(&quot;cents&quot;) 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 &amp;gt;= cents) {
    status &amp;amp;= accountRepository.addBalance(
        fromIban, (-1) * cents
    ) &amp;gt; 0;
     
    status &amp;amp;= accountRepository.addBalance(
        toIban, cents
    ) &amp;gt; 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(&quot;Alice-123&quot;));
assertEquals(0L, accountRepository.getBalance(&quot;Bob-456&quot;));

CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(threadCount);

for (int i = 0; i &lt; threadCount; i++) { new Thread(() -&gt; { try { startLatch.await();

        transferService.transfer(
            &amp;quot;Alice-123&amp;quot;, &amp;quot;Bob-456&amp;quot;, 5L
        );
    } catch (Exception e) {
        LOGGER.error(&amp;quot;Transfer failed&amp;quot;, e);
    } finally {
        endLatch.countDown();
    }
}).start();

} startLatch.countDown(); endLatch.await();

LOGGER.info( &quot;Alice's balance {}&quot;, accountRepository.getBalance(&quot;Alice-123&quot;) ); LOGGER.info( &quot;Bob's balance {}&quot;, accountRepository.getBalance(&quot;Bob-456&quot;) );

}

此时,账户余额记录如下:

  • 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 &gt;= cents) { status &amp;= accountRepository.addBalance( fromIban, (-1) * cents ) &gt; 0;

status &amp;amp;= accountRepository.addBalance(
    toIban, cents
) &amp;gt; 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 &gt;= cents) { status &amp;= accountRepository.addBalance( fromIban, (-1) * cents ) &gt; 0;

status &amp;amp;= accountRepository.addBalance(
    toIban, cents
) &amp;gt; 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/

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