[翻译] Spring注解@Transactional常见问题

1,234 阅读5分钟

原文地址: Spring @Transactional Mistakes Everyone Makes

作者: Aleksandr Kozhenkov

日期: 2021-09-28

@Transactional 可能是Spring最常用的注解之一。尽管如此普及,但有时也会出现滥用,导致出现某些不符合程序员预期的结果。

在这篇文章,我收集了一些自己在项目中遇到的问题。希望其中的问题列表可以帮助你更加理解事务,同时解决遇到的一些问题。

在同一个类中调用

@Transactional 注解很少有足够的测试案例覆盖,这就意味着在第一眼很难发现问题。因此,可能会遇到下面这样的代码:

public void registerAccount(Account acc) {
    createAccount(acc);

    notificationSrvc.sendVerificationEmail(acc);
}

@Transactional
public void createAccount(Account acc) {
    accRepo.save(acc);
    teamRepo.createPersonalTeam(acc);
}

这个案例中,当调用registerAccount()时,保存用户和创建团队没有在一个通用事务中执行。@Transactional 基于Aspect-Oriented Programming。因此,在一个对象调另一个对象的时候才会发生事务处理。在上面的例子中,方法在相同的类中被调用所以代理不能被使用。其它注解比如 @Cacheable 也是如此。

这个问题可以用以下3种方法解决:

  1. 自身注入
  2. 创建另一个抽象层
  3. registerAccount()方法中使用 TransactionTemplate 包裹createAccount()方法调用

第一个方法虽然看上去不是很清晰,但如果 @Transactional 包含参数使用这个方法可以避免重复的逻辑。

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accRepo;
    private final TeamRepository teamRepo;
    private final NotificationService notificationSrvc;
    @Lazy private final AccountService self;

    public void registerAccount(Account acc) {
        self.createAccount(acc);

        notificationSrvc.sendVerificationEmail(acc);
    }

    @Transactional
    public void createAccount(Account acc) {
        accRepo.save(acc);
        teamRepo.createPersonalTeam(acc);
    }
}

如果使用了Lombok,记得添加 @Lazy 到 lombok.config

处理异常

默认情况下,回滚只会出现在RuntimeException或Error的情况下。同时,代码可能包含编译时异常,这时也需要回滚事务。

@Transactional(rollbackFor = StripeException.class)
public void createBillingAccount(Account acc) throws StripeException {
    accSrvc.createAccount(acc);

    stripeHelper.createFreeTrial(acc);
}

事务的隔离级别与传播机制

通常,开发者添加注解的时候没有真正考虑过怎样的行为是他们想要达到的。几乎总是使用默认的隔离级别READ_COMMITED

理解隔离级别是必不可少的,避免后面出现难以调试的错误。

比如,如果你需要生成报告,你可能会在同一个事务中及默认的隔离级别下对不同的数据执行多次查找。此时可能会发生并行的事物提交。使用 REPEATABLE_READ 可以帮忙我们避免这种情况并且节省大量故障排查的时间。

不同的隔离级别可以帮助我们限制业务逻辑里的事务。比如,如果你需要再其他事务中执行一些代码并且不再外部事务中,你可以使用REQUIRES_NEW隔离级别,它可以挂起外部事务并创建一个新的事务,执行完后恢复外部事务。

事务不锁定数据

@Transactional
public List<Message> getAndUpdateStatuses(Status oldStatus, Status newStatus, int batchSize) {
    List<Message> messages = messageRepo.findAllByStatus(oldStatus, PageRequest.of(0, batchSize));
    
    messages.forEach(msg -> msg.setStatus(newStatus));

    return messageRepo.saveAll(messages);
}

有时类似这样的结构,先从数据库查询一些数据然后做更新,这些操作都在同一个交易中完成,在单个请求交易中执行这些代码具有原子性。

问题是不能阻止其它应用程序实例像第一个实力一样同时调用findAllByStatus方法。因此同样的方法会在两个实力中返回同样的数据,导致数据会被处理两次。

有以下两种方法避免这个问题。

Select for Update(悲观锁)

UPDATE message
SET status = :newStatus
WHERE id in (
   SELECT m.id FROM message m WHERE m.status = :oldStatus LIMIT :limit
   FOR UPDATE SKIP LOCKED)
RETURNING *

在上面的例子中,当select执行后,数据行会被锁定直到更新完成。查询会返回所有修改的行。

实体版本(乐观锁)

这个方法可以避免锁定数据。办法是添加一个version列到我们的实体中。于是我们可以查询数据然后更新数据库version与应用程序version匹配的数据。如果使用JPA,你可以使用 @Version 注解。

两个不同的数据源

比如,我们创建了一个新版本的数据存储,但仍然需要保存旧的数据一段时间。

@Transactional
public void saveAccount(Account acc) {
    dataSource1Repo.save(acc);
    dataSource2Repo.save(acc);
}

当然,在这种情况下,只有一次save会在事务中处理,也就是说在TransactionalManager中为默认的处理情况。

Spring提供了另外两种选择。

ChainedTransactionManager(已过时)

1st TX Platform: begin
  2nd TX Platform: begin
    3rd Tx Platform: begin
    3rd Tx Platform: commit
  2nd TX Platform: commit <-- fail
  2nd TX Platform: rollback  
1st TX Platform: rollback

ChainedTransactionManager是一种定义多数据源的方式,其中在出现异常的情况下,将以相反的顺序回滚。因此当有3个数据源时,如果有一个错误出现在第二个commit,只有第一个和第二个数据源会尝试回滚。第三个数据源已经提交了修改。

JtaTransactionManager

这个管理器允许使用完全支持的分布式事务,基于两阶段提交。然而,它会委托给后端的JTA提供者进行管理。可以作为Java EE服务或独立的解决方案。

总结

事务是一个棘手的话题,经常会遇到问题。很多时候,测试没有完全覆盖,所以大部分问题只能在code review中发现。如果在生产环境发生事故,寻找问题始终是一个挑战。