☰
Spring事务范围优化:从连接池告警到@Transactional边界拆解
2026/9/25 8:33:22 网站建设 项目流程

前阵子我们订单服务半夜突然告警,数据库连接池被打满,接口大面积超时。一开始以为是数据库慢查询,结果翻慢日志什么都没抓到。后来查了information_schema.innodb_trx,才发现罪魁祸首是一个挂了接近四十秒的事务——它把一个库存远程调用、一条短信发送、几行审计日志写入全部塞进了同一个@Transactional方法里。那天之后的很长一段时间,我都在跟“事务范围”这件事较劲。这篇博文就是想把这段时间的排查过程、背后原理和优化手段一次性讲清楚,适合那些天天用@Transactional但很少认真琢磨事务边界的开发者,尤其是刚接触 Spring 事务、或者正在做接口性能优化的朋友。

我先把结论放前面:事务范围优化的核心不是“少写几个注解”,而是把事务边界、方法边界和业务边界三者拆开看。很多线上事故不是因为代码写错了,而是因为一个事务里塞了太多不该塞的东西。

1. 从一次连接池告警说起:大事务是怎么拖垮系统的

1.1 事故现场与初步定位

那次事故的代码逻辑其实很常见,创建一个订单接口,里面做五件事:写订单主表、写订单明细、远程扣减库存、发送通知短信、记录审计日志。看起来每一步都是“必要操作”,所以很自然地写在了同一个方法里,方法上顶着一个@Transactional。上线半年都没出问题,直到某次大促流量上来,库存服务的响应时间从 20ms 涨到 2 秒,短信通道也开始抖动,然后整个订单接口就崩塌了。

这里有一个很多人忽略的资源绑定关系:一个 Spring 事务从开启到提交,会一直占有一个数据库连接。连接不是用完就还,而是要等整个事务commit或rollback才会释放。当远程调用在事务中间耗时 2 秒,那这条连接就白白挂 2 秒;如果远程调用超时设置是 10 秒,连接就要挂 10 秒。连接池的常用配置maximum-pool-size也就 20 到 50,一个接口并发一上来,连接瞬间被耗光,后续所有需要数据库连接的业务全部排队。

我后来喜欢用一个比喻来向组里新同事解释这件事:事务就像你去银行柜台的整个办理过程,你在等一个远程审批电话的时候,柜员不能去服务下一个人,座位也只有那么多。真正高效的做法是,凡是能事后确认的事情,都别占用柜台时间。

1.2 事务的本质:方法边界就是事务边界

Spring 声明式事务之所以好用,是因为它把“事务开关”藏进了 AOP 代理里。你写一个方法,加上@Transactional,代理会在方法执行前开启事务,方法正常返回后提交,抛异常就回滚。问题恰恰出在这:事务的边界被绑定到了方法边界上。方法越长,方法内做的事情越多,事务范围就越大。

一个事务从 begin 到 commit 之间,到底持有哪些东西?首先是那一条物理数据库连接,其次是所有被修改行的行锁、间隙锁,再次是 undo log 中记录的前镜像。行锁和间隙锁会直接影响其他事务的读写,事务的持续时间越长,锁被持有的时间就越长,别人等待锁的概率就越高。还有一个容易被忽略的点:在REPEATABLE READ隔离级别下,事务内的第一条SELECT会创建快照,事务持续越久,快照读取的数据越接近“过去”,业务上可能出现读到旧数据的情况。

所以排查这类问题有一个基本思路:列出事务方法内每一个操作的耗时,把所有耗时不稳定的操作(远程调用、外部 IO、大循环)排除到事务之外。事务内只留下“必须跟主记录一起成功或一起失败”的数据库写操作。

2. 事务边界的幕后规则:传播机制与代理陷阱

2.1 传播行为决定了事务粒度

要优化事务范围,先得理解 Spring 是怎么处理方法嵌套的。默认的传播行为是REQUIRED,它的语义是:如果当前线程已经存在一个事务,就加入这个事务;如果没有,才新开一个。这意味着你调用了一个带@Transactional的 B 方法,而 B 方法本身也有@Transactional,两个方法会合并成同一个事务。

