
先说一个我印象很深的线上事故。当时接手一个支付对账模块某天晚上告警群里突然刷出一堆“数据库连接池耗尽”的报错紧接着下游渠道回调超时。我第一时间看慢SQL和线程栈发现一个很不起眼的service方法上面只加了一个Transactional方法体里却同步调了一次外部结算系统的HTTP接口。那一次HTTP响应花了差不多2.7秒而数据库连接就握在手里干等了2.7秒。高峰期几十个请求同时进来连接池瞬间被打穿。后来和团队复盘发现真正的问题不是“那个人不该写HTTP调用”而是Transactional这种声明式事务在复杂业务里太容易被当作免死金牌。我后来刷到过不少技术博客标题都是“为什么大厂一般不推荐使用Transactional”下面评论吵得很凶。有人说是“能不用就不用别滥用”也有人反怼“连事务注解都不敢用那还写什么业务代码”。站在一线写业务的角度看这两拨人其实都没说全。Transactional不是不能用而是它的默认行为、代理机制、回滚边界在真实系统里藏着太多“看起来对、实际上没生效”的坑。这篇文章我想把这些坑拆开来讲每一个都是我自己踩过或者帮别人排查过的。1. 教学场景里的“万能注解”和生产系统里的“定时炸弹”1.1 注解背后是你没看见的一层代理很多刚工作一两年的同学会有个错觉方法上加了Transactional这个方法里所有数据库操作就一定在一个事务里出错了就一定回滚。这种理解在上学做课设的时候完全够用但到了生产环境这种思维方式会坑死你。Transactional本质上靠的是Spring AOP。框架在启动阶段扫描到注解后会给目标Bean生成一个代理对象然后事务逻辑开启事务、提交、回滚都包在代理逻辑里。真正执行业务流程的是代理对象外部调用的也是代理对象。这层代理能正常工作前提是“调用一定要经过代理对象”。可一旦方法被同类内部调用比如this.doSomething()这行代码是直接走原始对象的方法根本不会经过Spring生成的代理。事务注解在这种情况下就是一张废纸——不报错、不提示、毫无反应数据该怎么写就怎么写出错了也不会回滚。1.2 生产环境的约束比课设多得多课设阶段一个项目只有三五张表、几十个并发数据库连接随便拿。但生产环境不一样连接池是有上限的数据库锁是有竞争面的方法调用链是有外部依赖的。Transactional一旦控制不好范围最容易踩的雷就是“事务过大”。以我自己经历为例一个方法里先做了权限校验然后拉起一堆查询结果做计算再分批insert最后再更新主表状态。如果整个方法直接盖一个Transactional那从方法进来到最后提交所有数据库连接、行锁、undo日志都得扛着。要是计算逻辑还顺带调个远程接口这把数据库连接就跟“人质”一样被扣在手里远程服务多慢它就得等多慢。大厂为什么不爱让你直接用不是大厂的人清高而是生产系统并发高、链路长一个不小心就会把整个连接池拖垮一个人写错全团队跟着熬夜。2. 默认回滚规则checked exception根本不会回滚2.1 只回滚运行时异常的“历史设计”Transactional的默认回滚策略是只对RuntimeException和Error回滚对于Exception受检异常是不回滚的。也就是说如果你在事务方法里写着throws Exception业务代码捕获到业务异常后直接向上抛框架不会认为这是“需要回滚”的信号。这个设计本身有它的历史原因——早期Spring参考的是EJB的事务模型事务回滚要显式调用setRollbackOnly()后来Spring做了简化但简化得有点激进。我不知道有多少人第一次看到Transactional(rollbackFor Exception.class)时是囫囵吞枣直接复制的反正我自己第一次写的时候就是网上抄的。但要命的是很多人抄了rollbackFor Exception.class之后依然没有理解回滚的真正含义。框架回滚的是“当前线程绑定的数据库连接上已经执行的SQL”它回滚不了“你调外部系统产生的副作用”。比如你现在事务里先给用户加了一笔积分然后调外部短信平台发了条短信发短信失败抛出业务异常事务回滚了积分但短信平台那边接口已经返回成功——两边就不一致了。这种事靠Transactional是永远解决不了的。2.2 异常被catch掉等于告诉Spring“一切正常”还有一个细节特别容易踩方法里用了try-catch把异常捕获了并且在catch里没有重新抛出或者只是打了个日志。这时候Spring的代理根本感知不到异常事务会照常提交。你以为回滚了其实数据早就写进去了。我自己有个习惯现在写涉及事务的代码对异常处理会非常保守要么在方法内部一个异常都不catch要么catch住之后必须重抛符合当前事务语义的异常。最怕的就是“catch后打log还不往外抛”这种代码是脏数据的温床。在checklist层面我建议排查代码时重点看三件事事务方法里的catch块把异常吞掉后有没有重新抛出运行时异常。事务方法是不是直接抛了受检异常而没有配置rollbackFor。事务方法内部是不是调用了同类方法导致内部方法的Transactional失效。这三条我见过太多次而且每条都会让线上数据变得“润物细无声”地错。3. THIS调用与事务失效代码没报错但事务根本没开3.1 代理模式下的“灯下黑”前面提过同类内部调用会绕开代理这里我想认真展开一次因为这个坑在代码Review里极难发现而且不像连接池耗尽那样会立刻爆炸它会悄悄产生半成功半失败的数据。看这段伪代码Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { // 插入订单主表 orderMapper.insert(dto); // 插入订单明细 dto.getItems().forEach(item - orderItemMapper.insert(item)); // 扣减库存 this.deductStock(dto.getSkuId()); } Transactional(propagation Propagation.REQUIRES_NEW) public void deductStock(Long skuId) { stockMapper.deduct(skuId); // 如果这里抛出运行时异常 throw new RuntimeException(库存不足); } }假如createOrder里的“扣减库存”用this.deductStock()调用那么deductStock上的Transactional(propagation Propagation.REQUIRES_NEW)是不会生效的因为它没有经过代理。结果是什么deductStock抛异常后createOrder这个整体事务最终也会回滚因为异常继续向上抛了看起来好像没事。但如果把场景换一下deductStock内部用try-catch把异常吞掉了只记录日志然后返回一个包含失败原因的对象。那么整个createOrder会正常提交订单创建成功但是库存没扣成。这种问题比不回滚更阴因为代码完全没报错却产生了账实不符。3.2 如何确认事务是否真的处于激活状态排查事务到底有没有生效不用靠猜。你可以在事务方法内部加一行代码打印当前线程是否绑定事务boolean txActive TransactionSynchronizationManager.isActualTransactionActive(); System.out.println(事务激活状态 txActive);如果方法上明明加了Transactional打印结果却是false那基本就是代理没生效或入口不对最常见的入口就是“被同类其他方法调用”。另一个简单办法是EnableTransactionManagement或者基于Spring Boot自动配置下在日志配置里开启事务日志级别org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG事务的commit/rollback都会直接打印。我排查现场问题的时候通常直接用日志定位事务边界。在这里多说一句这类问题在微服务代码里格外多发因为Service层经常被Controller直接调用Controller里往往又写了一堆业务逻辑。如果Controller里调用了Service的A方法A方法里又调用了本类的B方法而B上面才是Transactional那这个事务大概率是没用的。破解方式很简单把事务方法拆到另外一个Bean里或者把内部调用改成通过注入的Bean来调用。4. 连接池耗尽、锁等待与主从延迟一个注解引发的“雪崩链路”4.1 事务生命周期越短越好是铁律很多团队之所以禁止Transactional出现在复杂方法上最核心的顾虑就是“它对数据库连接的占用时间不可控”。事务一旦开启数据库连接就被当前线程独占直到提交或回滚。你无法精确控制方法里的耗时因为可能会有远程调用、可能会有排队等待、可能会有复杂的本地计算。我用过一个大单体项目里面很多方法都习惯性地加Transactional当时没有细想。后来压测发现数据库连接池配置的是50个连接但只要10个线程同时调用一个“事务里嵌套HTTP调用”的接口连接池就会出现饥饿。剩下的40个连接不是没空闲而是被卡在别的事务上整个系统像多米诺骨牌一样连环报警。这种事故的核心链路其实特别清晰方法A开启事务拿到一个数据库连接。方法A调用远程服务B线程阻塞等待B返回。方法A持有的数据库连接一直不释放。更多请求进入继续开启新事务继续等待远程服务。连接池无连接可用新请求全部排队等待超时、报错、失败。所以大厂对“事务内做远程调用”几乎是零容忍。事务边界内只允许做数据库操作远程调用必须放在事务外前置或后置这是铁律。4.2 行锁的放大效应与主从延迟除了连接池还有一个隐蔽的问题是锁等待。事务范围拉长之后同一行数据被多个请求同时更新的概率大大增加。第一个事务没提交第二个事务就只能等行锁释放。表面上看接口偶尔慢了一下实际是数据库端在排队抢锁。我印象很深的一次一个订单状态更新的接口事务方法里除了更新订单表还顺手更新了一张统计表。统计表上有多个业务在写导致行锁竞争非常激烈。事后分析那张统计表根本不需要和订单更新放在同一个事务里拆出去做异步更新或者用最终一致性都没问题。另外就是大事务对主从架构的影响。事务执行过程中产生的变更会记录到binlog事务提交后从库才会同步。如果一个事务写了特别多数据、执行时间又长主库提交延迟从库同步也跟着延迟紧接着基于从库的报表查询就会读到旧数据。有的DBA会直接给“超过多少行/超过多少秒的事务”写告警规则就是因为大事务对数据库的整体扰动太大了。5. 传播级别不是银弹REQUIRES_NEW也不一定是“新事务”5.1 事务传播的常见误读Transactional的propagation属性最常用的是REQUIRED和REQUIRES_NEW。简单的理解是REQUIRED有事务就复用当前事务没有就新建一个。REQUIRES_NEW挂起当前事务重新开一个新事务新事务提交/回滚不会影响外层事务。听起来很美但REQUIRES_NEW是有代价的它会挂起外层事务占用的数据库连接再申请一个新的数据库连接来开新事务。也就是说这个线程短时间会同时持有两个连接。在并发高的时候连接池的压力直接翻倍。更麻烦的是很多人以为REQUIRES_NEW内层事务失败后外层事务可以继续提交于是把“记录日志”“发送消息”这类操作塞到新事务里。但在MySQL这种数据库里连接池切换和锁的语义会让程序行为变得非常反直觉。比如内层事务先update了某一行外层事务之前也已经update了同一行那外层事务提交时可能会报“死锁重试”之类的问题。5.2 大厂倾向“放弃跨服务强事务”如果一个业务涉及多个微服务的数据变更很多新人会想把所有调用都放在一个事务里或者试图用REQUIRES_NEW来解决。实际上分布式事务本来就是独立服务之间的数据一致性问题不是加一个本地事务注解能解决的。与其费劲去协调多个数据库事务不如接受“最终一致性”这个现实。我见过很多团队最后落地的方式是一个服务只保证自己本地数据的事务性跨服务的数据一致性通过“本地消息表 定时任务/消息队列重试”来保证。服务A先在自己事务里写业务数据和一张消息表事务提交后再发MQ消费者拿到消息后处理处理失败就重试重试到头还是不成功再走人工运维。这套思路看起来不如“跨库强事务”干净但它在高并发场景下的稳定性和扩展性是最好的。6. 大厂折中方案编程式事务与更精细的事务边界6.1 TransactionTemplate把事务控制权从“魔法”拉回“显式”我现在写业务代码越来越倾向于用TransactionTemplate替代直接加Transactional。TransactionTemplate是编程式事务的一种它把“开启事务”“提交”“回滚”这几个动作显式放在代码里不再依赖AOP的隐性代理。最简单的用法是这样的Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void createOrder(OrderDTO dto) { // 事务外做远程调用、校验等耗时操作 String externalResult remoteService.call(dto); // 事务内只做纯数据库操作 transactionTemplate.execute(status - { try { orderMapper.insert(dto); dto.getItems().forEach(item - orderItemMapper.insert(item)); stockMapper.deduct(dto.getSkuId()); } catch (Exception e) { status.setRollbackOnly(); throw e; } return null; }); } }这个用法最大的好处是事务边界一眼就能看出来代码不会给后来的维护者留下“这个Service方法全都被事务包着”的错觉。而且TransactionTemplate.execute()内部的异常处理可以自己控制不像声明式事务那样有那么多隐式规则。6.2 事务方法只做“最小更新”如果在代码Review里看到一段事务方法超过了十几行我会下意识开始找“哪些操作其实不需要在事务里”。事务里只应该放真正需要原子性的数据库写操作查询、校验、组装数据、远程调用、事件发布全部应该挪到事务外面。举一个典型的重构例子重构前Transactional public void payOrder(Long orderId) { Order order orderMapper.selectById(orderId); if (order.getStatus() ! 1) { throw new BizException(订单状态异常); } RemoteResult result paymentClient.request(order.getAmount()); if (!result.isSuccess()) { throw new BizException(支付失败); } orderMapper.updateStatus(orderId, 2); }重构后public void payOrder(Long orderId) { Order order orderMapper.selectById(orderId); if (order.getStatus() ! 1) { throw new BizException(订单状态异常); } RemoteResult result paymentClient.request(order.getAmount()); if (!result.isSuccess()) { throw new BizException(支付失败); } // 事务里只剩一次状态更新 transactionTemplate.execute(status - { orderMapper.updateStatus(orderId, 2); return null; }); }重构后的代码在支付成功率和状态一致性上可能看起来“少了点原子性”但仔细想一下orderMapper.updateStatus只需要保证单条SQL自己原子就可以了——单条UPDATE本身就是原子的。前面那些校验和远程调用根本不需要跟UPDATE放在同一个事务里。重构后数据库连接被占用的时间缩短了一个数量级这才是大厂愿意看到的代码。6.3 事务边界下沉到数据访问层还有一种常见做法是把事务边界从Service层下沉到独立的仓储方法或者一个专门的事务脚本类里面。服务里不同方法可以按需调用不同的事务脚本而不是一个Service所有方法共享一个事务边界。我自己实践之后的感觉是事务脚本化的坏处是代码粒度变细了类变多了但好处极其明显每个事务的边界被单独审视很难出现“谁都往里加代码最后整个方法全是事务”的情况。配合上单测每个事务脚本的行为可以直接验证而不需要启动整个Spring容器来试。6.4 用测试“戳穿”事务假象不管用声明式还是编程式我都建议至少在核心写路径上补一个回滚测试。方法很简单在一个测试方法里开启事务并插入一条数据然后主动抛异常断言最后数据库里查不到这条数据。Spring Test里可以用Transactional标配的测试回滚能力但那是另一个层面的“测试注解”别和业务代码搞混。如果要验收线上“事务到底有没有生效”我前面提过的TransactionSynchronizationManager.isActualTransactionActive()也可以写进断言里。有些项目甚至会在关键写操作前加一行类似“如果当前没有事务就抛出异常”的防御逻辑宁可中断也不要“假事务”悄悄提交。写在最后的一些体会我现在的习惯是写新的业务方法时先反问自己这个方法真的需要事务吗如果需要我能不能用编程式事务把边界写清楚事务里能不能不碰远程调用如果不能那我宁可牺牲一点跨步骤的一致性也不要拿数据库连接去赌别人的响应速度。Transactional本身没有原罪它在很多场景下依然是最简洁的答案——比如一个独立的、没有远程调用的、纯数据库写入的服务方法声明式事务一行注解就能写完。但“简洁答案”不等于“通用答案”业务规模越大、调用链越长、并发越高这种隐式魔法带来的不确定性就越让人不安。与其等事故发生后去排查一个“看起来已经加了事务”的诡异问题不如从一开始就让事务边界在代码里“肉眼可见”。