很久以前,我在排查线上一个库存扣减超卖的问题时,翻遍了下单链路,最终发现扣库存方法上明晃晃挂着 @Transactional,却形同虚设。那一刻我才清醒:Java 世界里大家最常挂在嘴边的“事务”,其实藏着大量反直觉的细节。后来这几年,我又在 MySQL 事务隔离级别、锁、传播行为上反复踩坑,每次复盘都想穿越回去给自己两巴掌。这篇就把我踩过的那些 MySQL 事务深坑整理出来,全部基于真实经历,代码可复现,SQL 可直接用。无论你是刚入行的后端开发,还是已经在 Spring Boot 项目里写过不少 @Transactional 的老兵,我相信都能从中找到自己的影子。
围绕“Java 事务”,大部分问题都发生在 Spring 声明式事务和 MySQL InnoDB 引擎这两个层面。Spring 管的是事务边界、传播行为、回滚规则,MySQL 管的是隔离级别、锁、undo log。两者一旦配合不好,轻则数据不一致,重则线上事故。下面我按从浅到深的顺序,把这些年踩过的坑逐个拆开讲。
1. Spring 事务失效:五个最常见的“白标”现场
先聊 Spring 层面的问题。很多人以为给方法加上 @Transactional 就万事大吉,实际上事务注解失效的场景比想象中多得多。我最早踩的坑就是自调用,后来在代码 review 里见过各种版本,这里统一梳理一遍。
1.1 同一个类里的自调用:最容易被忽略的坑
我当时的代码简化后长这样:
java复制@Service
public class OrderService {
public void createOrder(OrderDTO dto) {
// ... 一些校验逻辑
deductStock(dto.getProductId(), dto.getCount());
// ... 其他逻辑
}
@Transactional(rollbackFor = Exception.class)
public void deductStock(Long productId, Integer count) {
stockMapper.deduct(productId, count);
}
}
调用方执行的是 orderService.createOrder(...),看起来 createOrder 调用了带事务的 deductStock,但实际上 this.deductStock() 走的是目标对象内部直接调用,根本没有经过 Spring 生成的代理对象。Spring 的声明式事务是基于 AOP 代理实现的,代理对象在方法进入时开启事务、在方法退出时提交或回滚,你绕过了代理,注解就成了一块白标。
这个问题的解法有三种。第一种最直接,把 deductStock 拆到另一个 Service 类里,通过注入的依赖调用,这样调用链会经过代理。第二种是在方法内使用 AopContext.currentProxy() 拿到当前代理对象再调用,但前提是启动类上要配置 @EnableAspectJAutoProxy(exposeProxy = true)。第三种是自注入,也就是在 OrderService 里注入自己,虽然 Spring 能处理这种循环依赖,但写出来总感觉怪怪的。我的建议是优先用第一种,代码结构清晰,也不依赖 Spring 的代理配置。
1.2 private、final、static:修饰符如何杀死事务
有一次同事代码里写了个私有方法,上面加了 @Transactional,问为什么不起作用。这个其实涉及代理机制的本质。
Spring Boot 2.x 默认使用 CGLIB 代理,CGLIB 通过生成目标类的子类来覆写方法,实现增强。但 Java 的 private 方法是无法被继承覆写的,final 方法也不行,static 方法属于类级别,同样拦不住。所以 @Transactional 加在 private、final、static 方法上,事务必然失效。尤其要小心 private 方法——编译器不会报警,代码跑起来也不报错,数据还真的写进去了,直到某天出现问题才发现事务根本没生效。
我自己写代码的规范是:事务方法一律 public,外部通过 bean 调用。如果你的项目里有工具类或者私有辅助方法想开事务,别犹豫,拆出去单独建一个 Service。
1.3 try-catch 吞掉异常:回滚变成了假象
这种场景在真实代码里太常见了。一个事务方法内部,程序员为了防止某条数据写入出错影响主流程,在方法内部用 try-catch 把异常吞掉:
java复制@Transactional
public void updateOrder(OrderDTO dto) {
try {
orderMapper.updateStatus(dto.getId(), "PAID");
int i = 1 / 0; // 模拟运行时异常
} catch (Exception e) {
log.error("更新失败,但不想影响主流程", e);
}
}
这段代码执行完,orderMapper.updateStatus 的修改会被提交,虽然异常确实发生了,但事务拦截器根本没有感知到异常,因为它被 catch 住了,没有继续往外抛。Spring 默认只有方法抛出 RuntimeException 或 Error 时才会触发回滚。
这种写法要怎么处理?两个方向。如果确定要吞掉异常但还想要数据回滚,可以在 catch 块里手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),强制标记当前事务为回滚。但实际这么做会带来另一个问题:方法正常返回后,Spring 在提交阶段发现 rollbackOnly 标记,会抛出 UnexpectedRollbackException,调用方还是会收到异常。所以我更推荐的做法是:事务方法内部不吞异常,让它冒泡到调用方,由调用方决定怎么处理。事务的边界要清晰,该抛出就抛出。
1.4 默认回滚规则:受检异常不会触发回滚
这个坑比 try-catch 还要隐蔽,因为很多人只知道“默认回滚 RuntimeException”,却忽略了编译期异常(受检异常)默认不回滚。
看这段代码:
java复制@Transactional
public void syncData() throws IOException {
dao.updateA();
try {
Files.copy(source, target);
} catch (IOException e) {
// 这里如果用 throws 抛出去,事务不会回滚
throw new IOException("文件复制失败", e);
}
dao.updateB();
}
如果 Files.copy 抛出 IOException,方法会向外抛 IOException,但由于它不属于 RuntimeException 或 Error,Spring 默认不会回滚。结果就是 updateA 已经写入的数据不会回滚,而 updateB 没执行,数据处于部分成功状态。
解决方式就是在 @Transactional 注解上显式声明 rollbackFor:
java复制@Transactional(rollbackFor = Exception.class)
public void syncData() throws IOException {
// ...
}
在我现在维护的项目里,所有 @Transactional 都这么写。虽然默认值对运行时异常生效,但谁也不能保证团队里每个人都不会抛出受检异常,与其等出事再排查,不如一开始就把规则写死。
1.5 事务失效自检清单
为了以后少踩坑,我给自己整理了一个自检清单,每次写事务方法前过一遍:
- 方法是否 public?修饰符是否为非 final、非 static?
- 是否通过 this 调用了同类中的 @Transactional 方法?
- 方法内部是否 try-catch 吞掉了异常?
- 是否显式配置了
rollbackFor = Exception.class? - 事务方法所在的类是否被 Spring 管理(是否加了 @Service/@Component 等)?
- 是否在事务方法里使用了多线程创建子线程去执行数据库操作?
- 数据库表是否真的用了 InnoDB 引擎?MyISAM 不支持事务。
最后一条也踩过。某次排查发现事务一直不生效,最终定位到表引擎是 MyISAM,根本不支持事务。所以建表时要看清楚表引擎,别拿到老项目的遗留表就开始写代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL 隔离级别与锁:并发背后的真相
Spring 事务只是管边界,真正决定数据一致性的是 MySQL 的隔离级别和锁机制。这一层如果理解不透,会出现很多“看起来莫名其妙”的并发问题。
2.1 RR 隔离级别下,幻读到底存不存在
MySQL 默认的隔离级别是 REPEATABLE READ(RR),很多八股文说 RR 已经解决了幻读,严谨地说,InnoDB 在 RR 下通过 MVCC + 间隙锁解决了大部分幻读场景,但“绝对没有幻读”这个结论不能无脑接受。
我给你还原一个线上真实场景。两张表,product 和 product_stock,当时订单系统要保证查询商品列表和扣库存的一致性。事务 T1 先执行:
sql复制SELECT * FROM product WHERE status = 1;
返回了 2 条数据。此时事务 T2 插入了一条新的 status=1 商品并提交。T1 再次执行同样的查询,结果仍然是 2 条——这就是快照读的一致性,T1 看不到 T2 插入的数据,从这个角度看确实没有幻读。
但如果 T1 在第一次查询时走的是当前读,比如 SELECT ... FOR UPDATE,InnoDB 会给这个范围加上 next-key lock(间隙锁 + 记录锁),T2 想插入新记录时会被阻塞,直到 T1 提交。所以 RR 下的“防幻读”依赖条件:要么走快照读靠 MVCC,要么在当前读时靠间隙锁拦截。
但这里有个容易翻车的点:如果 T1 先做了一次普通快照读,然后去执行 UPDATE,UPDATE 属于当前读,会读取最新已提交的数据而不是快照数据。在极端情况下,T2 插入的数据可能被 UPDATE 一起更新掉,产生一种难以解释的“幽灵更新”。这就是为什么很多告警排查到最后,都要把 SQL 拉出来逐条分析是快照读还是当前读。
2.2 当前读与快照读:两条路径,两种结果
这里我用一张表把两类读操作区分开,方便你自己排查 SQL:
| 读类型 | SQL 示例 | 底层机制 | 是否加锁 |
|---|---|---|---|
| 快照读 | SELECT ...(普通查询) | MVCC 从 undo log 构建历史版本 | 不加锁 |
| 当前读 | SELECT ... FOR UPDATE / UPDATE / DELETE / INSERT | 读取最新已提交数据 | 加锁,受隔离级别约束 |
理解这个区别对排查锁等待特别重要。有一次生产环境报大量锁等待超时,我看监控发现是一条 SELECT 语句在等待,当时觉得很奇怪,SELECT 怎么会锁等待?后来才意识到那条 SELECT 是 FOR UPDATE,属于当前读,遇到行锁冲突就会等待,直到超过 innodb_lock_wait_timeout 默认值 50 秒。
2.3 索引失效引发锁升级,行锁差点变表锁
InnoDB 的行锁是通过索引实现的。走到这句话本身的含义是:如果你的 UPDATE 或 DELETE 语句没有命中索引,InnoDB 只能扫描聚集索引,扫描过程中发现符合条件的行就得加锁,最终可能把整张表的大量行都锁住,从效果上看等同表锁。
真实案例是这样的。订单表 t_order 的 order_no 字段没有建索引,有个定时任务执行:
sql复制UPDATE t_order SET status = 2 WHERE order_no = '202406010001';
这条语句会导致全表扫描,因为 order_no 没索引,InnoDB 会对扫描到的每一行都尝试加锁。结果就是整个表上其他事务的写入全部被阻塞,接口 RT 直线上升。
排查过程中还发现另一个隐藏问题:order_no 在表里是 varchar 类型,但定时任务传参时因为历史原因传成了数值型。SQL 写成 WHERE order_no = 202406010001,MySQL 会把字符串类型字段做隐式类型转换,导致索引失效,走了全表扫描。这类问题在代码 review 时很难发现,因为执行计划对不对,本地小数据量根本看不出来,得上线后遇到大数据量才暴露。
所以排查锁问题的时候,第一件事就是看执行计划:
sql复制EXPLAIN SELECT ... / UPDATE ...;
确认 type 是否为 ref/range 而不是 ALL,确认 key 是否真的有值。如果发现索引失效,优先修 SQL,而不是盲目调事务隔离级别。
2.4 一次生产死锁的完整复盘
死锁是事务场景里最让人头疼的问题,因为它是动态发生的,本地几乎无法复现。我遇到过最典型的一次,是抢购活动里两个事务加锁顺序不一致导致的死锁。
事务 A 的逻辑:
sql复制UPDATE t_stock SET count = count - 1 WHERE product_id = 1;
UPDATE t_stock SET count = count - 1 WHERE product_id = 2;
事务 B 的逻辑:
sql复制UPDATE t_stock SET count = count - 1 WHERE product_id = 2;
UPDATE t_stock SET count = count - 1 WHERE product_id = 1;
当 A 先锁了 product_id=1,B 先锁了 product_id=2,然后 A 想锁 2,B 想锁 1,两边互相等对方释放锁,就形成了循环等待。InnoDB 会在检测到死锁后自动回滚其中的一个事务,报错信息大概是:
code复制Deadlock found when trying to get lock; try restarting transaction
用户侧看到的是一次偶发的操作失败。要彻底解决,核心思路是让所有事务都按照相同的顺序获取锁。我当时把两个事务里的加锁顺序统一成先锁 product_id 小的,再锁大的,死锁就再没出现过。如果业务上无法统一顺序,也可以考虑将两个 UPDATE 合并成一个,或者用分布式锁在入口做串行化。
死锁排查时,执行 SHOW ENGINE INNODB STATUS 能看到最近一次死锁的详细信息,里面会列出两个事务执行的 SQL 和持有的锁。这个命令在线上要留意权限,普通账号可能需要授权才能查。
3. 大事务与长事务:从“性能差”变成“事故”
前面说的还是单个方法级别的失效和锁问题,这一章聊的是事务粒度控制不当引发的系统性故障。大事务和长事务是很多线上事故的根源,但平时开发时很少有人会意识到那个“慢接口”背后真实的原因。
3.1 5万条数据导入,主库被锁了20分钟
有一次运营提了个需求,要批量导入商品库存数据,数据量预计 5 万条。当时我第一版实现的思路很粗暴,一个方法,整体加 @Transactional,for 循环逐条 insert:
java复制@Transactional(rollbackFor = Exception.class)
public void batchImport(List<StockDTO> list) {
for (StockDTO dto : list) {
stockMapper.insert(dto);
}
}
逻辑没毛病,本地测试 500 条数据也很快。结果上线后运营导了一次 5 万条的 Excel,接口直接干了 20 多分钟,期间所有涉及库存表的读写请求全部排队,数据库连接池被这个事务占用,其他接口也开始超时。事后复盘,问题出在三个叠加起来的因素上:
- 5 万条 SQL 在一个事务里执行,事务持有行锁的时间极长。
- 单条 insert 的写法白白增加了很多次网络交互,5 万次往返耗时巨大。
- 事务占住了连接池里的连接,导致后续请求无连接可用。
改进方案是分两轮。第一轮先把单条 insert 改成批量 insert,每 500 条拼接成一条 SQL 提交。第二轮改成每 500 条一个事务,轮询提交,并且在 Spring 的配置里把事务超时时间设置上。最终导入耗时从 20 分钟降到 30 秒以内,而且对线上数据表的锁影响基本可控。
3.2 长事务引发的四连击
长事务的危害不止是锁。我见过太多人只关心“事务里是不是锁了太多行”,忽略了长事务在其他维度的隐性破坏。一个事务长时间不提交,至少会引发四个层面的事故:
- undo log 膨胀:事务执行期间,历史版本的清理(purge)会被阻塞,undo 表空间占用越来越大,极端情况下可能撑爆磁盘。
- 版本链变长:MVCC 的历史版本链越来越长,同一行数据的读操作需要遍历更多版本,查询性能下降。
- 主从延迟:如果主库上有长事务,binlog 写入和从库回放都会受到影响,从库的秒级延迟可能变成分钟级。
- 连接池耗尽:数据库连接被一个慢事务长期占用,连接池里有再多连接也扛不住持续占用,最终导致服务雪崩。
这四连击里,主从延迟是最隐蔽的。有一次半夜一个批量修复数据的脚本跑了半小时,第二天早上发现从库延迟 20 多分钟,前端页面数据一直刷不出来。后来定位到就是这个长事务产生的 binlog 太大,从库 SQL 线程回放压力极大。
所以说,写事务代码时一定要心里有数:这个事务会执行多久?会写多少数据?会不会在事务里循环?如果答案里有任何一个“会”,就需要重新设计事务边界。
3.3 事务里调外部接口,连接池是怎么被拖垮的
这是我在 code review 里反复强调的一点:事务方法里千万不要调用外部 HTTP 或 RPC 接口。道理很简单,外部接口的响应时间是不可控的,一旦对方慢或者挂掉,你的事务就会一直持有数据库连接和锁,等待外部返回。
之前遇到一个下单接口,代码大概是:
java复制@Transactional
public void createOrder(OrderDTO dto) {
orderMapper.insert(dto);
stockMapper.deduct(dto.getProductId(), dto.getCount());
String result = httpClient.post(paymentUrl, buildPaymentRequest(dto)); // 外部支付接口
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("支付失败");
}
}
支付接口平时响应 200ms,有一天对方服务抖动,接口超时设置成了 10 秒。这下好了,所有下单请求都卡在这个事务里 10 秒,数据库连接被大量占用,连接池很快打满,下单接口大面积超时,最终引起了整个服务的连环故障。
这个问题的解决方式是把外部调用从事务里挪出去。事务只负责写入本地数据,提交成功后再通过事务同步事件或者消息队列去调用外部接口。如果一定要在事务内调外部接口,那至少要做两件事:设置明确且很短的外部调用超时时间,并把外部调用放到事务方法的最后一步,减少锁的持有时间。更优雅的做法是使用 Spring 的事务事件,在 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 里处理外部调用,这样本地事务提交后才会触发外部操作,不会阻塞数据库连接。
4. 事务传播行为:REQUIRED 之外的世界
很多 Java 开发对事务传播行为的理解停留在“有个叫 REQUIRED 的东西”这个层面,但实际上传播行为的选择直接影响数据一致性。这里重点讲几个我实际用过的场景。
4.1 REQUIRED 与 REQUIRES_NEW:一条日志引发的血案
Spring 默认的传播行为是 REQUIRED,意思是如果当前已经存在事务,那么直接加入当前事务;如果没有,就新建一个事务。大部分业务方法这样是没问题的,但有一种场景会引发让人困惑的行为。
比如我在一个订单方法里调用了一个记录操作日志的方法。按照设计,订单方法如果有异常需要回滚,日志虽然也是数据库操作,但我希望它无论如何都写进去,方便排查问题。当时用 REQUIRED 实现了,结果订单方法回滚时,日志写入也一起回滚了,最后什么都没查到。
正确做法是把日志写入方法的传播行为改成 REQUIRES_NEW:
java复制@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(Long orderId, String action) {
auditLogMapper.insert(orderId, action);
}
}
REQUIRES_NEW 的特点是挂起当前事务,新开一个完全独立的事务。内层事务提交成功,外层事务再回滚,也不会影响内层已经提交的数据。这就是操作日志、审计记录这类场景的正确姿势。
但要注意,REQUIRES_NEW 不能滥用。如果某个数据需要和外层事务保持强一致,用了 REQUIRES_NEW 就会造成“外层回滚了,内层却留下了数据”的不一致状态。所以这个传播行为只适合那些“即使主流程失败也必须落库”的独立数据。
4.2 NESTED、SUPPORTS 这些冷门传播行为怎么用
NESTED 是一个比较特殊的传播行为,它基于 Savepoint 机制实现嵌套事务。内层事务回滚时,只会回滚到 Savepoint,外层事务中已经执行的修改不受影响;但外层事务最终回滚,内层也会跟着回滚。
我一般把 NESTED 用在分段处理一批数据的场景。比如批量处理 1000 条数据,我希望每处理 100 条能独立回滚,而不是整批一起回滚。用 REQUIRES_NEW 的话,内层提交后外层回滚也救不回来;用 NESTED 则可以在外层回滚时把内层也一并回滚,同时内层部分失败时不影响前面已经成功提交的部分。这里要注意的是,NESTED 在底层依赖 JDBC 的 Savepoint,MySQL 是支持的,但在某些不支持 Savepoint 的数据库上会退化成 REQUIRED,需要先确认数据库能力。
REQUIRED 之外,还有一个 SUPPORTS 值得理解:如果当前有事务就加入,没有就以非事务方式执行。这种传播行为适合查询类方法,有事务时可以享受一致性,没事务时也不会强行开启事务,减少无谓的事务开销。不过说实话,我实际用它的时候不多,因为查询方法大多直接不加事务注解。
4.3 传播行为选型速查表
把传播行为整理成一张速查表,开发时对照着选,能避免很多逻辑 bug:
| 传播行为 | 行为描述 | 推荐使用场景 |
|---|---|---|
| REQUIRED | 有事务则加入,无则新建 | 普通业务写入方法,大多数场景 |
| REQUIRES_NEW | 挂起当前事务,新建独立事务 | 操作日志、审计记录、消息发送记录 |
| NESTED | 基于 Savepoint 的嵌套事务 | 批量数据分段处理 |
| SUPPORTS | 有事务则加入,无事务则非事务执行 | 查询方法 |
| NOT_SUPPORTED | 挂起当前事务,以非事务方式执行 | 事务外的通知、计算 |
| MANDATORY | 必须在事务中执行,否则抛异常 | 强依赖事务的校验逻辑 |
| NEVER | 不能在事务中执行,否则抛异常 | 禁止事务化的特殊操作 |
这张表我打印出来贴在工位上,每次写 @Transactional 时先看一眼,确认自己选对了传播行为。大部分项目默认事务传播行为是 REQUIRED,这一点大多数时候是对的,但一旦涉及独立子任务、嵌套批量操作,就需要重新考虑。
5. 事务问题排查与防坑体系
讲完具体场景,最后分享一套排查方法和设计纪律。这些东西不是从文档里学的,都是被线上事故逼出来的。
5.1 三步定位未提交事务与锁等待
遇到事务相关问题,我习惯先查当前活跃的事务。MySQL 的 information_schema.innodb_trx 表是排查的第一步:
sql复制SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_duration,
trx_rows_locked, trx_rows_modified,
trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx;
这条 SQL 能列出所有正在执行的事务,包括事务开始时间、已执行秒数、锁了多少行、改了哪些行。如果发现某个事务已经跑了很久还没结束,基本可以锁定为长事务。
第二步是查锁等待关系。MySQL 5.7 以上可以查 performance_schema.data_lock_waits,结合 sys.innodb_lock_waits 视图可以更直观地看到谁在等谁的锁:
sql复制SELECT * FROM sys.innodb_lock_waits;
输出里会包含等待的线程 ID、阻塞的线程 ID、锁类型、锁在哪个表。
第三步是看完整的事务 SQL 和线程状态。用 SHOW PROCESSLIST 查看应用侧连接的实际状态,如果是 Sleep 状态且持续时间长,同时上面查出来有事务未提交,基本可以判定是代码里事务没及时提交。还有一种常见情况是 Spring 事务嵌套或者事务方法没有走代理,导致事务一直被挂起。
5.2 死锁日志怎么看
死锁发生时,InnoDB 会自动选择回滚一个小事务,但日志不会主动告诉你,需要手动查看:
sql复制SHOW ENGINE INNODB STATUS\G
输出内容很长,重点看 LATEST DETECTED DEADLOCK 这一段。里面通常包含两部分:
- 事务 1 的信息:持有的锁、等待的锁、正在执行的 SQL。
- 事务 2 的信息:持有的锁、等待的锁、正在执行的 SQL。
我第一次看这段日志的时候觉得很懵,因为格式偏底层,一行行锁信息没有业务上下文。后来习惯是先找两个事务的 SQL,然后对照代码,看他们加锁的资源顺序是否一致。如果日志里显示的 SQL 不够清楚,可以在 MySQL 配置里开启 innodb_print_all_deadlocks=ON,让所有死锁都打印到错误日志里,避免被埋没在定时任务或者低频操作中。这个参数对性能影响很小,生产建议开启。
5.3 我现在写事务代码的七条纪律
踩过足够的坑之后,我给自己定了七条硬性纪律,基本能规避掉 90% 的事务问题。这里分享出来,可以直接抄作业:
- 所有 @Transactional 注解都显式写
rollbackFor = Exception.class,不依赖默认值。 - 事务方法必须是 public,且不能是 final/static/private。
- 同一个类内部的相互调用,不直接调,拆分 Service 或者通过代理对象调用。
- 不在事务方法里吞异常,catch 到之后要么记录日志后重新抛,要么让事务方法自己处理。
- 不在事务里调用外部接口,不 sleep,不做耗时循环,批量操作必须分批提交。
- 查询类方法能不加事务就不加,加事务没有意义,还可能造成连接占用。
- 写更新类 SQL 前一定看执行计划,确认走了索引,避免行锁升级成表锁。
这些纪律也不是一步到位的,每一条的背后都是某次告警或者事故。写事务代码最忌讳“凭感觉”,因为事务的生效链路太长了:注解配置、代理机制、异常处理、数据库引擎、隔离级别、索引结构、锁顺序、传播行为,任何一个环节出问题,表象都是数据不一致或者接口超时,但排查路径完全不同。
如果你能把我上面讲的这些场景都过一遍,再遇到事务相关的坑,大概率能少走很多弯路。现在每次我在代码 review 里看到裸的 @Transactional,都会下意识看一下它有没有 rollbackFor,方法是不是 public,会不会被同类调用,事务里有没有远程调用。这些细节,值得每个写 Java 的后端刻进肌肉记忆。
