做后端开发这些年,Spring 的事务隔离级别和事务传播机制是我面试别人时必问、也是自己团队里出问题最多的一块。你问十个人“REQUIRES_NEW 和 NESTED 有什么区别”,能立刻答清楚的可能不到一半。更别提线上真正出事的时候——库存扣成负数、订单重复下发、日志表里出现莫名其妙的数据——绝大多数都和这两块知识的理解偏差有关。
这篇文章不打算从“事务的四大特性”开始念 PPT,那些基础概念自己翻书就行。我重点想聊的是:四个隔离级别在真实数据库里到底怎么表现,七种传播机制在业务里分别用在什么场景,以及我最常遇到的几个“代码看着没问题但事务就是失效”的经典坑。最后用一个下单扣库存的组合案例,把隔离级别和传播机制串起来走一遍。如果你正在用 Spring Boot 做业务开发,或者准备面试想把这块彻底讲明白,这篇应该对你有用。
1. 事务隔离级别:四个级别怎么选,MySQL 和 Oracle 默认值差在哪
1.1 三种读现象是理解隔离级别的钥匙
很多人背隔离级别表背得很熟,但一问到“为什么需要隔离级别”就卡住了。说白了,多个事务同时在跑的时候,如果不加任何隔离措施,会出现三种读异常:
- 脏读:事务 A 修改了一条数据但还没提交,事务 B 读到了这个未提交的修改。然后 A 回滚了,B 手里拿的数据就成了不存在的脏数据。
- 不可重复读:事务 A 先读了一次某条记录,事务 B 把这条记录改了并提交,事务 A 再去读同一条记录,发现值变了。重点在于“同一条记录,两次读不一样”。
- 幻读:事务 A 按某个条件查出了 10 条记录,事务 B 插入了一条符合条件的新记录并提交,事务 A 再用同样的条件查询,发现变成了 11 条。重点在于“结果集的行数变了,像出现了幻觉”。
理解这三种现象后,四个隔离级别就很好记了。它们就是一层层往上加锁、加限制:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 典型实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 | 不加读锁 |
| READ COMMITTED(读已提交) | 避免 | 可能 | 可能 | 每次读都重新生成快照 |
| REPEATABLE READ(可重复读) | 避免 | 避免 | 可能(InnoDB 基本解决) | 第一次读生成快照,后续读快照 |
| SERIALIZABLE(串行化) | 避免 | 避免 | 避免 | 读写互斥,完全串行 |
这里要特别提醒一个很多文章讲错的点:MySQL 的 InnoDB 引擎在 REPEATABLE READ 级别下,通过 MVCC(多版本并发控制)加间隙锁,其实已经基本解决了幻读问题。所以 MySQL 官方文档里说它的 RR 级别相当于 SQL 标准里的“可重复读但还会幻读”,但 InnoDB 自己用 Next-Key Lock 把这个口子堵住了大半。具体后面讲案例时会展开。
1.2 MySQL 默认 RR,Oracle 默认 RC,这个差异影响很大
不同数据库的默认隔离级别不一样,这是最容易踩坑的地方。
- MySQL InnoDB 默认是 REPEATABLE READ。
- Oracle、PostgreSQL 默认是 READ COMMITTED。
- SQL Server 默认是 READ COMMITTED。
这意味着同一套业务代码,在 MySQL 上和在 Oracle 上跑,并发行为完全不一样。我见过一个项目从 Oracle 迁到 MySQL,原本跑得好好的报表查询,迁移后偶尔出现重复数据,排查半天才发现是默认隔离级别变了导致的。
实际业务中怎么选?我的经验是:互联网高并发场景,如果用的是 Oracle 或 PostgreSQL,保持默认的 READ COMMITTED 就够用;如果用的是 MySQL,保持默认 RR 也能接受,但你要清楚 RR 下事务持有锁的时间会更长,死锁概率会高一些。除非有非常明确的业务需求,否则不要轻易把隔离级别调成 SERIALIZABLE,那等于把所有并发事务排成队,性能会断崖式下跌。
如果你确实需要在某个方法上单独指定隔离级别,Spring 里很简单:
@Transactional(isolation = Isolation.READ_COMMITTED) public void updateStock(Long productId, Integer count) { // 业务代码 }1.3 快照读和当前读的区别,决定你看到的是旧数据还是新数据
说到隔离级别,必须把“快照读”和“当前读”讲清楚,这是很多面试题里藏着的坑。
在 InnoDB 里,普通的 SELECT 是快照读,它读的是某个时间点的一致性快照,不加锁。而 UPDATE、DELETE、INSERT,以及SELECT ... FOR UPDATE和SELECT ... LOCK IN SHARE MODE是当前读,读的是数据库里最新的已提交数据,并且会加锁。
这个区别在实际业务里非常要命。举个例子,两个事务同时扣库存:
-- 事务 A BEGIN; UPDATE stock SET count = count - 10 WHERE product_id = 100; -- 事务 B BEGIN; UPDATE stock SET count = count - 5 WHERE product_id = 100;如果事务 A 先执行了 UPDATE,它会对 product_id = 100 这一行加写锁。事务 B 的 UPDATE 会一直阻塞,直到 A 提交或回滚才继续。这就是当前读的锁机制在起作用,它保证了两个事务不会同时把同一条记录的库存扣错。
但如果你的代码是先 SELECT 查出库存,再在 Java 里判断够不够,最后再 UPDATE 扣减,这里就有竞争窗口了。两个请求可能同时查出库存是 10,都判断“够扣”,然后都去扣,结果库存变成负数。这种情况光靠隔离级别解决不了,得靠条件更新或悲观锁、乐观锁兜底。后面实战案例里我会专门演示。
2. 事务传播机制:七种行为到底是干什么的
2.1 先统一认知:传播机制解决的是“事务边界怎么嵌套”的问题
事务传播机制这个词听起来唬人,其实问的就一件事:方法 A 开启了事务,方法 A 调用方法 B,而方法 B 自己也标了@Transactional,那 B 到底是加入 A 的事务,还是自己单独开一个?反过来,如果 A 没有事务,B 该怎么办?
Spring 定义了七种传播行为,我用一张表把它们的核心逻辑列出来:
| 传播行为 | 当前有事务 | 当前无事务 | 典型场景 |
|---|---|---|---|
| REQUIRED | 加入当前事务 | 新建事务 | 默认值,90% 的业务方法用它 |
| SUPPORTS | 加入当前事务 | 以非事务方式执行 | 查询方法,有没有事务都行 |
| MANDATORY | 加入当前事务 | 抛异常 | 强制在事务内执行的方法 |
| REQUIRES_NEW | 挂起当前事务,新建事务 | 新建事务 | 日志、流水、消息发送等独立操作 |
| NOT_SUPPORTED | 挂起当前事务,非事务执行 | 非事务执行 | 大数据查询、报表导出 |
| NEVER | 抛异常 | 非事务执行 | 强制禁止事务的方法 |
| NESTED | 基于保存点,开启嵌套事务 | 新建事务 | 批量处理中单条回滚的场景 |
2.2 REQUIRED:最常用的默认策略
REQUIRED 是@Transactional的默认值,核心含义是“有则加入,无则新建”。它是事务设计里最符合直觉的一种:整个调用链共享同一个事务,任何一个环节抛出异常,所有已执行的操作全部回滚。
我举个实际场景。用户下单这个动作,通常涉及创建订单、扣减库存、写操作日志。这三个操作如果都是 REQUIRED,它们在同一个事务里。创建订单成功了,扣库存也成功了,但日志写失败了,那么订单和库存的变更也会一起回滚。这是合理的,因为一个完整的业务操作应该保持原子性。
REQUIRED 要注意的坑是:多个方法公用一个事务,意味着公共连接和锁被占用的时间,是整个调用链的总时长。如果链路里有一段特别慢,整个事务都会卡住,连接池很容易被拖垮。
2.3 REQUIRES_NEW:真正独立的新事务,别和 NESTED 搞混
REQUIRES_NEW 是歧义最多、也最容易用错的一个。它的含义是:不管当前有没有事务,都挂起当前事务,新开一个完全独立的事务。内部方法的事务提交或回滚,跟外部事务毫无关系。
经典使用场景是:
- 记录操作日志:主流程事务回滚了,但日志得留下来。
- 发送 MQ 消息:主流程回滚了,消息不能跟着撤销。
- 保存异常堆栈:整个业务失败了,失败原因必须能写进数据库,如果失败日志也回滚了,你就啥也查不到了。
这里必须强调一个最容易混淆的点:REQUIRES_NEW 和 NESTED 看起来都是“内部事务”,但本质完全不同。REQUIRES_NEW 是两个物理上的独立事务,内层提交后外层回滚,内层数据不会回滚;NESTED 是在同一个物理事务里打了一个保存点(Savepoint),内层回滚只回滚到保存点位置,外层后续代码还能继续跑,但如果外层最终回滚,内层数据也一样回滚。
用一句通俗的话区分:REQUIRES_NEW 是“分家的兄弟”,NESTED 是“记了账的父子”。
2.4 NESTED:批量处理时的救命稻草
NESTED 最适合的场景是批量导入数据。比如你在一个事务里循环导入 1000 条 Excel 记录,其中某几条校验不过,你希望跳过这几条,让剩下的继续导入,而不是全部回滚。
@Transactional public void batchImport(List<Record> records) { for (Record record : records) { try { importOne(record); // 这个方法标注 @Transactional(propagation = Propagation.NESTED) } catch (Exception e) { // 记录失败原因,继续下一条 } } }用 REQUIRED 的话,importOne 抛异常会导致整个 batchImport 回滚,前面的导入全部白干。用 REQUIRES_NEW 的话,importOne 成功了就直接提交,如果最后整体发现有问题,想撤销全部也已经来不及了。NESTED 则处在中间:importOne 失败只回滚它自己的保存点,不影响其他记录;整个 batchImport 最后回滚时,成功的那些子事务也会跟着回滚。
不过要注意,NESTED 需要底层数据库和事务管理器支持保存点。DataSourceTransactionManager和JpaTransactionManager都支持,JTA 全局事务下可能不支持,这点在选型时要心里有数。
2.5 关于传播机制的实操心得
我自己的经验是:能用 REQUIRED 解决的,坚决不升级到 REQUIRES_NEW。REQUIRES_NEW 看着方便,但它让“一致性”变得非常难以维护。一旦你开了独立事务,内层提交的数据在外层回滚后依然存在,这时候数据对账、排查问题都特别痛苦。业务上要先用“这个操作如果独立提交,会不会产生脏数据”来反问自己,确认能接受后再用。
另外有一个非常经典的坑:REQUIRES_NEW 在内部方法执行时,会从连接池再拿一个数据库连接。如果你的连接池最大连接数设置得特别小,或者外层事务已经占了唯一的连接,内层方法会一直等待拿连接,最后造成死锁般的阻塞。这个我在压测环境里踩过一次,线上连接池 maxActive 设成 10,结果一个批量任务里有 8 个 REQUIRES_NEW 子任务,直接把连接池打满,整个服务卡死。
3. 业务落地最容易踩的坑:事务失效与异常回滚陷阱
3.1 自调用导致事务注解失效
这是 Spring 事务里最经典、出现频率最高的坑。同一个类里的方法 A 调用方法 B,B 标了@Transactional,但 B 的事务根本不会生效。
@Service public class OrderService { public void createOrder(Order order) { // 处理订单 updateStock(order.getProductId()); // 事务注解在这里其实没生效 } @Transactional public void updateStock(Long productId) { // 扣减库存 } }原因很简单:@Transactional是通过 AOP 动态代理实现的,Spring 容器里注入的 OrderService 其实是一个代理对象。方法 A 调用方法 B 时,用的是this指向的真实对象,而不是代理对象,所以代理逻辑根本没机会介入,事务自然不生效。
解决方案有三种。第一种是把 updateStock 拆到另一个 Spring Bean 里;第二种是注入自身代理对象,比如通过@Lazy注入OrderService self,然后调用self.updateStock();第三种是改用编程式事务模板:
@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(TransactionTemplate transactionTemplate) { this.transactionTemplate = transactionTemplate; } public void createOrder(Order order) { transactionTemplate.execute(status -> { updateStock(order.getProductId()); return null; }); } }第三种方式最直观,也最容易排查问题,缺点是代码里会多一些模板代码。
3.2 异常被 try-catch 吞掉,事务永远不会回滚
这个坑也极其常见。很多人写代码时习惯在事务方法内部 catch 住异常,打条日志就算完了,但事务拦截器是通过捕获方法抛出的异常来决定是否回滚的。异常被你 catch 掉了,Spring 根本不知道出错了,事务照样提交。
@Transactional public void transfer(Account from, Account to, BigDecimal amount) { try { accountMapper.deduct(from, amount); accountMapper.add(to, amount); } catch (Exception e) { log.error("转账失败", e); // 异常被吞掉,事务不会回滚,from 的钱扣了,to 的钱没到 } }正确的做法是:如果这个异常需要触发回滚,必须在 catch 之后重新抛出。或者你确实想“先做点别的处理,但不影响主事务回滚”,那也得在最后把异常抛出去。
3.3 默认只回滚 RuntimeException 和 Error,受检异常不回滚
@Transactional默认只对 RuntimeException 和 Error 触发回滚,对受检异常(Checked Exception)是不回滚的。这意味着如果你在业务代码里throw new Exception("库存不足"),事务会正常提交,而不是回滚。
这个设计其实是有历史原因的:Spring 认为受检异常代表“业务可以处理的预期情况”,不一定要回滚。但现实业务里,绝大多数“库存不足”“余额不够”这种异常,我们是希望整个操作回滚的。所以我的习惯是:凡是在事务里主动抛出的异常,全部继承 RuntimeException,或者在@Transactional上显式声明:
@Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { // 业务代码 }3.4 在事务里调用外部接口和发消息
事务的本质是“长事务”问题,它持有数据库连接和行锁的时间越长,对系统的影响就越大。我在代码评审里经常看到有人在@Transactional方法里做这些事情:调用第三方 HTTP 接口、发送短信、发送 MQ 消息、执行复杂的文件解析。
这些操作的共同点是耗时不确定。如果外部接口响应慢,你的事务就会一直挂着,数据库连接一直不释放,被修改的行一直持锁。并发上来之后,后面的请求全部排队,整个服务的吞吐量断崖式下降。
解决思路是:把耗时的外部操作移到事务提交之后。Spring 提供了@TransactionalEventListener和TransactionSynchronizationManager.registerSynchronization两种方式,可以做到事务提交后再执行异步操作。
@Transactional public void createOrder(Order order) { orderMapper.insert(order); // 注册事务提交后的回调 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { mqService.sendOrderMessage(order); } } ); }3.5 多数据源下的事务边界
当你配置了多个数据源,比如订单库一个、用户库一个,Spring 默认的DataSourceTransactionManager只能管其中一个。你在一个事务方法里同时操作两个库,事务注解只会作用在主数据源上,另一个库的数据变更根本不在事务管理范围内。
这种情况没有简单的银弹。要么把跨库操作拆开,通过最终一致性方案(本地消息表、MQ、定时对账)处理;要么引入分布式事务框架,比如 Seata 的 AT 模式、TCC 模式。这块内容很大,本文不展开,但你要知道:Spring 的声明式事务,天然只处理单数据源事务。想着一个@Transactional搞定跨库事务,是不可能的。
3.6 传播机制下隔离级别可能会“失效”
还有一个容易忽略的细节:当一个 REQUIRED 方法内部调用了另一个标注了不同隔离级别的事务方法,内层设置的隔离级别不会生效。因为 REQUIRED 是加入当前事务,事务的隔离级别在事务启动那一刻就已经定死了,后续加入的方法即使指定了别的级别,也会被忽略。
举个例子:外层方法是默认隔离级别 REPEATABLE READ,内层方法写了@Transactional(isolation = Isolation.READ_COMMITTED),那么内层实际还是按 REPEATABLE READ 跑。如果你确实需要不同隔离级别,只能把内层改成 REQUIRES_NEW,让它在独立的物理事务里执行。
4. 组合实战:下单扣库存场景的隔离级别与传播机制落地
4.1 业务需求与方案设计
前面讲了不少理论,这一节我拿一个非常典型的电商下单场景,把隔离级别和传播机制串起来完整走一遍。假设需求是这样的:
- 用户提交订单,系统创建订单记录。
- 系统扣减库存,如果库存不足,下单失败。
- 库存扣减成功之后,写一条库存流水,用于后续对账审计。
- 下单成功后发送一条站内通知,通知失败不能影响下单主流程。
- 如果下单主流程失败,需要记录一条异常日志,方便事后排查。
这个需求里的几个操作,事务边界完全不一样,正好是练手传播机制的好案例。
4.2 代码结构与传播机制选择
先看整体代码结构:
@Service public class OrderService { private final StockService stockService; private final StockFlowService stockFlowService; private final NotifyService notifyService; private final ErrorLogService errorLogService; @Transactional(rollbackFor = Exception.class) public void createOrder(OrderRequest request) { try { // 1. 创建订单 orderMapper.insert(buildOrder(request)); // 2. 扣减库存,REQUIRED,参与当前事务 stockService.deductStock(request.getProductId(), request.getCount()); // 3. 写库存流水,REQUIRES_NEW,独立事务 stockFlowService.saveFlow(request); // 4. 发送通知,REQUIRES_NEW + 异步 notifyService.sendNotify(request.getUserId()); } catch (Exception e) { // 5. 记录异常日志,REQUIRES_NEW,独立事务 errorLogService.saveErrorLog(request, e); throw e; } } }各个方法的传播机制配置:
| 方法 | 传播机制 | 原因 |
|---|---|---|
| createOrder | REQUIRED | 主流程,统一事务边界 |
| deductStock | REQUIRED | 必须与主事务保持一致,订单创建失败,库存扣减必须回滚 |
| saveFlow | REQUIRES_NEW | 库存流水是审计数据,即使主流程回滚,流水也要保留,方便追查 |
| sendNotify | REQUIRES_NEW | 通知不参与主流程一致性,发送失败不影响下单成功 |
| saveErrorLog | REQUIRES_NEW | 主流程回滚后,异常日志必须独立保存,否则什么都查不到 |
4.3 库存扣减的隔离级别与并发控制
库存扣减是最需要考虑并发安全问题的地方。单纯在deductStock上加@Transactional是不够的,因为事务隔离级别解决的是“读”的一致性问题,而库存扣减本质是“写”的并发问题。
最稳妥的写法是条件更新,让数据库在原子层面保证不超卖:
@Transactional(rollbackFor = Exception.class) public void deductStock(Long productId, Integer count) { int updated = stockMapper.deductStockByCondition(productId, count); if (updated == 0) { throw new InsufficientStockException("库存不足"); } }对应的 SQL:
UPDATE stock SET count = count - #{count} WHERE product_id = #{productId} AND count >= #{count}这条 UPDATE 是当前读,会锁住匹配的行。两个并发请求同时进来,第一个请求拿到行锁并更新成功后,第二个请求会等锁释放再执行,然后发现count >= count条件不满足,更新影响行数为 0,抛出异常,事务回滚。这样就从根上避免超卖。
在 MySQL 默认的 REPEATABLE READ 级别下,这条条件更新配合行锁,已经能保证正确性。如果你用的是 READ COMMITTED,也同样没问题,因为更新走的是当前读加锁机制,跟快照读的隔离级别没有直接关系。
4.4 库存流水为什么用 REQUIRES_NEW
我在代码评审时经常被问:库存流水也是写库操作,为什么不能跟主事务一起提交?这里有个审计场景的考量。
假设主事务执行到一半出错回滚,库存没扣成,但用户可能已经在前端看到了“下单失败”的提示。这时候如果流水也回滚了,你事后查不到任何痕迹。可实际上用户确实发起过这个请求,系统也尝试过扣库存,只是因为某种原因失败了。如果流水独立提交了,你就可以看到“某年某月某日,用户请求扣减某个商品库存 3 件,但订单创建失败回滚”的记录,这对于排查问题、对账、风控都有价值。
还有一种更实际的场景:主事务里扣了库存,但订单表插入超时导致事务回滚。这时候库存已经扣了,流水如果跟着回滚,你查不到“库存到底扣没扣”的记录,而库存表里确实少了数据——这种数据不一致,会让整个团队陷入无休止的对账中去。REQUIRES_NEW 在这里像是给关键操作加了一个“监控摄像头”,至少能还原现场。
4.5 通知服务为什么还要加异步
通知服务的代码我单独列出来,因为这里有两个层面的事务思考:
@Service public class NotifyService { @Transactional(propagation = Propagation.REQUIRES_NEW) @Async public void sendNotify(Long userId) { // 站内通知写入 notifyMapper.insert(buildNotify(userId)); // 调用消息服务发送 mqSender.send(userId); } }REQUIRES_NEW 保证了它不会因为主事务失败而回滚;@Async保证了即使通知发送慢了,也不会阻塞下单主流程的接口响应时间。两个维度是互补的。
但这里也要注意一个新的坑:REQUIRES_NEW 和@Async同时使用时,如果外层事务还没提交,异步线程里的 REQUIRES_NEW 其实拿不到外层事务里未提交的数据。比如通知内容里要带“订单编号”,而订单是主事务里刚 insert 还没提交的,异步线程去查订单表可能查不到。所以通知内容最好在进入异步方法之前就把数据准备好,通过参数传进去,而不是让异步方法再去查库。
5. 线上事故排查实录:从现象到定位的几个实用命令
5.1 一个真实的“库存扣成负数”排查过程
之前有次线上事故,用户反馈“下单成功后,库存变成负数了”。现象是极少数商品在并发高的时候会出现超卖,但大多数时候正常,这让问题很难复现。
我当时的排查思路是这样的:先看是不是所有商品都会超卖,还是只有特定商品;然后看超卖时段的请求日志和数据库锁等待记录;最后定位到代码里用了先查询再更新的“非原子扣减”写法。我把当时的 SQL 日志打开,确认两个请求确实都查到了同一个库存快照,然后在 Java 代码里各自判断“库存够”,接着都执行了 UPDATE,最终覆盖成了超卖结果。
解决方案就是我上面写的那条条件更新 SQL,把判断库存和扣减库存合并到一个原子操作里。这个问题本质上是“并发写”问题,隔离级别解决不了它,必须靠数据库层面的原子性来兜底。
5.2 事务迟迟不提交,怎么快速定位
线上还有一种常见问题:数据库连接打满,报Connection is not available, request timed out。这种一般是有长事务占着连接不释放。
排查时我先用下面这条 SQL 看当前有哪些事务正在跑:
SELECT * FROM information_schema.innodb_trx\G;重点看trx_started字段,如果某个事务已经执行了几分钟甚至更久,它大概率就是罪魁祸首。然后再用:
SHOW ENGINE INNODB STATUS\G;看LATEST DETECTED DEADLOCK或事务等待信息,进一步定位锁冲突。
定位到具体事务后,再去代码里反查:这个事务对应的业务方法是谁调的,为什么执行了这么久。通常找到的都是两类问题:事务里调了外部接口,或者事务方法里做了大批量循环写入。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 事务没有回滚 | 异常被 catch 吞掉 | 检查方法内是否有 try-catch | 重新抛出异常,或改用 TransactionTemplate |
| 事务没有回滚 | 受检异常未声明 rollbackFor | 看异常类型 | rollbackFor = Exception.class |
| 事务没有回滚 | 自调用导致代理失效 | 看调用方是否同一个类 | 拆分 Bean、注入自身代理、用 TransactionTemplate |
| 数据重复写入 | 先查再写存在竞争窗口 | 看执行计划和代码逻辑 | 条件更新、乐观锁、悲观锁 |
| 连接池打满 | 事务中调用外部接口 | 查 innodb_trx 长事务 | 移到事务提交后执行 |
| 数据库死锁 | 事务间锁顺序不一致 | SHOW ENGINE INNODB STATUS | 统一锁获取顺序,缩短事务时间 |
| 内层设置隔离级别无效 | REQUIRED 加入已有事务 | 检查传播机制 | 改用 REQUIRES_NEW |
5.4 排查事务问题时我的三条原则
第一,先在数据库层确认问题,再回代码层找原因。很多事务问题其实不是 Spring 的事,而是 SQL 和锁的事,SHOW ENGINE INNODB STATUS的输出经常能直接给出答案。
第二,排查自调用和异常吞噬问题时,最有效的办法是给事务方法加日志,把进入方法、抛出异常、事务提交三个时间点都打出来。Spring 的事务日志也可以开,在配置里加:
logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction.interceptor.TransactionInterceptor: DEBUG这样能直接看到事务的开启、提交、回滚日志,比猜代码快得多。
第三,改事务代码时一次只改一个变量。事务涉及连接、锁、隔离级别、传播机制,多个因素交织在一起,如果一次改了好几个地方,出了问题根本没法定位。我在刚带团队时犯过这种错,一次改了传播机制又改了隔离级别,结果出问题后完全不知道是哪个改动引起的。
6. 一些心里话
踩过这么多坑之后,我对 Spring 事务的态度可以总结成一句话:能用默认配置解决的,绝不用高级特性;用高级特性前,先问自己“这个操作真的需要独立事务吗”。
事务隔离级别和传播机制是 Spring 里少有的、需要你同时理解“数据库原理”和“框架源码”才能用好的一块。隔离级别本质上是在说数据库在并发下怎么保证一致性,传播机制本质上是在说一个业务调用链里怎么划分事务边界。两者一个向下、一个向上,中间卡着的就是你的业务代码。平时写代码时多想一层——这个操作如果单独提交了会怎样,这个异常如果吞掉了会怎样——很多事故都能在代码评审阶段被挡下来。