这就出现了一个很隐蔽的问题。假设 A 方法是纯查询逻辑,但是因为业务需要调用了 B,B 里面做了一次更新并提交,由于传播行为是REQUIRED,B 的提交并不会真的提交,而是要等 A 方法整个返回后一起提交。在调用链很深的时候,事务范围会被层层扩大,比单独看任何一个方法时看到的都要大。

传播行为行为说明适用场景
REQUIRED有事务则加入,没有则新建默认,最常用
REQUIRES_NEW挂起当前事务,新开一个独立事务长流程分批处理、独立子任务
NESTED利用 savepoint 实现嵌套事务部分失败回滚到保存点
SUPPORTS有事务就加入,没有就以无事务方式执行只读查询方法
NOT_SUPPORTED以无事务方式执行,挂起当前事务事务内不想占连接的操作

优化时最常见的一个误区是盲目加REQUIRES_NEW。它的代价是外层事务被挂起,如果内层事务提交了但外层事务后续失败回滚,内层事务的数据并不会跟着回滚,会造成数据不一致。而且它需要额外占用一个数据库连接,在高并发场景下反而会加剧连接池的压力。我在下文会给出一个相对安全的用法。

2.2 自调用与代理失效:事务没生效的常见原因

事务范围优化的前提是事务本身是有效的。很多人踩过一个坑:同一个类里,一个普通方法调用另一个@Transactional方法,事务完全不生效。原因不复杂,Spring 事务通过代理对象开事务,而this.method()直接绕过代理,等于裸调。

