2024 年我们一个下单接口出过一次"数据对不上"的事故:订单主表写成功了,扣库存的那步代码抛了异常,按理说整个下单应该回滚,结果订单还在,库存却没扣。代码里方法上明明白白写着 @Transactional,事务却像没生效一样。我们盯着那段代码看了很久,语法没有任何问题,可事务就是不回滚。后来才明白,@Transactional 是一个特别容易"看起来生效、实际没生效"的注解,它的失效方式有十几种,而且每一种都很隐蔽。投了几天做事务专项治理,把项目里所有 @Transactional 过了一遍,本文复盘这次实战。
问题背景
业务:订单中心,Spring Boot 2.7 + MyBatis,MySQL InnoDB
事故现象:
- 下单接口:订单写成功,但扣库存那步异常了
- 预期:整个下单回滚,订单不该留下
- 实际:订单留下了,库存没扣 —— 事务根本没回滚
现场排查:
# 1. 看那段下单代码
@Service
public class OrderService {
@Transactional // 注解明明在
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req)); // 写订单,成功了
deductStock(req); // 扣库存,这步异常
}
private void deductStock(OrderReq req) { // private!
stockMapper.deduct(req.getSkuId(), req.getCount());
if (notEnough) throw new RuntimeException("库存不足");
}
}
# 2. 第一个疑点:deductStock 是 private 的
# —— 但它是被同类的 createOrder 直接调用的
# 3. 关键发现:createOrder 内部直接调 deductStock,
# 这是【自调用】—— 走的是 this.deductStock(),不是代理对象
根因:
1. Spring 事务靠 AOP 动态代理实现,只有"走代理对象"的调用才有事务
2. 类内部方法直接调另一个方法(自调用),走的是 this,绕过了代理
3. 还查出一批:异常被 catch 吞掉、抛 checked 异常没回滚、
方法非 public、多线程里调事务方法等多种失效写法
4. 团队普遍以为"加了 @Transactional 就一定有事务",这是最大误区
修复 1:理解 @Transactional 的代理机制
// === @Transactional 是怎么生效的:靠 AOP 动态代理 ===
// Spring 启动时,发现一个类里有 @Transactional 方法,
// 不会把你的原始对象直接给调用方,而是生成一个【代理对象】。
// 你以为的调用:
// controller --> OrderService.createOrder()
// 实际的调用:
// controller --> 代理对象.createOrder()
// └─ 开启事务 --> 真实对象.createOrder() --> 提交/回滚
// === 代理对象做的事(伪代码)===
class OrderService$$Proxy extends OrderService {
public void createOrder(OrderReq req) {
Object tx = beginTransaction(); // 1. 开启事务
try {
super.createOrder(req); // 2. 调真实方法
commit(tx); // 3. 正常 -> 提交
} catch (RuntimeException e) {
rollback(tx); // 4. 异常 -> 回滚
throw e;
}
}
}
// === 核心结论 ===
// 事务的开启/提交/回滚,全在【代理对象】这一层完成。
// 只有"通过代理对象去调方法",事务才生效。
// 一旦绕过代理对象,@Transactional 就是一行普通注释。
//
// 所以排查事务失效,第一个要问的永远是:
// 这次调用,到底走没走代理对象?
修复 2:自调用导致事务失效
// === 失效场景:类内部方法直接调另一个事务方法 ===
@Service
public class OrderService {
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
// 错:this.deductStock() —— this 是真实对象,不是代理对象!
// deductStock 上即使加了 @Transactional 也【完全无效】
deductStock(req);
}
@Transactional
public void deductStock(OrderReq req) { // 自调用进来,事务不生效
stockMapper.deduct(req.getSkuId(), req.getCount());
}
}
// 原因:createOrder 里写 deductStock(req) 等价于 this.deductStock(req),
// this 指向的是没有事务增强的原始对象,直接绕过了代理。
// === 解法 A:注入自己,通过代理对象调用 ===
@Service
public class OrderService {
@Resource
private OrderService self; // 注入的是代理对象
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
self.deductStock(req); // 走代理对象,事务生效
}
@Transactional
public void deductStock(OrderReq req) {
stockMapper.deduct(req.getSkuId(), req.getCount());
}
}
// === 解法 B:用 AopContext 拿到当前代理对象 ===
// 需要 @EnableAspectJAutoProxy(exposeProxy = true)
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
((OrderService) AopContext.currentProxy()).deductStock(req);
}
// === 解法 C(推荐):把事务方法拆到另一个 Service ===
// 最干净:跨 Bean 调用天然走代理,没有自调用问题
@Service
public class OrderService {
@Resource private StockService stockService;
@Transactional
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
stockService.deduct(req); // 跨 Bean,正常走代理
}
}
修复 3:异常处理不当导致事务不回滚
// === 失效场景 1:异常被 catch 吃掉了 ===
@Transactional
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
try {
deductStock(req); // 这里抛了异常
} catch (Exception e) {
log.error("扣库存失败", e); // 异常被吞,只打了日志
// 没有 re-throw —— 代理对象那层根本感知不到异常,
// 它以为方法正常结束了,于是【提交】事务!
}
}
// 解法:要么别 catch 让异常冒出去,要么 catch 后手动标记回滚:
@Transactional
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
try {
deductStock(req);
} catch (Exception e) {
log.error("扣库存失败", e);
// 手动把当前事务标记为 rollback-only
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly();
throw new BizException("下单失败");
}
}
// === 失效场景 2:抛了 checked 异常,默认不回滚 ===
@Transactional
public void createOrder(OrderReq req) throws IOException {
orderMapper.insert(buildOrder(req));
if (bad) throw new IOException("xxx"); // checked 异常
// Spring 默认规则:只有 RuntimeException 和 Error 才回滚,
// checked 异常(IOException 等)默认【不回滚】!
}
// 解法:用 rollbackFor 明确指定"什么异常都回滚"
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderReq req) throws IOException {
orderMapper.insert(buildOrder(req));
if (bad) throw new IOException("xxx"); // 现在会回滚了
}
// 经验:团队统一约定 @Transactional(rollbackFor = Exception.class),
// 不要依赖"默认只回滚 RuntimeException"这个反直觉的规则。
修复 4:方法可见性与 Bean 管理
// === 失效场景 1:@Transactional 方法不是 public ===
@Service
public class OrderService {
@Transactional
protected void createOrder(OrderReq req) { // protected!
// Spring 默认的代理(基于 CGLIB/JDK 动态代理)
// 只对 public 方法增强,private/protected/默认包级 一律无效。
// 注解写了也白写,不会报错,只是静默失效。
}
}
// 解法:事务方法必须是 public。
// === 失效场景 2:类没被 Spring 管理 ===
// 一个普通 new 出来的对象,根本没有代理一说
public class OrderService { // 没有 @Service/@Component
@Transactional
public void createOrder(OrderReq req) { } // 注解无效
}
OrderService s = new OrderService(); // 自己 new 的,绕过 Spring
s.createOrder(req); // 没有代理,没有事务
// 解法:类要加 @Service 等注解交给 Spring 管理,
// 使用时用 @Resource/@Autowired 注入,不要自己 new。
// === 失效场景 3:数据库引擎不支持事务 ===
// MySQL 的 MyISAM 引擎压根不支持事务,
// 表用了 MyISAM,Spring 这边事务做得再对也回滚不了。
// 解法:确认核心业务表都是 InnoDB:
// SHOW TABLE STATUS WHERE Name = 'orders'; 看 Engine 列
// ALTER TABLE orders ENGINE = InnoDB;
// === 失效场景 4:@Transactional 加在了接口/抽象方法上 ===
// JDK 动态代理时注解在接口上能识别,但 CGLIB 代理(Spring Boot 默认)
// 建议直接把 @Transactional 加在【实现类的 public 方法】上,最稳妥。
修复 5:多线程与事务传播行为
// === 失效场景:在新线程里调用事务方法 ===
@Transactional
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
// 错:事务是绑定在【当前线程】的(基于 ThreadLocal),
// 新起一个线程,它拿不到外层的事务上下文
new Thread(() -> stockService.deduct(req)).start();
// deduct 要么自己独立开事务,要么没事务 —— 反正和外层不是一个事务
}
// 结论:事务【不能跨线程传播】。
// 多线程场景下,要么各自独立事务 + 业务层做补偿/对账,
// 要么改成串行,别指望一个 @Transactional 罩住多个线程。
// === 事务传播行为:方法套方法时,事务怎么处理 ===
// 传播行为决定"已经有事务时,我该加入还是新开"。
// --- REQUIRED(默认):有事务就加入,没有就新建 ---
@Transactional // 等价于 propagation = REQUIRED
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req));
stockService.deduct(req); // deduct 也是 REQUIRED,加入同一个事务
// 任何一步失败,整体一起回滚
}
// --- REQUIRES_NEW:总是新开一个独立事务,挂起外层事务 ---
// 典型用途:记操作日志,不管主业务成败,日志都要落库
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(OrderLog log) {
logMapper.insert(log);
// 它在自己独立的事务里提交,外层事务回滚也不影响它
}
// --- NESTED:嵌套事务,基于 savepoint,可单独回滚到保存点 ---
@Transactional(propagation = Propagation.NESTED)
public void deductPoints(Long userId) {
// 内层失败只回滚到 savepoint,外层可以选择继续
}
// === 一个常见坑:用错了传播行为 ===
// 想让"扣积分失败不影响下单",却用了默认 REQUIRED ——
// 扣积分一抛异常,整个下单事务被一起带回滚。
// 这种"子操作失败可容忍"的场景,要用 REQUIRES_NEW 或在外层 catch 住。
修复 6:事务的排查与监控
# === 1. 打开事务相关日志,排查时能看到事务边界 ===
# Spring 事务管理器的 debug 日志
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.jdbc.datasource=DEBUG
# 能看到 "Creating new transaction" / "Committing" / "Rolling back"
# 如果某个方法你以为有事务,日志里却没有 Creating new transaction,
# 那就是事务失效了。
// === 2. 写一个事务自检,确认 @Transactional 真的生效 ===
@Service
public class TxSelfCheck {
@Transactional(rollbackFor = Exception.class)
public void probe() {
orderMapper.insertProbe(); // 写一条探针数据
throw new RuntimeException("probe"); // 故意抛异常
}
}
// 调用 probe() 后,去库里查探针数据:
// - 查不到 -> 事务生效,回滚成功 ✓
// - 查得到 -> 事务失效了!回滚没发生,要排查
// === 3. 排查清单:事务失效从这几个方向逐一确认 ===
// (1) 是不是自调用?(同类方法 this 直接调)
// (2) 异常是不是被 catch 吞了 / 没 re-throw?
// (3) 抛的是不是 checked 异常,而没配 rollbackFor?
// (4) 方法是不是 public?
// (5) 类是不是被 Spring 管理(有没有 @Service)?
// (6) 是不是在新线程里调用的?
// (7) 数据库表引擎是不是 InnoDB?
// (8) 传播行为是不是配错了?
# 事务相关监控告警
groups:
- name: transaction
rules:
# 1. 事务回滚率突增(大量业务异常或事务问题)
- alert: TxRollbackSurge
expr: rate(tx_rollback_total[5m]) / rate(tx_total[5m]) > 0.1
for: 5m
annotations:
summary: "事务回滚率 > 10%,排查业务异常或事务配置"
# 2. 长事务(事务持有时间过长,易导致锁等待、连接耗尽)
- alert: LongRunningTx
expr: tx_duration_seconds{quantile="0.99"} > 3
for: 5m
annotations:
summary: "出现长事务 P99 > 3s,排查事务方法里的慢操作/远程调用"
# 3. 数据库连接池接近耗尽(长事务长期占用连接的典型表现)
- alert: DbConnectionPoolHigh
expr: hikaricp_connections_active / hikaricp_connections_max > 0.85
for: 5m
annotations:
summary: "连接池使用率 > 85%,排查是否有长事务占用连接"
优化效果
指标 治理前 治理后
=============================================================
下单事务回滚 自调用失效,不回滚 拆 Service,正常回滚
异常处理 catch 吞掉无回滚 re-throw / setRollbackOnly
checked 异常 默认不回滚 统一 rollbackFor=Exception
事务方法可见性 混有 protected 统一 public
跨线程事务 误以为能传播 明确不跨线程 + 对账兜底
传播行为 全用默认 REQUIRED 按场景选 REQUIRES_NEW
数据库引擎 个别表 MyISAM 核心表统一 InnoDB
事务可观测性 无 日志 + 自检 + 三类告警
治理过程:
- 排查事务失效根因(自调用):0.5 天
- 全量扫描 @Transactional,修自调用/可见性:2 天
- 异常处理与 rollbackFor 统一规范:1.5 天
- 传播行为梳理 + 多线程场景改造:2 天
- 表引擎核对 + 事务自检/监控接入:1 天
避坑清单
- @Transactional 靠 AOP 动态代理实现,只有"走代理对象"的调用才有事务
- 类内部方法直接调本类的事务方法(自调用)走 this,绕过代理,事务失效
- 解决自调用:拆到另一个 Service、注入 self 代理、或 AopContext.currentProxy()
- 异常被 catch 吞掉且不 re-throw,代理感知不到异常,会照常提交事务
- catch 后若仍要回滚,用 TransactionAspectSupport.setRollbackOnly()
- Spring 默认只对 RuntimeException/Error 回滚,checked 异常要配 rollbackFor
- 团队统一约定 @Transactional(rollbackFor = Exception.class),不依赖默认规则
- 事务方法必须是 public,private/protected 上的注解静默失效
- 类必须被 Spring 管理(@Service),自己 new 的对象没有代理没有事务
- 事务绑定线程,不能跨线程传播;表引擎必须是 InnoDB,MyISAM 不支持事务
总结
这次事务事故,表面看是一个"订单和库存数据对不上"的数据一致性问题,但深挖下去,根因其实是我们对 @Transactional 这个注解有一个根深蒂固的误解——以为只要把它写在方法上,这个方法就一定运行在事务里。事实并非如此。Spring 的声明式事务,本质上是靠 AOP 动态代理实现的:容器在启动时,发现某个类里有 @Transactional 方法,就不会把你写的那个原始对象直接交给调用方,而是偷偷生成一个代理对象,代理对象在调用真实方法的前后,负责开启事务、提交或回滚。也就是说,事务这件事完全发生在"代理对象"这一层,真实对象本身对事务一无所知。理解了这个机制,几乎所有的事务失效场景就都能解释了——它们无一例外,都是因为某次调用"没有走代理对象"。最典型的就是我们这次踩中的自调用:一个类里的 A 方法直接调用同类的 B 方法,写出来是 B(),实际等价于 this.B(),而这个 this 指向的是没有事务增强的原始对象,于是 B 方法上的 @Transactional 形同虚设。除了自调用,还有一长串同样隐蔽的失效方式:把异常 catch 住却不重新抛出,代理对象那层就以为方法正常结束了,该回滚的事务反而被提交;抛出的是 checked 异常,而 Spring 默认只对 RuntimeException 和 Error 回滚,于是事务又一次该回滚却没回滚;事务方法不是 public、类压根没被 Spring 管理、在新线程里调用事务方法(事务上下文是绑定在线程上的,不能跨线程)、甚至数据库表用的是不支持事务的 MyISAM 引擎——这些场景的共同特征是,代码语法全部合法,IDE 不会报错,程序也照常运行,只是事务在你看不见的地方静静地失效了。这就是 @Transactional 最危险的地方:它失效得悄无声息,往往要等到一次真实的异常发生、数据已经写脏了,你才会发现"原来这里根本没有事务"。所以这次复盘,我们沉淀下来的最核心一条经验是:永远不要假设 @Transactional 一定生效,而要主动去验证它。验证的办法很朴素——打开 org.springframework.transaction 的 DEBUG 日志,看调用某个方法时有没有打印出 "Creating new transaction";或者写一个故意抛异常的探针方法,事后查库确认数据是不是真的被回滚了。同时,在团队层面要把容易踩坑的点固化成规范:事务方法一律 public、一律 rollbackFor = Exception.class、需要事务的子操作尽量拆到独立的 Service 里跨 Bean 调用、想让子操作失败不连累主流程就明确用 REQUIRES_NEW。事务不是写一个注解就万事大吉的魔法,它是一套有明确生效边界的机制,只有当我们清楚地知道这条边界在哪里,才能让每一个我们以为有事务的地方,真的有事务。
—— 别看了 · 2026