Spring事务最佳实践

介绍

在本文中,我将向您展示各种 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只读节点,而不是[主节点]

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容