@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 由面向切面编程提供支持。因此,当从一个 Bean 调用另一个 Bean 时,就会发生处理。在上面的示例中,该方法是从同一个类调用的,因此不能应用代理。对于其他注释(例如@Cacheable )也是如此。
该问题可以通过三种基本方式解决:
自注入
创建另一个类
在registerAccount()调用TransactionTemplate.createAccount(),在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);
}
}
默认并非所有异常都会回滚
默认情况下,仅在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);
}
正如上面代码,有时我们需要查询出数据并更新,这些都是在一个事务中完成的,并且事务具有原子性,因此这段代码是作为单个请求执行的。
问题是如果是分布式部署,可能有两台服务同时调用getAndUpdateStatuses。这会导致该方法将在两个实例中返回相同的数据,并且数据将被处理两次。
有两种方法可以避免这个问题
1、悲观锁
UPDATE message
SET status = :newStatus
WHERE id IN (
SELECT m.id
FROM (
SELECT id
FROM message
WHERE status = :oldStatus
LIMIT :limit
FOR UPDATE SKIP LOCKED
) AS m
)
在上面的示例中,当执行更新时,行将被阻塞,直到更新结束。该查询返回所有已更改的行。
2、乐观锁
这种方式能避免阻塞。这个想法是向我们的实体类添加一列version。因此,只有当数据库中实体的版本与应用程序中的版本匹配时,我们才可以选择数据并更新它。在使用JPA的情况下,可以使用@Version注解
两种不同的数据源
@Transactional
public void saveAccount(Account acc) {
dataSource1Repo.save(acc);
dataSource2Repo.save(acc);
}
当然,在这种情况下,只有一个save会被事务处理,就是在TransactionalManager中默认的那个数据源。
Spring在这里提供ChainedTransactionManager解决
链式事务管理器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 是一种声明多个数据源的方式,当出现异常时,会按照相反的顺序进行回滚。因此,对于三个数据源,如果在第二个数据源的提交期间发生错误,则只有前两个数据源会尝试回滚。第三个会提交更改。
结论
事务是一个棘手的话题,而且经常会出现问题。大多数时候,它们并没有被测试完全覆盖,因此大多数错误只能在代码审查中才能注意到。如果生产中发生事故,找到根本原因始终是是一个很大挑战。
评论区