每个Java程序员都犯过的Spring事务@Transactional错误 - Kozhenkov


可能最常用的 Spring 注释之一是@Transactional。尽管它很受欢迎,但它有时会被误用,从而导致一些不是软件工程师想要的东西。
在这篇文章中,我收集了我个人在项目中遇到的问题。我希望这份清单能帮助您更好地了解交易并帮助解决一些问题。
 
1. 同一个类中的调用

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

    notificationSrvc.sendVerificationEmail(acc);
}

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

在这种情况下,调用 时registerAccount(),

createAccount中的
保存用户和创建团队两行代码将不会在事务中执行。
@Transactional由面向方面的编程提供支持。因此,当从另一个 bean 调用一个 bean 时,才会发生处理。在上面的示例中,该方法是从同一类调用的,因此无法应用代理。对于其他注释(例如@Cacheable )也是如此。
问题可以通过三种基本方式解决:
  1. 自注射
  2. 创建另一个抽象层
  3. 通过包装调用在方法中使用TransactionTemplateregisterAccount()createAccount()

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

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

        notificationSrvc.sendVerificationEmail(acc);
    }

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

 
2. 并非处理所有异常
默认情况下,回滚仅发生在RuntimeExceptionError 上。同时,代码中可能包含受检异常,其中也需要回滚事务。
@Transactional(rollbackFor = StripeException.class)
public void createBillingAccount(Account acc) throws StripeException {
    accSrvc.createAccount(acc);

    stripeHelper.createFreeTrial(acc);
}

 
3. 事务隔离级别和传播
通常,开发人员添加注释时并没有真正考虑他们想要实现什么样的行为。几乎总是使用默认的隔离级别READ_COMMITED。
了解隔离级别对于避免以后很难调试的错误至关重要。
例如,如果您生成报告,您可以通过在事务期间多次执行相同的查询来选择默认隔离级别的不同数据。当并行事务此时提交某些内容时,就会发生这种情况。使用REPEATABLE_READ将有助于避免此类情况并节省大量故障排除时间。
不同的传播有助于在我们的业务逻辑中绑定事务。例如,如果您需要在另一个事务中而不是在外部事务中运行某些代码,则可以使用REQUIRES_NEW暂停外部事务、创建新事务然后恢复外部事务的传播。
  
4. 事务中是不锁定数据的
@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作为第一个实例同时调用。因此,该方法将在两个实例中返回相同的数据,并且数据将被处理 2 次。
有两种方法可以避免这个问题。
  • 使用SQL语句悲观锁定

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为我们的实体添加一列。因此,只有当数据库中实体的版本与应用程序中的版本匹配时,我们才能选择数据然后更新它。在使用JPA的情况下,您可以使用@Version注释。
 
5. 两种不同的数据来源
例如,我们创建了一个新版本的数据存储,但仍然需要维护旧版本一段时间。
@Transactional
public void saveAccount(Account acc) {
    dataSource1Repo.save(acc);
    dataSource2Repo.save(acc);
}

当然,在这种情况下,只有一个save会被事务处理,即在被认为是默认的TransactionalManager中。
Spring在这里提供了两个选项。

  • 链式事务管理器

ChainedTransactionManager 是一种声明多个数据源的方式,在异常情况下,会以相反的顺序进行回滚。因此,对于三个数据源,如果在第二个提交期间发生错误,则只有前两个会尝试回滚。第三个已经提交了更改。
  • 事务管理器

该管理器允许使用基于两阶段提交的完全支持的分布式事务。但是,它将管理委托给后端 JTA 提供程序。它可能是 Java EE 服务器或独立解决方案(AtomikosBitrionix等)。