《Spring Boot + JPA 实战》第13招 事务为什么失效

01、痛点

凌晨两点,运维群弹告警:订单服务数据库连接池耗尽,接口平均响应从 80 毫秒飙到 4 秒,大量下单请求超时。排查发现,一位开发在创建订单的事务里加了一次外部库存预占调用,对端系统抖动,事务一直不提交,连接和锁全部卡住。类似的问题还有很多:有人给方法加上 @Transactional,结果同类方法里内部调用,注解根本不生效;有人把整条下单链路包成一个事务,包含 HTTP、消息、通知,最后锁等待和死锁频发;还有人遇到受检异常不回滚,怀疑框架有问题。

我是老李。今天我们不讲“注解怎么加”,而讲 @Transactional 真正在表达什么。它不是一个“加上就安全”的开关,而是一段业务一致性边界。这个问题现在特别值得讲,因为系统越来越分布式、并发越来越高,事务边界一旦画错,破坏面会比想象中大得多。

02、是什么

@Transactional 是 Spring 提供的事务管理注解。很多人的理解停留在“加上它,方法里的数据库操作就会自动回滚”。这句话只说对了一半。更准确的说法是:@Transactional 声明了一个事务边界,告诉 Spring 从代理方法进入开始,到方法结束,内部涉及的数据库写操作要纳入同一个数据库事务;如果发生符合回滚规则的异常,事务管理器执行 rollback;否则统一 commit。

这个边界包含两层含义。第一层是 Spring 事务管理,负责根据传播行为、隔离级别、回滚规则来决定事务的开启、加入、挂起、提交和回滚。第二层是数据库事务,通常由 MySQL InnoDB 保证 ACID。Spring 只是把连接绑定到当前线程,并在合适的时机调用 commit 或 rollback,真正的原子性、隔离性和持久性仍然由 InnoDB 的 undo log、redo log、锁和 MVCC 完成。

所以,注解本身不是安全符,它只是你向系统发出的一个声明:这段代码里的数据库状态变更必须一起成功,或者一起失败。边界划得对不对,取决于业务,而不仅仅取决于注解。

03、为什么

过去单体应用代码简单,一个 Service 方法常常就是一个完整业务动作,事务边界天然清晰。现在系统拆成多个服务,下单可能涉及订单、库存、支付、风控、通知,外部调用多,数据库并发高。此时事务边界不再显而易见,很容易出现两种偏差:一是事务包得太大,把不该包含的外部 HTTP、消息、通知都圈进来;二是事务边界划得太碎,本应同时成功的操作被拆开,导致一部分成功、一部分失败。

更关键的是,只停留在注解层无法解释常见问题。比如为什么同类内部调用会失效?为什么 REQUIRES_NEW 能让内层事务独立提交?为什么可重复读级别下仍然可能看到幻读?这些都需要向下理解 Spring AOP Proxy、事务传播、隔离级别,以及 MySQL InnoDB 的锁和 MVCC。把上层和下层串起来,才能真正知道边界应该落在哪里。

04、架构

先看 Spring AOP Proxy。@Transactional 本质上依赖代理对象生效。当外部调用一个被代理的事务方法时,调用会先经过代理拦截器,拦截器根据事务属性向事务管理器申请事务,再调用目标方法。JDK 动态代理或 CGLIB 生成的代理对象只负责拦截外部调用;如果目标方法内部直接调用同类另一个带注解的方法,这个调用不经过代理,注解自然不生效。

传播行为决定事务如何流转,常见的有几种。REQUIRED 是默认值:当前有事务就加入,没有就新建,适合大多数场景。REQUIRES_NEW 会挂起当前事务,新建一个独立事务,内层事务提交或回滚不影响外层,适合需要独立记录的旁路场景。NESTED 使用保存点机制,内层回滚可以回滚到保存点,不会影响外层已执行的操作,但不是所有数据库都支持。其他如 MANDATORY、SUPPORTS、NOT_SUPPORTED、NEVER 则分别用于强制要求或禁止事务。

隔离级别决定多个事务之间的可见性。READ_UNCOMMITTED 可能读到未提交数据,出现脏读;READ_COMMITTED 解决脏读,但不可重复读和幻读仍在;REPEATABLE_READ 是 InnoDB 默认级别,通过 MVCC 解决不可重复读,并通过间隙锁部分解决幻读;SERIALIZABLE 最严格,基本串行执行,性能较差。

