加了 @Transactional 却不回滚:一次 Spring 事务失效的复盘

下单接口订单写成功了,扣库存那步抛异常,事务却没回滚 —— 订单留下了,库存没扣。方法上明明写着 @Transactional。排查发现是类内部自调用绕过了 AOP 代理。几天事务专项治理:理解代理机制、修自调用、异常处理与 rollbackFor、方法可见性、多线程与事务传播行为、事务自检与监控。

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 天

避坑清单

  1. @Transactional 靠 AOP 动态代理实现,只有"走代理对象"的调用才有事务
  2. 类内部方法直接调本类的事务方法(自调用)走 this,绕过代理,事务失效
  3. 解决自调用:拆到另一个 Service、注入 self 代理、或 AopContext.currentProxy()
  4. 异常被 catch 吞掉且不 re-throw,代理感知不到异常,会照常提交事务
  5. catch 后若仍要回滚,用 TransactionAspectSupport.setRollbackOnly()
  6. Spring 默认只对 RuntimeException/Error 回滚,checked 异常要配 rollbackFor
  7. 团队统一约定 @Transactional(rollbackFor = Exception.class),不依赖默认规则
  8. 事务方法必须是 public,private/protected 上的注解静默失效
  9. 类必须被 Spring 管理(@Service),自己 new 的对象没有代理没有事务
  10. 事务绑定线程,不能跨线程传播;表引擎必须是 InnoDB,MyISAM 不支持事务

总结

这次事务事故,表面看是一个"订单和库存数据对不上"的数据一致性问题,但深挖下去,根因其实是我们对 @Transactional 这个注解有一个根深蒂固的误解——以为只要把它写在方法上,这个方法就一定运行在事务里。事实并非如此。Spring 的声明式事务,本质上是靠 AOP 动态代理实现的:容器在启动时,发现某个类里有 @Transactional 方法,就不会把你写的那个原始对象直接交给调用方,而是偷偷生成一个代理对象,代理对象在调用真实方法的前后,负责开启事务、提交或回滚。也就是说,事务这件事完全发生在"代理对象"这一层,真实对象本身对事务一无所知。理解了这个机制,几乎所有的事务失效场景就都能解释了——它们无一例外,都是因为某次调用"没有走代理对象"。最典型的就是我们这次踩中的自调用:一个类里的 A 方法直接调用同类的 B 方法,写出来是 B(),实际等价于 this.B(),而这个 this 指向的是没有事务增强的原始对象,于是 B 方法上的 @Transactional 形同虚设。除了自调用,还有一长串同样隐蔽的失效方式:把异常 catch 住却不重新抛出,代理对象那层就以为方法正常结束了,该回滚的事务反而被提交;抛出的是 checked 异常,而 Spring 默认只对 RuntimeExceptionError 回滚,于是事务又一次该回滚却没回滚;事务方法不是 public、类压根没被 Spring 管理、在新线程里调用事务方法(事务上下文是绑定在线程上的,不能跨线程)、甚至数据库表用的是不支持事务的 MyISAM 引擎——这些场景的共同特征是,代码语法全部合法,IDE 不会报错,程序也照常运行,只是事务在你看不见的地方静静地失效了。这就是 @Transactional 最危险的地方:它失效得悄无声息,往往要等到一次真实的异常发生、数据已经写脏了,你才会发现"原来这里根本没有事务"。所以这次复盘,我们沉淀下来的最核心一条经验是:永远不要假设 @Transactional 一定生效,而要主动去验证它。验证的办法很朴素——打开 org.springframework.transaction 的 DEBUG 日志,看调用某个方法时有没有打印出 "Creating new transaction";或者写一个故意抛异常的探针方法,事后查库确认数据是不是真的被回滚了。同时,在团队层面要把容易踩坑的点固化成规范:事务方法一律 public、一律 rollbackFor = Exception.class、需要事务的子操作尽量拆到独立的 Service 里跨 Bean 调用、想让子操作失败不连累主流程就明确用 REQUIRES_NEW。事务不是写一个注解就万事大吉的魔法,它是一套有明确生效边界的机制,只有当我们清楚地知道这条边界在哪里,才能让每一个我们以为有事务的地方,真的有事务。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

加了索引还是慢:一次 MySQL 索引失效排查的复盘

2026-5-20 13:14:17

技术教程

消息积压三百万:一次 Kafka 消费积压与重复消费的复盘

2026-5-20 13:19:27

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索