@Service public class OrderService { public void createWithoutTx() { // 这里看起来调用了带事务的方法,实际没走代理 this.createWithTx(); } @Transactional public void createWithTx() { orderMapper.insert(order); } }

解决办法通常是三种:把被调用方法拆分到另一个@Service里,注入那个 bean 来调用;或者注入自身代理,也就是@Autowired自己这个类型的 bean;或者用AopContext.currentProxy()。我不太推荐第三种,因为需要额外配置exposeProxy,而且代码里读起来很绕。最稳妥的做法还是拆类,把事务方法独立出去,顺便也能让职责更清晰,这本身就是一种事务范围优化。

2.3 嵌套调用会悄悄扩大事务边界

在实际业务代码里,事务边界往往不是写代码的人主观决定的,而是被调用链“推”着走的。我见过一个典型的订单查询接口,Controller 层调 Service 的查询方法,查询方法里为了写操作日志,调了一个logOperation()方法,而这个日志方法上恰好有@Transactional。结果查询接口的无意中被包了一个事务,虽然日志方法很快返回,但如果调用链中任何一个检查、远程调用、复杂计算被放进这个链路,事务就会被无限拉长。

这里想强调一个观点:**事务边界应该是显式设计出来的,而不是被方法调用关系自然带出来的。**我在做代码评审时,会重点关注两个信号:一是带@Transactional的方法内部出现了明显的耗时操作,二是无状态查询入口下面带着写操作。这两个信号基本能定位到 80% 的大事务隐患。

3. 事务范围优化的五条实战策略

3.1 策略一:事务内只保留数据库写操作

这是最基础也最容易被接受的一条原则。还是拿开头的下单场景举例,优化前的代码大概是这样的:

@Transactional public void createOrder(OrderCreateRequest request) { // 1. 写订单主记录 orderMapper.insert(buildOrder(request)); // 2. 写订单明细 orderItemMapper.batchInsert(buildItems(request)); // 3. 远程扣减库存,网络耗时不可控 stockClient.deduct(request.getSkuId(), request.getCount()); // 4. 发送通知短信,外部接口耗时不可控 smsClient.sendMessage(request.getMobile(), "您的订单已创建"); // 5. 记录审计日志 auditLogMapper.insert(buildAuditLog(request)); }

这个方法的数据库操作只有三步,但实际事务占用时间 = 3 + 4 的耗时。库存接口稳定的时候还好,一抖动整个事务就悬了。优化后第一版,我把远程调用挪出事务:

@Transactional public void createOrder(OrderCreateRequest request) { orderMapper.insert(buildOrder(request)); orderItemMapper.batchInsert(buildItems(request)); auditLogMapper.insert(buildAuditLog(request)); } public void createOrderWithRemote(OrderCreateRequest request) { createOrder(request); // 事务提交后才执行外部调用 stockClient.deduct(request.getSkuId(), request.getCount()); smsClient.sendMessage(request.getMobile(), "您的订单已创建"); }

这里有一个关键细节:createOrderWithRemote不能加@Transactional。,createOrder自己带事务,方法一返回事务就提交了,连接也随之释放。如果把createOrderWithRemote` 也加上事务注解,那远程调用又回到事务内了,等于白拆。这是我对很多同事强调过的一个点:不是把代码物理挪个位置就完了,要保证外部调用所在的方法不带事务传播进来。

3.2 策略二:用事务同步把外部调用挪到提交后

单纯把远程调用放到事务方法外部,会引入一个新的问题:事务提交成功后,如果远程调用失败了怎么办?订单数据已经入库,短信没发出去,用户状态不对。更稳妥的方案是用 Spring 的事务同步机制,注册一个afterCommit回调。

@Transactional public void createOrder(OrderCreateRequest request) { orderMapper.insert(buildOrder(request)); orderItemMapper.batchInsert(buildItems(request)); auditLogMapper.insert(buildAuditLog(request)); TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // 事务提交成功后才执行,连接已经释放 stockClient.deduct(request.getSkuId(), request.getCount()); smsClient.sendMessage(request.getMobile(), "您的订单已创建"); } }); }

注意几个坑。afterCommit里如果抛出异常,不会触发主事务回滚,因为事务已经提交了,但异常会传回主方法,可能让接口报错。所以在回调里必须自己捕获异常,记录日志,必要时走补偿逻辑。另一个点是注册回调后,如果事务回滚了,afterCommit不会执行,这恰恰是我们想要的:数据没落库,就不该发通知。

我在生产环境使用这个方案时,通常会把afterCommit里要做的远程操作继续封装成一种可重试的任务,用本地消息表加定时任务兜底,保证“订单已创建”的通知最终一定到达。事务同步解决的是时序问题,不解决可靠性问题。

3.3 策略三:批量长流程用 REQUIRED_NEW 分批提交

有些业务天然是长流程,比如定时任务里批量处理一万条对账单,每条对账要更新余额、记录流水、可能还要锁账户行。如果整个方法一个事务跑完,这一万条的所有行锁、更新操作全都挤在一个事务里,一旦某一条失败,全部回滚,前功尽弃。

这种场景的正确做法是外层无事务,内层按批次提交。我常用的一个结构是:

public void batchProcess(List<LoanAccount> accounts) { for (List<LoanAccount> batch : partition(accounts, 500)) { processBatch(batch); } } @Transactional(propagation = Propagation.REQUIRES_NEW) public void processBatch(List<LoanAccount> batch) { for (LoanAccount account : batch) { accountMapper.updateBalance(account); flowMapper.insert(buildFlow(account)); } }

这样每个 500 条的批次是一个独立事务,成功就提交,失败只影响当前批次。加REQUIRES_NEW是因为外层方法没有事务,如果不加,第一个批次开启的事务会一直传到后续批次,又变回一个大事务。我踩过的坑是忘记在外层无事务的情况下,内层方法互相调用,导致事务被共享,所以建议把processBatch放在独立的 bean 里。

REQUIRES_NEW的性能代价也需要评估。每个批次提交时的fsync消耗是之前我说过的;连接占用,REQUIRES_NEW挂起外层事务后,会从连接池再拿一个连接,如果同时有几十个批次在跑,连接池需要足够大。我的经验是批次数和连接池大小要放到一个池子里综合考虑,不要通过无限加大连接池来解决问题。

3.4 策略四:TransactionTemplate 实现编程式事务

声明式事务注解适合边界固定的场景,但现实业务里经常出现“事务边界不完全是方法边界”的情况。比如一个方法里,前面的数据库操作不要求事务,中间有一段必须原子更新,后面的远程调用又不希望被事务拖住。这种时候用注解反而别扭——切来切去拆方法虽然能做,但代码结构被事务需求扭曲了。

TransactionTemplate是一个很顺手的工具,它把事务边界控制显式地放在代码里:

@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(TransactionTemplate transactionTemplate) { this.transactionTemplate = transactionTemplate; } public void createOrder(OrderCreateRequest request) { // 事务外:校验、组装 Order order = buildOrder(request); // 事务内:只做数据库写 transactionTemplate.executeWithoutResult(status -> { orderMapper.insert(order); orderItemMapper.batchInsert(buildItems(request)); }); // 事务外:远程操作 stockClient.deduct(request.getSkuId(), request.getCount()); } }

executeWithoutResult会在回调方法正常返回后自动提交回调里定义的TransactionTemplate的默认传播行为是REQUIRED,如果你发现它已经在一个事务里,就会加入。如果想强制新事务,可以用构造TransactionTemplate时传入TransactionDefinition.PROPAGATION_REQUIRES_NEW。我通常会用编程式事务细化某些热点方法的范围,作为声明式事务的补充,而不是替代。

3.5 策略五:事务外的非核心动作异步化

很多时候,我们把所有操作都塞进事务方法里,是因为不敢丢逻辑,而不是因为逻辑必须和主数据同生共死。通知类型的操作(短信、站内信、邮件)、分析埋点、操作日志,都属于“可以晚一点执行,但不能丢”的事情。这类任务用异步化最合适。

实现上可以用 Spring 的@Async搭配独立线程池,或者直接丢进消息队列。

public void createOrder(OrderCreateRequest request) { transactionTemplate.executeWithoutResult(status -> { orderMapper.insert(buildOrder(request)); }); // 异步发送通知,不阻塞主流程 notifyService.sendOrderCreatedMessage(request); }

给notifyService.sendOrderCreatedMessage加上@Async注解,调用方不需要等待执行完成。这里要提醒的是,不要直接从事务内部调用@Async方法,因为在事务提交前异步线程就已经开始了,如果它去读刚写入的数据,可能读不到,因为数据还没提交。正确的方式是事务提交后,再触发异步调用,或者把异步调用排在afterCommit回调里。

异步化的问题在于可观测性变差,线程池满了会丢任务,消息队列会引入新的运维成本。所以我个人的排序是:能同步简单调用的尽量同步,只有像群发通知、报表生成、日志清洗这类明显可以延后的动作才建议异步化。

4. 如何量化与定位线上大事务

4.1 数据库侧观测长事务

优化不是拍脑袋,得先量化。数据库层面对长事务最直接的观测手段是查innodb_trx表:

SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_age_seconds, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started ASC;

查出来的结果里,trx_age_seconds比较大的就是长事务,拿到对应的trx_mysql_thread_id,再去怼到具体的线程和 SQL:

SELECT * FROM performance_schema.processlist WHERE id = 这里填查到的ID;

MySQL 8.0 的performance_schema里还有events_statements_current,能看到这个连接当前正在执行的 SQL。如果事务卡住是因为锁等待,还能通过sys.innodb_lock_waits看锁等待关系。

线上实践的时候,我会做三件事:一是定时任务每隔几分钟扫一次innodb_trx,超过 10 秒的事务就告警;二是把告警信息直接推到排查群;三是要求所有慢事务都必须登记原因。这个机制上线后,大促期间的长事务数量下降得很明显——因为大家都知道了线上真的会有人盯着你的事务。

4.2 应用侧统计事务耗时与锁等待

数据库侧能看到“事务存在”,但定位“事务是哪个方法开启的”需要应用侧配合。我常用的一个办法是加一个 AOP 切面,拦截所有带@Transactional注解的方法,统计方法总耗时。

更精细一点,可以注册事务同步来统计“事务实际活跃时间”和“事务外时间”的差值:

public class TransactionMetricsAspect { @Around("@annotation(org.springframework.transaction.annotation.Transactional)") public Object measure(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCompletion(int status) { long cost = System.currentTimeMillis() - start; // 记录事务方法总耗时,超过阈值告警 } }); return pjp.proceed(); } }

事务里真正跟数据库相关的耗时,可以通过注册事务同步时再包一层 DataSource 代理来统计,也可以直接观察连接池的活跃连接数指标。Spring Boot 的 Actuator 配合 Micrometer 一般会暴露hikaricp_connections_active之类的指标,如果活跃连接数持续高位,大事务的嫌疑非常大。

还有一种更省事的办法:把方法耗时、连接获取耗时、已占用连接数打点进日志,用 APM 工具的慢调用链去看。生产环境上,我好几次就是靠着链路追踪里“数据库连接持有时间”这个指标反向找到了还在事务内跑远程调用的方法。

4.3 代码审查中的大事务坏味道

即使没有监控系统,代码审查也能发现大部分大事务隐患。我总结了一套高频坏味道清单,看到以下情况就应该警惕:

  • 带@Transactional的方法里出现 HTTP 调用、RPC 调用、短信/邮件发送。不分原因,一律先质疑。
  • 方法内部出现循环写数据库。循环内每条记录单独 update 或 insert,意味着事务持有了大量行锁。
  • 一个事务方法里的代码行数超过某个阈值,比如 50 行以上。
  • 查询方法被莫名其妙加上@Transactional,很可能只是为了某条查询语句,完全没有必要。
  • 同类内自调用,不是事务失效的问题,就是事务边界被内部逻辑绕过的隐患。

这里有一个比较有争议的地方:查询方法能不能加@Transactional(readOnly = true)?我的看法是它能帮助数据库优化只读事务的路径,也能避免查询过程中意外写入,但在大多数业务系统里,readOnly = true并不能显著降低连接持有时间,它只是给了数据库一个“只读”的提示。如果查询链路里没有写操作,加不加都行,加了反而产生不必要的代理成本。把长查询拆到事务外,收益更直接。

5. 踩坑实录与个人经验总结

5.1 常见问题速查表

我在推进事务范围优化的过程中,被问过很多次“为什么我改完了还是有问题”,这里把几个高频问题整理成了速查表,遇到类似症状可以直接对照。

现象可能原因解决办法
事务方法里调了远程接口,连接池还是满远程调用所在方法整体被外层事务包住拆类,确保远程调用方法不参与事务
方法加了@Transactional但数据没回滚同类自调用,代理未生效拆类注入,或改用编程式事务
小事务提交特别慢事务内持有行锁,等待其他事务释放排查锁等待,缩短持锁时间
批量任务跑一半失败,全部回滚单线程单事务处理大量数据按批次REQUIRES_NEW分批提交
事务提交后发通知,通知发出去了但数据没落库通知放在事务内异步执行,读到了未提交数据把异步调用移到事务提交后(afterCommit)
REQUIRES_NEW加了之后连接池耗尽新事务额外占用连接,连接池配置不足评估并发连接需求,适当调大连接池或减少并发

这张表也是我在团队内部分享时的核心内容。对照排查上表,基本能解决 90% 的事务范围相关疑难杂症。

5.2 几个值得记住的实操教训

最后分享几个在我自己的项目里踩出来的深刻教训。

第一个是事务方法命名要立规矩。我们团队后来约定:不带事务的方法命名里不写 create、update、delete 这类暗示写库的词,带事务的方法名一律加InTx后缀,比如createOrderInTx。命名一改,代码评审时一眼就能看出哪些方法会开事务,很多误加事务的情况自然就消失了。

第二个是注册事务同步的代码最好封装,不要在每个事务方法里写一大段匿名内部类。建议封装成工具方法,比如TransactionUtils.afterCommit(Runnable),内部自动捕获异常、记录失败日志。我们后来在回调里统一弹一个事务消息到本地消息表,由独立的消费线程负责真正的远程通知,实现了解耦和重试,这个改造带来的稳定性提升非常明显。

第三个是不要为了优化而把事务拆得支离破碎。事务的存在本身是为了保证一致性,如果一段逻辑确实需要原子性,强行拆开反而造出数据不一致的坑。优化的目标是把“不需要原子性”的部分挪出事务,而不是把“必须原子性”的逻辑拆散。判断的标准只有一个:如果这段操作在事务提交前失败,主数据是不是会出现不可接受的状态。如果是,就留在事务内;如果不是,就挪出去。

我在实际项目中体会到的最重要的一点:事务范围优化不是一次性的重构,而是一种持续的设计习惯。每当你往一个带事务的方法里多塞一个操作,都应该问自己一句——这个操作真的需要跟主数据同生共死吗?多问几次,很多坑在写代码的时候就能避开,而不是等线上事故来教你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询