回滚规则经常被误解。默认只对 RuntimeException 和 Error 回滚,对受检异常不回滚。如果方法里抛出 IOException 之类受检异常,而又没有通过 rollbackFor 指定,事务会照常提交,这就是“明明抛异常却还是写进去了”的原因。readOnly 则更多是提示和优化,它不会强制阻止写入,但 Hibernate 等框架可能在只读事务中关闭自动 flush 行为,减少开销。Flush 与 commit 不是一回事,flush 只是把持久化上下文中的 SQL 同步到数据库,真正提交仍然由 commit 完成,未提交数据对其他事务的可见性由隔离级别决定。

再往下看 MySQL InnoDB。ACID 中,原子性靠 undo log 回滚,持久性靠 redo log 保证,隔离性靠锁和 MVCC,一致性则是综合结果,既依赖约束,也依赖业务。MVCC 通过隐藏列和 undo 版本链保存多版本数据,普通快照读生成 ReadView 判断哪些版本可见,因此读不阻塞写。当前读如 UPDATE、DELETE 或 SELECT FOR UPDATE 则需要加锁。可重复读级别下,快照读在事务内首次读取时建立 ReadView,此后读到的版本基本稳定;当前读则还要配合间隙锁和临键锁来防止幻读。理解这些后,就会明白为什么事务越大、锁持有越久,越容易撞出锁等待和死锁。

05、实战

用一个订单创建场景来验证。假设有三张表:订单主表、库存表、支付记录表。目标流程是“创建订单 → 扣库存 → 创建支付记录”。我们在 Spring Boot 项目中定义两个独立 Bean:InventoryService 负责扣库存,PaymentService 负责创建支付记录;OrderService 注入它们,并声明事务。

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private InventoryService inventoryService;
    @Autowired
    private PaymentService paymentService;

    @Transactional(rollbackFor = Exception.class)
    public void createOrder(OrderCreateRequest request) {
        Order order = new Order();
        order.setOrderNo(request.getOrderNo());
        orderMapper.insert(order);

        inventoryService.deduct(request.getSku(), request.getQuantity());

        paymentService.createPayment(request.getOrderNo(), request.getAmount());

        if (request.isMockFail()) {
            throw new RuntimeException();
        }
    }
}

外部通过 Spring 代理调用 createOrder 时,三个写操作处于同一个事务。故意把 mockFail 设置为 true,方法执行到支付记录创建后抛出 RuntimeException,事务回滚。最终订单主表、库存表、支付记录表都不会新增数据。这就是事务边界正确时的表现:要么一起成功,要么一起失败。

接下来演示内部调用失效。如果在 OrderService 里新增一个普通方法,直接调用同类带注解的方法:

public void createOrderDirect(OrderCreateRequest request) {
    this.createOrderInternal(request);
}

@Transactional(rollbackFor = Exception.class)
public void createOrderInternal(OrderCreateRequest request) {
    orderMapper.insert(order);
    inventoryService.deduct(request.getSku(), request.getQuantity());
    paymentService.createPayment(request.getOrderNo(), request.getAmount());

    if (request.isMockFail()) {
        throw new RuntimeException();
    }
}

此时外部调用 createOrderDirect,它又内部调用 createOrderInternal。由于 this 调用不经过 Spring 代理,@Transactional 不会生效。方法抛异常后,三张表的数据不会回滚,订单、库存、支付记录可能已经落库,业务出现部分成功。解决办法不是继续加注解,而是把事务方法放到独立 Bean,让外部调用经过代理;或者从业务设计上重新梳理边界,避免同类内部调用依赖注解。

再看外部 HTTP 调用的问题。假设在事务中插入订单后调用外部价格接口:

@Transactional
public void createOrderWithRemoteCall(OrderCreateRequest request) {
    orderMapper.insert(order);
    remoteClient.queryPrice(request);
    paymentService.createPayment(request.getOrderNo(), request.getAmount());
}

如果 queryPrice 正常但很慢,一次调用 2 秒,事务会持有一个数据库连接和已经产生的行锁 2 秒。并发一高,连接池很快耗尽,其他请求得不到连接,锁等待也会堆积。即使外部调用最终成功,数据库资源已经被白白占用。更合理的做法是把 queryPrice 放到事务边界之外,先拿到价格,再进入事务处理本地写操作。

