1. 一次线上慢接口排查:事务范围过宽的现场还原
接手这个优化任务之前,我一度以为“事务范围优化”是个伪命题——毕竟 Spring 把@Transactional封装得那么优雅,一个注解挂在方法上,事务就自动开启、提交、回滚,几乎感觉不到它的存在。直到线上一个订单创建接口被压测打崩,我才意识到事务范围这件事远没有看起来那么简单。
当时的情况很典型:业务量一上来,接口 TPS 卡在个位数,数据库连接池频繁告警,DBA 查慢日志发现一堆长时间的“空闲事务”占着连接不释放。代码翻出来一看,整个订单创建流程从参数校验、库存扣减、优惠计算、积分赠送、消息推送全部包在同一个带@Transactional的方法里。前端调一次接口,数据库连接就被这个事务占住几百毫秒甚至更久,并发一高连接池直接被耗尽。
问题不仅仅在“慢”。事务范围过宽会让锁持有时间变长、数据库连接复用率下降、事务日志膨胀、甚至引发死锁和主从延迟,这些都是单体应用在业务复杂度上升后最容易踩的坑。这篇文章不聊理论上的“事务隔离级别”和“ACID”,我想借这个真实重构过程,把“事务范围”到底是什么、为什么会影响性能、以及如何在不破坏业务原子性的前提下把事务范围收拢到最合理的位置,完整地梳理一遍。
2. 事务范围到底消耗了什么:连接、锁与日志的三个隐形账单
很多同学理解事务的代价只停留在“开启事务会多几条 SQL”这个层面,实际上事务范围变大后,代价体现在三个维度:连接占用、锁竞争、日志与存储。理解了这三个账单,后面再做优化时思路就会清晰很多。
2.1 连接占用:数据库连接是被“借走”的
Spring 管理事务的底层是DataSourceTransactionManager,它做的事情本质上就是:从连接池借出一个连接,setAutoCommit(false),执行业务 SQL,最后commit()或rollback(),然后把连接归还连接池。
关键就在“借出”和“归还”之间的时长。假设连接池最大 20 个连接,一个接口的事务平均占用连接 500ms,那么理论上这个接口的极限并发也只有 40 QPS——不管你后面的服务怎么扩容,数据库连接池这一层就卡死了。如果事务里再混入外部 HTTP 调用、文件上传这类 IO 操作,事务持有连接的时间直接被网络延迟拉长,连接池被打满几乎是必然的。
我排查那个订单接口时发现,连接占用时间分布很不合理:真正写库的 SQL 加起来不到 20ms,但整个事务从开启到提交花了几百毫秒——中间夹了两次远程 RPC 调用和一次模板渲染。这就是典型的“事务范围过宽”,事务把原本可以并行化、异步化的工作全部串行占住了。
2.2 锁竞争:行锁和间隙锁不会因为你“想一下”就释放
事务范围内执行的写 SQL 会加锁,这些锁要到事务提交或回滚才释放。事务范围越大,锁持有时间越长,并发写同一行数据时阻塞的概率就越高。
举一个电商项目里最常见的场景:库存扣减。如果在一个事务里先查库存、再计算优惠、再调外部营销服务、最后才执行UPDATE stock SET ... WHERE product_id = ?,那么这个商品的行锁从UPDATE执行时就一直持有到整个事务结束,中间所有耗时操作都在“挡着”别人扣库存。高并发抢购场景下,大量线程会堆积在锁等待上,表现就是接口超时、数据库Lock wait timeout exceeded。
这里还有一个容易忽略的点:在 MySQL InnoDB 的可重复读隔离级别下,范围查询会加间隙锁(Gap Lock)。事务范围越大,间隙锁覆盖的范围就越广,原本并发插入不相干数据的操作也可能被阻塞。所以“事务范围优化”不只是省连接的问题,更是减少无谓锁竞争的问题。
2.3 日志与快照:长事务背后的隐藏开销
每次数据变更都会产生 redo log,配合 undo log 实现回滚和 MVCC 快照。长事务意味着 undo log 里堆积了大量旧版本数据,无法被 purge 线程清理,最终导致:
- undo 表空间膨胀,磁盘占用升高;
- 长事务存在期间,其他事务的查询如果需要构建一致性快照,读取成本变高;
- 主从复制场景下,从库的
relay log可能积压,延迟变大。
这一点在压测时不太能直接看到,但一旦上线运行几天,DBA 通常能从监控面板上发现异常。我见过一个系统因为一个定时任务开了长事务跑全量数据比对,结果主库 undo 日志在几个小时内翻了几倍,连带从库延迟从毫秒级变成秒级,影响面相当广。
3. 三类最常见的“范围过宽”代码模式:从案例里认出现在的问题
想优化事务范围,第一步是能在代码评审时认出“范围过宽”的长相。我总结了三种在业务系统里出现频率最高的模式,基本覆盖了绝大多数问题场景。
3.1 模式一:整个 Service 方法一个@Transactional,远程调用全包进去
@Service public class OrderService { @Transactional public Order createOrder(OrderCreateRequest request) { // 1. 参数校验 validate(request); // 2. 库存服务远程调用 boolean stockResult = stockClient.deduct(request.getProductId(), request.getQuantity()); if (!stockResult) { throw new BizException("库存不足"); } // 3. 本地写订单表 Order order = orderMapper.insert(buildOrder(request)); // 4. 计算优惠 BigDecimal discount = calcDiscount(order); order.setDiscount(discount); orderMapper.update(order); // 5. 调用营销服务,发优惠券 couponClient.sendCoupon(order.getUserId(), discount); return order; } }这个代码最大的问题是第 2 步和第 5 步的远程调用被事务包住了。stockClient.deduct()如果耗时 200ms,这 200ms 内数据库连接就被这个事务占着;更危险的是,如果远程调用成功但后续本地 SQL 失败,事务回滚并不会回滚远程调用已执行的库存扣减——这已经是分布式事务一致性的范畴了,后面专门说。
优化的方向很明确:远程调用要么提到事务之前,要么放到事务提交之后,通过TransactionSynchronizationManager注册afterCommit回调或直接用事件发布异步处理。
3.2 模式二:循环体内逐条操作数据库,全包在一个事务里
@Transactional public void batchUpdate(List<Item> items) { for (Item item : items) { Item dbItem = itemMapper.selectById(item.getId()); if (dbItem == null) { continue; } dbItem.setStatus(item.getStatus()); dbItem.setUpdateTime(new Date()); itemMapper.updateById(dbItem); } }这种写法在批量导入、批处理任务里很常见。事务范围是整个循环,循环体有多少条记录,事务就持续多久。假设一次导入 5000 条数据,每轮查询加更新 10ms,整个事务就要 50 秒。这个过程中所有更新过的行锁全部不释放,连接也被独占,其他业务查询读到的是事务开始前的快照,脏读倒是不会,但锁等待和连接占用足以让数据库崩溃。
如果是纯批量操作,通常的做法有两个:一是去掉事务,改为单条自动提交;二是按批次拆分事务,比如每 500 条一个事务。但要先问业务是否接受“中间失败、前面批次已提交”的语义,如果要求全量成功或全量回滚,那就不是一个事务能解决的事,得走对账补偿流程。
3.3 模式三:事务里做大量的只读查询和 CPU 计算
@Transactional public void processOrder(Order order) { // 各种只读查询 List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); User user = userMapper.selectById(order.getUserId()); Address address = addressMapper.selectById(order.getAddressId()); // CPU 密集计算 BigDecimal finalPrice = priceCalculator.calculate(items, user, address); // 最终只有一次更新 order.setFinalPrice(finalPrice); orderMapper.updateById(order); }这个模式的问题在于,所有只读查询都不是必须放在事务里的。只读查询放在事务里,会额外占用一个数据库连接,而且在可重复读隔离级别下会建立一致性快照,虽然短暂但积累起来也是负担。正确的做法是:只有最后那一次写操作需要事务保护,前面的查询完全可以在事务外执行。
用@Transactional(readOnly = true)标注只读方法也是一种常见做法,但要注意它并不是“开启一个没有代价的只读事务”,它仍然会占用连接,只是给数据库一个“可以走只读副本”的提示。真正的优化还是要把事务范围缩小到写操作本身。
4. 从大事务到小事务:一次重构的完整过程与具体代码
下面用订单创建接口的真实重构来演示完整过程。核心思路是:把事务视为“写操作的原子性边界”,而不是“整个业务方法的边界”。
4.1 第一步:梳理业务流程,区分“必须原子”和“可以异步/前置”的部分
重构前先画一张流程脑图,按操作类型分类:
| 操作 | 类型 | 是否必须与订单写操作同事务 |
|---|---|---|
| 参数校验 | 本地计算 | 否 |
| 库存扣减 | 远程调用 | 否(应前置或后置) |
| 插入订单主表 | 本地写 | 是 |
| 插入订单明细表 | 本地写 | 是 |
| 计算优惠 | 本地计算 | 否 |
| 发送优惠券 | 远程调用 | 否(应事务后置) |
| 发送通知 | 远程调用 | 否(应事务后置) |
这个表格就是事务重构的蓝图。只有“插入订单主表”和“插入订单明细表”是真正需要同生共死的写操作,它们必须在同一个事务里。其余操作要么提前到事务外执行,要么延后到事务提交后异步执行。
4.2 第二步:把远程调用前置或异步化
以库存扣减为例,这个操作如果放到事务外面,就需要考虑“扣减成功但订单创建失败”的补偿。我的做法是:
- 先调用库存服务预扣(或者使用库存服务的订单预占接口),拿到一个预占凭证;
- 本地事务里创建订单,并把预占凭证记录到订单表;
- 事务提交后,调用库存服务的确认扣减接口;
- 如果事务回滚,事务内记录的回滚状态触发库存预占的自动释放。
这种模式在订单-库存场景里已经是很成熟的实践了,它比“在事务里同步调用库存”更复杂一些,但换来了事务范围的大幅收缩和远程调用失败时不再阻塞数据库连接。如果库存服务不支持预占凭证,退而求其次就是本地事务提交后再调库存接口,配合定时任务做对账。
4.3 第三步:重构后的代码对比
重构前的问题代码就是前面 3.1 节那段。重构后我按职责拆成了两层:
@Service public class OrderService { @Transactional public Order createOrder(OrderCreateRequest request) { // 事务内只保留本地写操作 Order order = buildOrder(request); orderMapper.insert(order); List<OrderItem> items = buildItems(request, order.getId()); orderItemMapper.batchInsert(items); // 返回时不在乎远程调用的结果 return order; } // 事务提交后异步处理衍生操作 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onOrderCreated(OrderCreatedEvent event) { Order order = event.getOrder(); // 优惠计算 BigDecimal discount = priceCalculator.calculate(order); // 营销服务调用 couponClient.sendCoupon(order.getUserId(), discount); // 通知服务 notifyClient.sendOrderCreatedMessage(order.getUserId()); } }事件发布放在事务提交后再执行,这里用的是 Spring 的@TransactionalEventListener,事务提交成功才会触发监听方法。如果监听方法里抛异常,只影响异步处理本身,不会导致主事务回滚——这正是事务提交后做衍生操作的正确姿势。
4.4 第四步:警惕自调用导致的事务失效把范围“悄悄变宽”
重构过程中还踩了一个经典坑:事务方法内部调用本类的另一个方法,另一个方法上的@Transactional不会生效。比如:
@Service public class OrderService { @Transactional public void createOrder(OrderCreateRequest request) { // ... sendCouponAfterCommit(request.getUserId()); // 本类调用,注解失效! } @Transactional(propagation = Propagation.REQUIRES_NEW) public void sendCouponAfterCommit(String userId) { // 这个方法虽然标了 REQUIRES_NEW,但因为是在类内部调用, // 不会经过 Spring 代理,所以实际上还是在原事务里执行。 } }这种写法会让原本想独立开的事务意外合并成一个事务,事务范围“悄悄变宽”。解决方式很简单:把需要独立事务的方法放到另一个Bean中,或者通过AopContext.currentProxy()获取代理对象再调用。这个知识点在所有的 Spring 面试题里几乎都会出现,但在实际项目里没踩过一遍很难有切肤之痛。
5. 事务边界上移与下移:什么时候不能盲目拆分
事务范围优化不是“把事务拆得越小越好”——业务原子性不允许。这一节重点讲清楚边界移动的原则和取舍。
5.1 必须保持同一个事务的强一致场景
账户扣款和交易流水记录是最典型的例子。扣款不成功就不能记流水,流水记不上就不能返回成功,这两步必须在一个事务里。如果你把扣款放在事务 A、流水记录放在事务 B,中间崩溃了,账就对不上,只能靠对账补偿,代价极高。
类似场景还有:订单状态流转和对应状态变更日志、库存扣减和库存流水、余额变更和余额流水。这些写操作之间往往有“既然 A 发生了,B 就必须发生”的业务约束,拆开就打破了事务的原子性语义。
我的判断标准很简单:两个写操作之间如果存在“不可能接受一个成功一个失败”的业务关系,就应该放在同一个事务里。这不是性能问题,是数据正确性问题。事务范围优化只能优化那些“可接受异步、不要求强一致”的操作。
5.2 只读操作尽量移出事务:边界“下移”到写操作附近
对于那些只是帮忙算个数、查点数据的操作,大胆移出事务。有些开发者习惯在 Service 方法上无脑挂@Transactional,哪怕方法里只有一个查询。这个习惯要改掉,原因前面说过:占用连接、建立快照、还可能因为后续不小心加了写操作而“意外扩大”事务。
边界下移指的是,尽量让事务贴近数据访问层。如果你只有一个写操作需要事务保护,完全可以让一个只有写操作的小方法带@Transactional,而外层业务编排方法不带。这样即使后续在外层方法里加更多只读逻辑,也不会把事务范围撑大。
5.3 事务传播行为对范围的影响:REQUIRED、REQUIRES_NEW、NESTED的应用
Spring 提供的事务传播行为直接影响事务范围的大小,这是很多人在配置时容易忽略的:
REQUIRED(默认):如果当前有事务就加入,没有就新建。大部分业务方法都用这个,它的效果是让多个方法共享一个事务,范围由最外层决定。REQUIRES_NEW:无论当前有没有事务,都新建一个独立事务,挂起当前事务。适合“日志记录”这类不想被主事务回滚影响的场景。NESTED:如果当前有事务,则创建 Savepoint 嵌套事务,内层回滚不影响外层。适合批量处理中“这一批失败但不想让前面批次跟着回滚”的场景。
用对传播行为,本质就是对事务范围做更精细的控制。REQUIRES_NEW会把一个大事务拆成两个独立事务,代价是从此失去“同时成功、同时失败”的保证;NESTED保留了回滚粒度但性能开销更大。没有银弹,只能根据业务的可接受语义来选。
5.4 一个大事务拆成多个小事务后,一致性风险怎么兜底
拆事务不是拆完就完事了,一定要回答一个问题:如果第二个事务失败,第一个事务已经提交的数据怎么办?
我的经验是引入三种兜底手段:
- 本地消息表:第一个事务写业务数据的同时写一条“待处理消息”,第二个事务或异步任务消费这条消息完成后续操作。如果后续操作失败,消息表里还留着记录,由定时任务重试。
- 事务消息(消息队列的半事务消息):业务事务提交后,消息才对外可见;如果事务回滚,消息自动丢弃。RocketMQ 的 Transaction Message 就是这个思路。
- 定时对账:无论前面怎么设计,最终要对账。订单创建接口每天凌晨跑一次对账任务,把“有订单但库存预占未确认”或“已扣库存但无订单”的数据捞出来人工处理。
事务范围优化本质上是把“强一致性的大事务”转换成“小事务加最终一致性的补偿机制”,不是消灭一致性要求,而是改变一致性的达成时点。这是从单体应用走向微服务架构时必须想清楚的思维转变。
6. 分布式事务与事务范围的关系:事务消息、库存-订单场景的取舍
热词里频繁出现“分布式事务”“订单与库存分布式事务”“事务消息”,这里单独说一说,因为它和事务范围优化有着直接承接关系。
6.1 为什么分布式事务本质上还是“事务范围”问题
单体应用里事务范围是一个 JVM 事务、一个数据库连接的事。一旦拆成微服务、分库分表,一个业务操作涉及多个服务、多个数据库,事务的现实边界没法靠单个数据库事务覆盖了,于是产生了分布式事务问题。
用前面订单-库存的例子看:订单库创建订单、库存库扣库存,如果这两个写操作在两个数据库里,你不可能再指望一个@Transactional同时提交两个库。此时的问题已经变成了:如何把事务范围划到哪个边界?是选择“双库同时提交”的强一致方案,还是选择“先本地提交、再异步通知对方”的最终一致方案?
6.2 强一致方案与最终一致方案的实际选择
强一致方案的代表是两阶段提交(2PC)和 TCC(Try-Confirm-Cancel)。2PC 在实现层面依赖消息队列或专用中间件协调,性能损耗明显,而且数据库对分布式事务的支持在不同版本和厂商上表现差异很大,很多团队在生产环境里不敢直接启用 XA。TCC 则需要业务在 Try、Confirm、Cancel 三个阶段都写幂等逻辑,开发成本高。
最终一致方案的代表是本地消息表和事务消息。以“订单创建 + 库存扣减”为例:
- 订单服务开启本地事务,插入订单数据的同时,向本地消息表插入一条“待扣库存消息”;
- 本地事务提交成功;
- 异步任务扫描消息表,把消息发送到 MQ;
- 库存服务消费消息,执行库存扣减,成功后回执;
- 如果消费失败,MQ 重试;积压超过阈值则触发告警和人工介入。
这套方案没有全局锁,事务范围被限制在每个服务自己的本地事务里,性能和可扩展性远好于 2PC。代价是“库存可能延迟扣减”,但配合库存预占、超时释放机制,绝大多数业务场景都能接受。
做技术选型时我个人的经验是:能用本地事务解决的,绝不上分布式事务;能接受最终一致的,绝不选 2PC/TCC;非要强一致且数据敏感度极高(比如清结算),那就认真评估分布式事务中间件。
6.3 从 Spring 事务到 Seata:什么时候需要引入外部框架
Spring 本身只管理本地事务,不提供分布式事务能力。如果你听到“Spring Cloud 要处理分布式事务”,通常指的是在 Spring 生态里集成 Seata、RocketMQ 事务消息、或者自己写消息表方案。
Seata 的 AT 模式原理可以理解成:每个分支事务先把数据变更写入 undo log,全局事务提交时让所有分支事务一起提交,任一分支失败则根据 undo log 回滚。这个方案对业务代码侵入小,但要注意它对数据库类型、SQL 写法都有使用约束;NOT、LIKE等操作在某些版本里存在兼容性问题。
引入 Seata 之前要冷静评估:你的并发量真的达到需要分布式事务框架了吗?你愿意为它付出的运维成本和排错成本是多少?从“事务范围优化”的角度看,如果能在架构上拆分成小事务加异步补偿,很多分布式事务问题都可以在架构层面化解,而不需要引入重型组件。
7. 设计阶段预防事务范围失控:自查清单与代码评审要点
踩过坑之后,我在团队里定了一套代码评审和设计阶段的检查要点,现在分享出来,可以直接当 checklist 用。
7.1 代码评审时重点盯的五个信号
- 一个方法里既有事务注解又有远程调用:这是最危险的信号。无论远程调用在事务前、中、后哪个位置,都要明确问一句“远程调用失败时,本地事务应该怎么办?远程调用的结果是否真的需要和本地写操作绑定?”
@Transactional挂在超大方法上:方法超过 50 行就要开始警惕。事务范围是随着行数膨胀的,方法越大,事务里包含无关操作的概率越高。- 事务方法里有循环写操作:循环内逐条 insert/update/delete,事务范围随数据量线性膨胀。审查时必须要求拆批或者显式确认循环量级上限。
- 事务方法里有耗时操作但没有任何注释说明:事务里做加密、加签、图片处理、模板渲染这类 CPU 密集操作,比远程调用更容易被忽视。审查时看到这类代码,一律要求说明为什么不能放到事务外。
- 一个事务里同时写多个业务域:比如下单事务里同时更新订单表、用户积分表、优惠券表、商品表。跨业务域的写操作放一起,锁竞争范围很大。除非有强一致需求,否则优先拆开。
7.2 线上发现大事务的排查手段
代码评审拦不住所有问题,线上监控还是要配好。我实际用下来有效的手段:
| 手段 | 做法 | 能发现什么 |
|---|---|---|
| 数据库连接池监控 | 监控活跃连接数、等待连接数 | 连接被长时间占用 |
| 慢 SQL 日志 | 关注事务执行总时长和执行 SQL 数 | 一个事务里 SQL 过多 |
performance_schema事件统计 | 按事务分组统计耗时 | 长事务定位到具体会话 |
DBA 侧的information_schema.innodb_trx | 查询长时间未提交的事务 | 事务未提交的会话和 SQL |
| 接口 Trace 日志 | 记录事务开始/提交时间 | 定位代码里的事务边界 |
其中innodb_trx是排查利器:SELECT * FROM information_schema.innodb_trx ORDER BY trx_started ASC LIMIT 10;能立刻列出当前最老的事务,结合performance_schema.threads和events_statements_history就能定位到是哪条业务代码开启了长事务。
7.3 事务失效场景与范围优化的交叉影响
Spring 事务失效的场景在某些情况下也会“变相”扩大或缩小事务范围,这一点容易被人忽略:
- 方法不是
public:@Transactional不生效,原事务被拆成多个自动提交的小事务,范围从“大事务”变成“多小事务”。如果原本依赖跨多行写入的原子性,逻辑就有问题。 - 异常被内部 catch 吞掉:事务无法感知异常,不会回滚。这个和范围无关,但后果是数据不一致。
- 自调用:如前面说的,内层方法的事务注解失效,范围被意外合并到外层事务。
- 数据库引擎不支持事务:比如 MySQL 的 MyISAM 表,
@Transactional再大也是摆设,这个属于环境层面的坑。
代码评审时把这几条和“范围过宽”的检查同步进行,能省掉很多线上救火的精力。
8. 重构后的效果与一条硬经验
回到开头的订单接口,重构完成后的效果是:事务内只剩订单主表、明细表的本地写入,耗时从 700ms 降到 30ms 左右;数据库连接占用时间收缩到原来的二十分之一;压测 TPS 从个位数直接拉到 100 以上;数据库连接池告警消失。营销服务、通知服务的远程调用全部移到事务提交后异步处理,接口耗时不再受下游服务波动影响。
硬经验只有一条:事务是保护数据正确性的工具,不是业务编排的容器。任何和原子性无关的操作,都不应该在事务范围内执行。判断一个操作是否该留在事务里,只需问一个问题——它失败了,是否需要把事务里其他写操作一并回滚?如果答案是否定的,就把它挪出去。
我见过太多团队在新项目初期写代码时无脑加注解,等线上出了问题再组织专项治理,成本远高于设计阶段就想清楚边界。如果你正在重构一个事务范围过宽的老接口,不要急着改代码,先画流程脑图,把操作分类列出来,分类对了,重构只是体力活。