
我第一次被问到“为什么大厂一般不推荐使用 Transactional”时愣了一下。后来在新东家翻了核心业务系统的代码发现一个耐人寻味的现象真正跑在高并发、资金相关、订单核心链路上的方法绝大多数没有直接在上面对 Transactional 注解要么移到独立的中间层要么干脆用 TransactionTemplate 把事务边界写进代码里。没人明文规定“禁用”但大家不约而同地选择绕开它。其实 Transactional 本身不是坏东西它是 Spring 对声明式事务非常成熟的抽象。问题恰恰出在“注解”这两个字上——它把事务的边界感藏了起来。开发时只用一行注解看着清爽但代码跑起来之后连接什么时候占用、锁什么时候释放、事务什么时候提交全部变成了肉眼不可见的状态。一旦有异常或并发问题排查链会拖得非常长。这篇文章我想结合自己踩过的坑把 Transactional 在大厂场景里被冷落的深层原因拆开聊。内容会覆盖事务失效、长事务、回滚边界、可测试性、分布式演进以及编程式事务的替代写法。适合对 Spring 事务已经有基础认知、但还没遇到过生产事故的读者也适合准备面试时想把这个话题讲深一层的人。1. 注解声明事务的“透明陷阱”为什么看起来简单用起来处处是坑1.1 事务失效的四个高频场景不是注解没生效是你根本没有走代理很多人对 Transactional 的理解是“只要我在方法上加了注解这个方法里所有的数据库操作就都在同一个事务里”。但这个认知有个前提你调用的对象必须是 Spring 管理下的代理对象调用链必须经过代理。一旦这个前提被破坏注解形同虚设。我自己在实际项目中遇到最多的事务失效场景排第一的是同类的自调用。例如Service public class OrderService { Transactional public void createOrder(Order order) { saveOrder(order); sendMessage(order.getId()); // this 调用不是代理调用 } Transactional(propagation Propagation.REQUIRES_NEW) public void sendMessage(Long orderId) { messageDao.insert(orderId); } }这段代码表面上看起来是 sendMessage 单独一个事务实际上它根本没有生效。因为 sendMessage 是 this 调用的没有经过 Spring AOP 的代理对象事务切面压根不会介入。最终结果是 sendMessage 里的操作跟着 createOrder 的事务一起走或者创建订单失败时消息记录也被一起回滚业务表现完全不符合预期。排第二的是私有方法。Spring 默认使用的 CGLIB 代理对 private、final 方法无法增强。之前有一个线上小故障排查了很久最后发现是同事在私有方法上加了 Transactional还特意配置了 REQUIRES_NEW 希望“强制走独立事务”结果自然全部失效。排第三的是异常被吞。这个太常见了我后面单独用一小节说。排第四的是数据库本身不支持事务比如早期项目里还有 MyISAM 引擎的表加了注解也不会回滚。这个现在少见但在老系统里仍然存在。还有一种容易忽略的细节在多线程环境里子线程中执行的事务操作不会和主线程处于同一个事务中因为默认事务传播只对同一个线程内的调用有效。1.2 异常与回滚的边界默认回滚策略为什么让很多人翻车Spring 声明式事务的默认回滚规则是只对 RuntimeException 和 Error 回滚checked exception 不会触发回滚。这么设计的理由是Spring 认为受检异常往往表示“可以处理的业务预期”不应该把整个事务全部推翻。但很多业务场景恰恰相反。比如用户下单先锁库存、再扣余额、最后发积分。如果“积分服务不可用”被实现成了受检异常而你没有显式指定 rollbackFor Exception.class那么库存锁了、余额扣了偏偏事务提交了后面又因为积分发放失败让用户反复重试一重试就又扣一次钱。这种问题在微服务环境下尤其致命因为远程接口跨网络的异常大多数会被包装成受检异常。更隐蔽的是“异常被 catch 住但没重新抛出”的情况Transactional public void createOrder(Order order) { try { inventoryService.deductStock(order.getProductId()); } catch (StockNotEnoughException e) { log.warn(库存不足订单创建失败, e); // 没有继续抛出事务不会回滚 } orderDao.insert(order); }这段代码里库存扣减抛了异常但被 catch 住了。事务管理器根本没有感知到任何异常等到方法正常返回事务会被干净地提交掉。结果就是库存扣减失败但订单还是被创建了出来后面库存对账怎么都对不上。回滚边界问题在大厂里一直是 Code Review 的重点。因为声明式事务的回滚行为不是写在业务代码里的而是由方法调用链和异常传播隐式决定很多人根本意识不到自己在“修改事务边界”。我之前在团队里做过一次统计事务相关的 bug 中有接近四成属于“catch 了异常没有重新抛出”导致的静默提交。2. 长事务才是真正的生产事故源头连接、锁、数据库一起遭殃如果说事务失效是“歪打正着”地产生错误数据那么长事务就是直接冲击系统可用性的杀手。它不像事务失效那样有个明确的错误报出来而是缓慢、持续地把系统拖垮。这种问题在开发环境、低并发环境几乎测不出来一到线上流量上来就原形毕露。2.1 事务内做 RPC 和批量操作系统是怎么一步步卡死的先看一段典型的反面代码Transactional public void syncGoodsData(ListGoods goodsList) { for (Goods goods : goodsList) { goodsPriceRemoteService.transformPrice(goods); // 每次循环一次 RPC goodsMapper.update(goods); } }这段逻辑本身不算复杂把一批商品的价格同步到本地数据库。但因为加了 Transactional整段方法执行期间数据库连接一直被这个事务持有。RPC 调用占用的时间、网络超时重试的时间全部算在连接占用时间里。假设商品有 1000 条每一次 transformPrice 调用平均耗时 300 毫秒光 RPC 就拿走了 300 秒。第一条数据更新后占用的行锁在整个事务提交前都不会释放。如果这批商品里恰好包括了线上热点商品那么所有涉及这些商品的订单操作都会在锁上排队。数据库连接池如果是 50这个定时任务每次同步占住 1 个连接 5 分钟再有一些慢查询、报表任务把连接占住连接池很快被耗尽。后续所有正常业务请求拿不到连接系统表现就是接口大面积超时。更隐蔽的问题在于很多性能监控工具只会告诉你“数据库等待时间很长”不会告诉你“真正的瓶颈在事务内的一次外部 RPC”。我实际排查过一次就是这种表现DBA 说数据库没有慢查询但事务平均执行时间高达几十秒最后通过 information_schema.innodb_trx 表看到事务状态一直处于 runningurl 列上显示的线程还在等一个 HTTP 调用返回。在数据库事务里做外部调用是最需要警惕的反模式不只是 RPC包括 HTTP 请求、发送 MQ、文件读写、第三方 SDK 调用都不应该放在事务范围内。事务应该只包裹“必须同步成功、失败必须一起回滚”的纯数据库操作外部调用要么放在事务之前要么放在事务成功提交之后用对账、本地消息表、重试队列去处理最终一致性。2.2 从监控指标定位长事务事前防御比事后救火靠谱长事务不是不能预防关键是要把监控做起来。MySQL 里可以直接查当前活跃事务SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_running_seconds, trx_rows_locked, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;这条 SQL 能快速看到有哪些事务已经跑了很久。我在生产环境通过它抓到一个跑了 400 多秒的事务顺着 trx_mysql_thread_id 找到对应连接再从连接反向定位到业务线程堆栈很快锁定了是一个同步接口在事务里做了两次外部调用每次等超时 30 秒循环了好几次。应用层也可以配置 Spring 的事务超时时间防止事务无限制地挂下去Transactional(timeout 5) public void syncGoodsData(ListGoods goodsList) { // 超过 5 秒未提交事务直接抛异常回滚 }超时是最后一道防御而不是首选方案。核心还是要控制事务方法本身的执行时间批量操作拆批、外部调用移出事务范围、大结果集分页处理。这几个原则比任何监控工具都重要。3. 隐性成本与团队协作为什么大厂更看重“代码可预期”3.1 声明式事务的可测试性困局声明式事务有一个很少被公开讨论的问题很难写单元测试。它不是不能测而是你很难在脱离 Spring 容器的情况下验证注解的行为。比如你想测试“库存扣减失败时整个事务回滚”你需要一个真实的数据源、一个能被代理拦截的 Spring 上下文、一套能注入异常 mock 的方案。为了验证一行业务代码你可能要搭一整套集成测试环境。如果事务逻辑藏在几十个方法的调用链里测试还要复制整个调用栈。而编程式事务把边界写在方法里测试时可以直接对 TransactionTemplate 的 execute 回调做验证传入一个必然抛异常的操作断言数据库回滚到位。或者干脆用一个假的 TransactionManager 来记录事务提交与回滚的次数。这种可测试性差异在核心系统里是实打实的工程成本毕竟大厂最不缺的就是复杂业务和严苛的回归要求。3.2 多人协作下的事务边界混乱另一个大厂场景特有的问题是“边界被无限放大”。一个方法加上 Transactional 之后它内部调用的所有方法、所有 Mapper 操作全部共享同一个事务。如果这个方法是 Service 层暴露出来的入口并且在业务迭代里不断被新需求扩展它的事务范围会像滚雪球一样越滚越大。我经历过一个典型的例子某个订单聚合服务方法最初只有“保存订单 扣减库存”两个操作事务范围很正常。后来接入了优惠券、积分、用户等级、物流预占每一次迭代都往方法里塞一段新逻辑所有操作都隐式落在同一个大事务里。到后面一次订单操作涉及十几张表事务提交时需要同时持有大量锁并发稍微一上来就锁等待超时。最麻烦的是没人能一眼看出这个方法的事务边界到底覆盖了多少操作。因为边界不在代码里在注解上而注解只告诉你“有事务”不告诉你事务跨了多少行代码。在这种代码结构下任何一次重构都可能无意中把事务边界改掉引发连锁故障。3.3 业务复杂性增长从单库事务到分布式事务的必然演进大厂的业务几乎不可能长期停留在一个数据库实例上。分库分表、微服务拆分是常态。而 Transactional 本质上依赖的是本地数据库事务它无法跨越多个数据库、多个服务实例去保证一致性。最典型的问题一个用户下单操作要写订单库、扣用户钱包库、给积分库加积分。三个库来自三个独立应用每个应用内部可以各自用 Transactional但整体上没有任何机制保证“三个库全部成功或全部失败”。如果订单库提交了积分库写失败了业务就出现了不一致。这时候有人会说用 Seata 这类分布式事务框架不就行了可以但分布式事务和 Transactional 配合起来反而更小心。Seata 的 AT 模式虽然也通过注解标记全局事务但它需要全局锁、分支事务资源长时间占用事务时间越长数据库锁粒度冲突越明显。在高并发扣减类场景里全局事务的性能成本极高。很多大厂最终对一致性要求没那么极端的业务宁可选择本地消息表、可靠事件、Saga 等从业务层解决最终一致性的方案也不愿意在一个长事务注解上赌性能。4. 工程上的替代方案编程式事务如何把控制权真正拿回来既然声明式事务有这么多问题那大厂日常是怎么写事务的我的经验是核心链路上大量使用编程式事务尤其是 TransactionTemplate。它把事务的边界、提交点、回滚条件全部显式地摆在代码里读代码的人一眼就能看到事务从哪里开始、到哪里结束。4.1 TransactionTemplate 的完整用法先看基础设施怎么搭Configuration public class TransactionConfig { Bean public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) { TransactionTemplate template new TransactionTemplate(transactionManager); // 建议在模板级别就约束默认行为 template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); template.setTimeout(10); // 事务秒级超时防止长事务 template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); return template; } }然后在业务代码里注入使用Service public class GoodsSyncService { private final TransactionTemplate transactionTemplate; private final GoodsMapper goodsMapper; private final FailLogService failLogService; public GoodsSyncService(TransactionTemplate transactionTemplate, GoodsMapper goodsMapper, FailLogService failLogService) { this.transactionTemplate transactionTemplate; this.goodsMapper goodsMapper; this.failLogService failLogService; } public void syncGoodsInBatch(ListGoods goodsList) { // 按批次拆分事务避免一个长事务占住连接和锁 for (ListGoods batch : Lists.partition(goodsList, 100)) { try { transactionTemplate.executeWithoutResult(status - { for (Goods goods : batch) { goodsMapper.update(goods); } }); } catch (DataAccessException ex) { // 单批次失败不影响其他批次记录失败明细人工重放 failLogService.record(batch, ex); } } } }这里每个批次的 100 条商品在各自独立的事务里提交。这样一来单个事务占用连接和锁的时间就被控制在了非常小的范围内。即使某一批失败也只是失败那 100 条其他批次照常处理。这种“部分成功部分失败”的语义在某些业务里是有意为之的因为它比“全部回滚然后重跑全部任务”的代价小得多。如果需要在事务里拿返回值可以用 executepublic OrderResult createOrderWithResult(Order order) { return transactionTemplate.execute(status - { orderMapper.insert(order); int rows stockMapper.deduct(order.getProductId(), order.getCount()); if (rows 0) { // 库存不足手动标记回滚 status.setRollbackOnly(); return null; } return new OrderResult(order.getId(), SUCCESS); }); }注意 status.setRollbackOnly() 是编程式事务里控制回滚非常重要的手段。你可以在事务里做任意业务判断认为不该提交时就手动标记Spring 在 return 前看到 rollbackOnly 标记就不会提交。4.2 声明式 vs 编程式什么场景选哪个维度Transactional 声明式TransactionTemplate 编程式事务边界可读性弱注解只标记方法级别强代码中显式包裹事务失效风险高代理机制依赖多个条件低调用逻辑直观异常回滚策略默认只回滚运行时异常容易误判可精确控制每个分支的回滚方法内分批事务难实现一个方法只能一个事务容易实现循环中按批次执行代码侵入性低一个注解搞定中需要注入模板、写包裹逻辑是支持事务传播行为配置支持支持不需要把声明式事务一棒子打死。它最适合的场景是单方法内做了简单的、小范围的数据库操作事务时间极短且没有外部调用、没有批量循环、没有复杂异常。比如一个简单的用户资料修改方法里就两三行 update用 Transactional 完全没问题。但一旦方法开始膨胀或者需要拆批、需要嵌套才能完成一致性保障我就会毫不犹豫地改成编程式事务。4.3 与事务切面共存时的注意事项还有一个容易踩的坑如果代码里同时使用了声明式事务注解和 TransactionTemplate要注意传播行为。比如外层方法声明了 REQUIRED内层 TransactionTemplate 默认也是 REQUIRED那么两者会合到同一个事务里。如果外层不加注解、只有 TransactionTemplate 是事务那么 TransactionTemplate 执行范围内的就是独立事务。我最推荐的组合是底层 DAO / 通用方法不带任何事务注解业务层在需要时直接用 TransactionTemplate 明确开启。这样整个系统的事务逻辑统一收口在少数几个方法里代码评审时只需要盯着这几个方法就够了。遇到性能瓶颈也能很快定位到具体事务不会像注解声明式那样事务藏在一个几十层深的调用链里无从下手。5. 一次线上事故的完整复盘一个 Transactional 引发的连环雪崩理论说再多不如复盘一个真实案例。虽然细节做过脱敏但整体链路非常典型。5.1 事故当天的时间线那是某个工作日下午 2 点左右用户反馈“订单确认页转圈打不开”紧接着监控系统报警订单服务的数据库连接池活跃连接数飙升到上限接口平均响应时间从几十毫秒涨到十几秒。起初以为是数据库慢查询或者磁盘 IO 问题。DBA 查了慢查询日志没有发现异常的大 SQL但发现大量事务一直处于 running 状态。逐个查看这些活跃事务发现事务内容集中在某几个订单号的 update 操作上trx_started 时间已经远超正常范围。也就是说不是 SQL 慢而是事务迟迟不提交导致行锁一直不释放所有依赖同一行数据的更新操作全部排队。顺着活跃事务的线程 ID 查业务线程堆栈定位到了如下代码Transactional public void settleOrderBatch(ListLong orderIdList) { for (Long orderId : orderIdList) { Order order orderMapper.selectLockByOrderId(orderId); order.setStatus(SETTLED); orderMapper.updateById(order); pointRemoteService.grantPoints(order.getUserId(), order.getAmount()); // 外部 RPC } }这个方法是个定时任务每天下午统一结算一批订单。因为定时任务的定时器调用了这个入口方法所以它一开始就有事务。问题是业务迭代时有人在循环里塞了积分发放的 RPC 调用RPC 超时时间设定为 10 秒。线上积分服务刚好在那个时段进行发布响应变慢每次 grantPoints 都等满了 10 秒才超时。一批订单几百个循环下来一个定时任务的事务挂了几分钟行锁全部压在订单数据上前端所有落单操作都被堵住了。5.2 根因分析问题不在注解本身这个事故的根本原因当然不只是 Transactional。真正的问题有三个层次第一事务方法内做了远程调用第二批量循环里的事务范围过大第三RPC 没有做合理的超时控制也没有拆出事务外。但 Transactional 在这里扮演了一个放大器因为它把整个方法悄悄包成了一个大事务连循环中第一个订单的更新操作能不能提交都要等最后一个 RPC 返回才知道。如果是编程式事务拆成每单一个事务即使某一单的 RPC 超时也只有那一单的行锁短暂占用其他订单的正常操作不受影响事故影响面会小很多。从排查角度复盘整个过程的定位链条是应用超时 - 数据库连接池打满 - 活跃事务堆积 - innodb_trx 定位到事务 - 线程堆栈定位到业务方法 - 发现 RPC 调用在事务内。每一步都不难但加起来花了一个多小时。如果代码本身没有把事务藏起来这起事故 10 分钟内就能定位。5.3 事后修复与团队约定修复方案分三步。第一步立刻去掉 settleOrderBatch 方法上的 Transactional改成 TransactionTemplate并且把事务粒度拆到单个订单的 update 操作。积分发放的 RPC 完全移出事务范围放在事务提交成功后执行如果失败就写入本地补偿表。第二步给所有外部调用统一配置更短的超时时间不合理的超时配置一律重审。第三步团队内部约法三章任何事务方法内禁止远程调用一个事务方法原则上只处理一张核心表批量新增/更新必须做批次切分。后面两条写进了代码规范评审的时候会专门检查。6. 规范建议如果团队暂时还是决定保留 Transactional该怎么降低风险完全没有必要在所有项目里强行禁用 Transactional。即使是大厂也会在非核心链路、管理后台、低频写入场景继续用它因为开发效率确实高。但如果要用我建议至少建立这样几条底线规范。第一事务方法内部禁止远程调用。这一点无论用什么事务方案都是死规矩。可以在 Code Review 环节通过静态扫描强制检查也可以利用代码评审插件来拦截事务方法体里的 RPC 调用语句。第二事务方法必须设置 rollbackFor Exception.class。虽然默认只回滚运行时异常有它的设计理由但在业务系统里受检异常通常意味着“这事没办成”业务上通常也需要回滚。与其让每个开发都理解这一层不如统一约定事务方法全部显式写 rollbackFor不允许依赖默认值。第三事务方法要短小。如果方法体超过几十行或者包含循环、批量操作、消息发送、文件处理就不适合用声明式事务。改成 TransactionTemplate 把事务范围进一步收窄。一个事务方法只做一件完整的事情这句话听起来像废话但代码里能真正做到的人不多。第四注意传播行为。REQUIRES_NEW、NESTED、MANDATORY 这些每个都要想清楚再写。我见过最乱的一种代码是A 方法用 REQUIRED 调了 BB 又用 REQUIRES_NEW 开了新事务最后业务逻辑要求“AB 要么都成功要么都失败”结果因为 B 已经提前提交A 失败时根本拉不回 B 的数据。事务传播行为的选择本质上就是在定义业务一致性边界需要非常谨慎。第五无论用哪种事务方案都要在监控里加事务时长指标。Spring 的 PlatformTransactionManager 可以包一层统计每个事务从开始到提交/回滚的耗时超过阈值就告警。这个指标比接口耗时更能反映数据库事务的健康度强烈建议有条件的团队加上。我在实际项目里最后形成的一套做法是所有核心链路上的事务方法全部改写成 TransactionTemplate方法命名里直接带上原语比如 createOrderInTransaction、batchDeductInTransaction让人一眼就知道这里有事务边界。管理后台和低频操作保留 Transactional但严格遵守上面的五条底线。这样既保证了核心系统的稳定性又没有让开发效率沦丧。几年实践下来事务相关的生产事故确实降到了很低的频率。如果你也正准备在团队里梳理事务规范可以从这几条开始然后结合自己的业务不断打磨出更细的约定。