06、洞见

事务范围为什么不是越大越安全?因为事务的核心代价是锁和连接。范围越大,锁持有时间越长,隔离范围扩大,冲突概率上升。一个事务里如果既有耗时外部调用,又有多个表的写操作,等于把所有资源都压在一条长链路上。并发场景下,这会让多个事务互相等待,死锁概率明显增加。安全不是靠包裹更多操作换来的,而是靠精确划定必须同时成功的最小变更集合。

外部 HTTP 调用为什么不应该长时间包在数据库事务里?因为数据库事务管理的是本地数据一致性,而外部调用具有网络不确定性、超时、重试、对端不可控等特点。把外部调用放在事务里,等于让一个本地一致性单元被外部系统的抖动绑架。数据库连接不释放,锁不释放,接口响应时间却被外部系统拉长。正确思路是:外部调用能前置就前置,能后置就后置;确实需要根据外部结果落库,也尽量让事务短小,或者改用本地消息表、最终一致性等方式。

所以洞见可以归结为一句话:事务的本质是业务一致性边界,而不是 Service 方法装饰器。加注解之前,先问一句:哪些状态必须同时成功?哪些状态允许最终一致?哪些操作根本不应该和数据库写操作绑在同一个事务里?答案清楚了,事务边界自然就出来了。

07、行动

给订单创建流程画出事务边界。可以分几步走。

第一步,梳理用例里的全部状态变更:创建订单主记录、扣减库存、创建支付记录、发送通知等。

第二步,圈定必须同时成功的写操作。订单主记录、库存扣减、支付记录创建通常需要写在同一个事务里,因为它们共同表达“下单成功”这个不可拆分的业务事实。

第三步,把外部交互和校验移出事务。参数校验、价格查询、风控检查、外部库存预占等可以在事务前完成;短信、推送、物流等通知类操作放在事务提交后。

第四步,设置明确回滚规则。建议把 rollbackFor 设为 Exception.class,或者清楚列出需要回滚的受检异常,不要依赖默认行为。

第五步,画出边界图:

HTTP 请求
  → 参数校验
  → 外部询价/风控(事务外)
  → [事务边界开始]
       插入订单主记录
       扣减库存
       创建支付记录
     [事务边界结束/提交]
  → 发送短信/通知/物流(事务外)

第六步,验证失败路径。故意在扣减库存后、创建支付记录前制造异常,确认三张表都回滚;再模拟外部调用慢的场景,观察连接池占用是否下降。

第七步,把边界写进设计文档。边界不是某一个方法名,而是业务变更的一致范围。以后每次新增字段或新操作,都先判断它属于边界内还是边界外。

08、小结

@Transactional 真正的作用是声明一段业务一致性边界。它不是安全符,也不能替代业务边界设计。Spring AOP Proxy 解释了为什么同类内部调用会失效;传播行为决定了事务如何流转;隔离级别和回滚规则影响并发与异常表现;readOnly 和 Flush 各自影响优化与写入时机;MySQL InnoDB 的 ACID 和 MVCC 则决定了底层的可见性与锁行为。订单创建流程如果把这些串起来,就能避免大事务、外部调用进事务、边界过碎这些典型错误。

09、链接与概念说明

相关概念速查:

  • Spring 事务传播行为:REQUIRED、REQUIRES_NEW、NESTED、MANDATORY 等。
  • Spring 事务隔离级别:READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。
  • Spring 回滚规则:RuntimeException、Error、rollbackFor、noRollbackFor。
  • Hibernate/JPA 持久化上下文:flush、commit、readOnly。
  • MySQL InnoDB 事务模型:ACID、undo log、redo log、锁类型、MVCC、ReadView、间隙锁、临键锁。
  • 事务边界设计:聚合根、用例一致性、本地消息表、最终一致性。

延伸检索建议:在官方文档中搜索 Spring Framework Transaction Management 与 MySQL Reference Manual InnoDB Transaction Model and Locking,能获得更完整的参数说明和锁行为解释。

posted @ 2026-10-10 23:47  李福春  阅读(3)  评论(0)    收藏  举报