FEATURED · 精选文章

大厂为什么不推荐@Transactional?编程式事务才是可控之道

发布时间 / 2026/9/8 6:45:54
来源 / 创域科博编辑部
栏目 / 资讯中心
大厂为什么不推荐@Transactional?编程式事务才是可控之道 1. 一个注解引发的“线上事故”联想先还原一个我见过很多次的场景某个服务在高峰期突然接口超时飙升数据库连接池被打满慢SQL堆了一屏。排查到最后问题往往不是SQL本身有多慢而是某个方法上安安静静躺着一个Transactional。方法确实在跑事务也确实开着但这个方法中间还调了一次外部的HTTP接口——好一点的等个几百毫秒差一点的等个两三秒超时。数据库连接就这么被攥在手里不放连接池一旦耗尽整个服务就像堵死的停车场后面的车全进不来。这种问题在大厂晋升答辩里几乎是保留曲目也是为什么很多大厂代码规范里明确写着“谨慎使用Transactional”甚至直接规定“新代码禁止使用声明式事务”。不是这个注解一无是处而是它在复杂业务场景下的隐含成本远远超过了大多数人写第一行代码时的那点方便感。Transactional本质上是Spring声明式事务的入口依赖AOP动态代理在方法前后自动开启、提交或回滚事务。写起来确实很爽一行注解就解决事务问题不用手动管理连接、提交、回滚。但问题恰恰出在这个“爽”字上——它把关键的事务边界藏到了注解背后很多人根本看不见事务是什么时候开始、什么时候结束的更看不见这个过程中数据库连接到底被占用了多久。这篇文章我想把这件事彻底讲透为什么大厂普遍不推荐Transactional、声明式事务的表面便利下藏着哪些隐性成本、典型的生产事故是怎么发生的、以及大厂通常用什么方案来替代它。不是叫你别用而是要搞清楚什么时候能用、什么时候必须绕开。2. 先从原理说起Transactional到底是靠什么机制在工作2.1 代理模式与事务拦截器Spring的声明式事务并不是魔法它是通过AOP代理实现的。Spring容器在启动的时候会扫描带有Transactional注解的Bean并为它们生成代理对象。当外部调用这个Bean的方法时实际进去的是代理对象代理对象里的TransactionInterceptor会先执行一些前置逻辑再真正调用你写的目标方法最后执行后置逻辑。这个过程对应到代码层面大致是// 伪代码展示TransactionInterceptor的核心流程 public Object invoke(MethodInvocation invocation) throws Throwable { TransactionInfo txInfo createTransactionIfNecessary(); Object retVal; try { retVal invocation.proceed(); } catch (Throwable ex) { completeTransactionAfterThrowing(txInfo, ex); throw ex; } commitTransactionAfterReturning(txInfo); return retVal; }当你调用orderService.createOrder()时实际调用链是调用方 - 代理对象 - TransactionInterceptor - 目标方法 - 数据库操作这也是最重要的一句话Transactional只有经过代理才能生效。而Spring容器默认对接口使用JDK动态代理对类使用CGLIB代理Spring Boot 2.x后默认使用CGLIB。无论哪种都要求调用必须穿过代理层。一旦这个前提被破坏事务就静默失效。2.2 每个事务的背后都是一条物理数据库连接事务是绑定在数据库连接上的。Spring的DataSourceTransactionManager在执行doBegin时会从DataSource里取得一个连接把autoCommit改成false然后把这个连接绑定到当前线程的ThreadLocal上。后续这个线程执行的所有SQL拿到的都是同一个连接。这正是事务能够跨多个DAO生效的根本原因——连接是同一个事务上下文就在同一个连接上。你把这个机制记在脑子里再去看大多数线上问题就清晰了事务没结束连接就不会归还连接池。事务里执行的时间越长这条连接被占用的时间就越长。并发高的时候每多一个长事务连接池的可用连接就少一个。一个事务从开启到提交维系的核心资源就是数据库连接。而MySQL默认的innodb_lock_wait_timeout是50秒一旦多个事务互相等待锁极容易把整个连接池拖垮。2.3 默认的传播行为与回滚规则Transactional默认的传播行为是REQUIRED如果当前没有事务就新建一个如果已有事务就加入当前事务。这也是大厂头痛的地方之一——当多个事务方法嵌套调用时它们会合并成一个物理事务任何一个环节抛异常整条链路上的数据全部回滚。默认的回滚规则是只在抛出RuntimeException或Error时回滚受检异常如Exception的直接子类默认不回滚。这个设计太容易被坑了。很多人写代码习惯throw new Exception(xxx)结果代码没报错数据却只写了一半。以上三点构成了Transactional的全部底层逻辑。你把这些原理弄明白后面所有“为什么不推荐”的讨论就都串起来了。3. 声明式事务的典型问题四个坑都是事故级别的3.1 类内部自调用注解静默失效这是最隐蔽、也最容易踩的坑。比如你写了一个OrderService里面有个公开方法createOrder()它调了同类里的另一个updateStock()方法而updateStock()上面标了Transactional。Service public class OrderService { public void createOrder(OrderDTO dto) { // 这里省略订单创建逻辑 updateStock(dto.getProductId(), dto.getQuantity()); } Transactional public void updateStock(Long productId, Integer quantity) { // 省略库存扣减SQL } }createOrder()里调用updateStock()本质是this.updateStock()——也就是通过当前对象直接调用不会经过Spring生成的代理对象。代理都进不去TransactionInterceptor压根没有机会执行事务自然就没有开启。这个问题的危险之处在于它不报错、不警告业务看起来完全正常只有出问题时才有人发现库存SQL和前面几条订单SQL不在同一个事务里。我见过不止一个团队排查数据不一致问题排查到凌晨最后找到的根因就是这么一行this.xxx()。解决方案有几种把updateStock拆到另一个Service类里让调用必须走代理。在OrderService里注入自身用self().updateStock(...)调用。把事务方法直接改成编程式事务不依赖代理。3.2 事务里做RPC或HTTP调用连接被白白占住第二种典型的坑是事务方法里调外部服务。比如你在Transactional方法里调用了一个FeignClient或者RestTemplateTransactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto); // 假设这里调用外部支付接口网络耗时1.5秒 paymentClient.pay(dto.getPayAmount()); stockMapper.deduct(dto.getProductId()); }这段代码表面上没问题但你想过数据库连接在这1.5秒里在干什么吗它什么都没干但它也没被释放。一个请求这样就算了如果这个接口的QPS是50那连接池里平均有75条连接被这种“等网络”的事务占住。连接池一般也就配50到100个一冲就可能直接耗尽。而且更致命的是分布式一致性外部支付接口已经扣款成功了本地事务后面如果SQL执行失败本地数据回滚了但外部服务回滚不了。你以为Transactional保证了“要么全成功、要么全失败”但实际上它只能保证本地数据库这一个维度的原子性对外部系统的操作它管不了一点。大厂对这个问题的约束通常很硬性事务方法内禁止远程调用。如果确实需要事务和外部操作同时存在先调用外部服务再开启事务执行本地写操作或者落地本地消息表做最终一致性。3.3 try-catch吞掉异常回滚静默失败Transactional的回滚依赖异常被TransactionInterceptor捕获。如果你在方法内部把异常自己吃掉了Spring根本不知道这里出了问题事务就会正常提交。Transactional public void transfer(String from, String to, BigDecimal amount) { try { accountMapper.deduct(from, amount); accountMapper.add(to, amount); } catch (Exception e) { log.error(转账失败, e); // 没有把异常重新抛出来 } }这段代码就是典型的“薛定谔的转账”从账户扣了钱加钱失败了日志里躺着一条error但事务照样提交。用户的钱凭空消失。正确的做法有两种要么别自己捕获异常让异常直接冒泡到TransactionInterceptor。要么在catch块里显式标记回滚比如TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()或者干脆改用编程式事务自己控制。很多团队踩过这个坑以后直接在规范里要求“使用Transactional时禁止try-catch”。3.4 大事务与不合理的回滚边界还有一类问题不是某个瞬间爆发的bug而是运行一段时间以后慢慢显现的性能劣化。比如定时任务里给两千条数据逐条做更新整个方法一个Transactional包到底或者批量导入时把全量数据放在一个事务里执行。大事务带来的问题很多持有数据库连接时间过长连接池压力大。锁定的行太多其他事务的更新等待时间变长。产生大量的Undo LogMySQL的purge线程来不及清理导致undo表空间膨胀。binlog和redo log写入量大主从同步延迟升高。回滚开销大万一中途报错回滚需要的时间比执行本身还长。事务的原则其实是“能短则短”只把必须保证原子性的那些操作放进去。很多人却习惯把一个方法从头到尾都包在事务里因为写着方便。这就是声明式事务的另一个隐患——它太方便了以至于让人懒得思考事务边界到底应该划在哪里。4. 大厂的替代方案编程式事务是更可控的底气4.1 TransactionTemplate最推荐的替代方式编程式事务不等于回到JdbcTemplate手动管理连接那个原始时代。Spring提供了TransactionTemplate它既保留了模板方法模式的简洁性又把事务边界和提交回滚逻辑都摆在明面上。先看怎么配置Service public class OrderService { private final TransactionTemplate transactionTemplate; // Spring Boot中可以直接注入PlatformTransactionManager public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); // 可以按需设置传播行为、隔离级别、超时时间 this.transactionTemplate.setTimeout(5); } public void createOrder(OrderDTO dto) { // 事务外可以放心做远程调用、计算、校验 Integer result paymentClient.pay(dto.getPayAmount()); transactionTemplate.execute(status - { try { orderMapper.insert(dto); stockMapper.deduct(dto.getProductId(), dto.getQuantity()); return Boolean.TRUE; } catch (DataAccessException e) { // 显式设置回滚 status.setRollbackOnly(); throw e; } }); } }把第一种自调用场景用TransactionTemplate改掉以后事务边界就是execute这个Lambda块的边界一眼就能看清哪些操作在事务里、哪些不在。事务里出了问题怎么回滚、事务外还能做什么代码本身就把答案写出来了完全不需要靠对代理机制的理解去推断。事务提交的时机也非常明确execute方法正常返回时提交抛出异常时回滚。代码里如果你主动调setRollbackOnly()哪怕方法正常返回也照样回滚。我在项目里最常干的写法是:public boolean transfer(String from, String to, BigDecimal amount) { return transactionTemplate.execute(status - { try { accountMapper.deduct(from, amount); accountMapper.add(to, amount); return true; } catch (Exception e) { status.setRollbackOnly(); log.error(转账失败from{}, to{}, amount{}, from, to, amount, e); return false; } }); }这种写法的好处是一个Plain Old Java方法事务逻辑自己控制不再依赖Spring代理类内部随便调用也不会失效。4.2 手动控制事务管理器极端场景下的完全掌控TransactionTemplate已经覆盖了绝大多数场景但有些特殊场景需要更细的粒度。比如你需要在事务执行的过程中记录一条日志而这条日志表即使事务回滚也必须写入那就必须在事务内部单独开一个新事务。这时候可以手动使用PlatformTransactionManagerService public class OrderService { private final PlatformTransactionManager transactionManager; public void createOrderWithLog(OrderDTO dto) { DefaultTransactionDefinition def new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); def.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT); TransactionStatus txStatus transactionManager.getTransaction(def); try { orderMapper.insert(dto); stockMapper.deduct(dto.getProductId(), dto.getQuantity()); // 在事务中单独记录一条日志使用REQUIRES_NEW传播行为 insertOperationLog(dto); transactionManager.commit(txStatus); } catch (Exception e) { transactionManager.rollback(txStatus); throw e; } } private void insertOperationLog(OrderDTO dto) { TransactionTemplate logTemplate new TransactionTemplate(transactionManager); logTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); logTemplate.execute(status - { operationLogMapper.insert(dto.getUserId(), CREATE_ORDER); return null; }); } }不过这种写法代码量明显变大团队里不常使用真用到的时候通常都是log、审计、消息这类需要“事务内写但不能被主事务回滚”的场景。4.3 声明式与编程式怎么选一张表看清差异对比维度Transactional声明式TransactionTemplate编程式代码侵入性低一个注解搞定中需要包装业务逻辑事务边界可见性低隐含在AOP拦截器里高Lambda块即边界类内部自调用失效事务静默不生效不受影响直接可用动态调整传播行为注解属性改代码才能变运行时可以动态设置精细控制回滚依赖异常类型和rollbackFor可以显式setRollbackOnly适合场景简单短事务、单机低并发复杂业务、跨服务、对边界敏感表格里虽然写了声明式适合简单场景但实操中很多大厂仍然倾向全部使用编程式哪怕是最简单的事务也写TransactionTemplate。原因很简单统一规范比讨论“这个场景能不能用注解”要省事得多。5. 分布式事务场景下的进一步思考单向事务思维是不够的5.1 注解只能保证本地事务不能保证全局一致很多团队在搞微服务之后仍然在每个Service方法上习惯性加Transactional但这种做法在分布式环境下的实际意义非常有限。订单服务扣库存、积分服务加积分、优惠券服务核销券三个服务各自有自己的数据库Transactional最多只能保证“自己库里的那几步操作”是原子的跨服务的数据一致性它根本无法保证。典型场景是本地事务提交了消息发给下游失败了或者消息发出去了但下游处理失败了这时候总数据还是不一致。这不是拆几个Service、调几个RPC就能解决的需要的是分布式事务方案。5.2 本地消息表与最终一致性更务实的落地方案分布式事务在互联网大厂里的实践远远没有教科书里写的那么理想化。强一致方案如两阶段提交在互联网场景下太重伤性能和可用性都扛不住。更多的时候大家采用的是最终一致性方案其中用得最多的是“本地消息表”或者“事务消息”。本地消息表的思路是把本地业务操作和“写一条待发送消息”放在同一个数据库事务里。事务提交成功后通过一个异步任务把消息发给MQ由下游消费消息继续处理后续业务。// 伪代码示例 public void createOrder(OrderDTO dto) { transactionTemplate.execute(status - { orderMapper.insert(dto); // 在同一事务里写一条本地消息 outboxMessageMapper.insert(new OutboxMessage(dto.getOrderId(), ORDER_CREATED)); return null; }); // 事务提交之后由定时任务或binlog监听把消息发到MQ }这个方案不需要引入额外的分布式事务中间件可靠性也足够高。核心原则是本地事务与消息写入强一致消息投递与消费靠重试保证最终一致。5.3 数据库锁与并发控制有些问题不是事务能解决的还有一个容易被混淆的概念是把Transactional当成并发控制工具。比如扣库存场景代码长这样Transactional public void deductStock(Long productId, Integer quantity) { Product product productMapper.selectById(productId); if (product.getStock() quantity) { throw new BusinessException(库存不足); } product.setStock(product.getStock() - quantity); productMapper.updateById(product); }两个并发请求同时读到stock10都判断库存够都去做扣减最后stock8而不是0——虽然方法加了事务但事务默认的隔离级别是READ_COMMITTED或REPEATABLE_READ它不会自动帮你解决并发更新的问题。正确的做法是使用乐观锁或悲观锁// 乐观锁更新时带上版本号或库存条件 int rows stockMapper.deductWithCondition(productId, quantity, stockVersion); // MySQL: UPDATE product SET stock stock - #{quantity}, version version 1 // WHERE id #{productId} AND stock #{quantity}Transactional管的是“要么全做、要么全不做”它不管“多个请求同时做的时候结果对不对”。这两个问题经常被混为一谈这也是很多并发bug的温床。6. 什么场景下仍然可以安全地使用Transactional讲了这么多“不推荐”但把话说公道点Transactional不是毒药在一部分场景下它仍然是合理的。关键是团队要能分清什么场景能用什么场景不能用。我总结下来同时满足下面所有条件的时候用Transactional问题不大方法里只有数据库操作没有远程调用、没有消息发送、没有耗时计算。方法体比较短事务执行时间在毫秒级。整条调用链路都在同一个数据源内不涉及跨数据源。团队有代码规范约束禁止在事务方法内部捕获异常而不重新抛出。调用方不会绕过代理外部Service调用不是内部this调用。典型的正确用法Service RequiredArgsConstructor public class StockService { private final StockMapper stockMapper; Transactional public void batchReduceStock(ListStockReduceItem items) { for (StockReduceItem item : items) { stockMapper.reduceStock(item.getProductId(), item.getQuantity()); } } }这个例子里方法做的事很纯粹就是几条数据库update执行时间短被别的Service调用不会有自调用问题。这种事务加上去不会出大乱子。但如果你所在团队的代码里频繁出现“方法又长又杂里面有循环、有外部调用、有try-catch”那趁早把Transactional禁了改成编程式事务会安全得多。规则越简单越容易执行你可以用一个很蠢但有效的规定想开事务去项目里搜TransactionTemplate用没用过没用过不允许加Transactional。7. 事务边界设计的一点个人建议最后聊点实际的体会。事务这个东西代码怎么写只是表象更核心的是“边界思维”。你写一个Service方法的时候第一件事不是想“我要不要在方法上加注解”而是想“这个操作要保证哪些数据原子成立哪些不算这个原子范围”想清楚这个问题事务边界自然就出来了声明式还是编程式只是实现手段的区别。我之前接手过一个订单系统原来的开发者几乎每个写方法都带Transactional运行了大半年都没事直到一次做秒杀活动连接池瞬间被打满。当时的慢SQL日志显示大量Idle in transaction的会话——连接开着事务开着但什么都没执行等待外部响应。那次以后我们统一做了改造所有Transactional替换成TransactionTemplate事务方法内禁止RPC、禁止消息发送、禁止耗时的循环批量操作按批次拆分到多个短事务中涉及跨服务一致性的场景一律走本地消息表。这个改造上线以后连接池再也没爆过。有意思的是业务代码里的事务数量没有变少只是事务的粒度从“整个方法”变成了“一小段必要操作”。分享一个小技巧写代码的时候可以在小本子上记一道判断题——“如果我把这段逻辑打印出来别人能不能一眼看出它的原子边界在哪里”Transactional只有一个注解边界藏在AOP里很难看得出来而transactionTemplate.execute的Lambda大括号摆在明面上谁来看都知道这段代码是原子操作边界清清楚楚。这也是我认为编程式事务最大的价值它不是多写了几个字符的问题而是把“这里有个事务”从隐性约定变成了显式表达。对于需要长期维护、多人协作、链路复杂的系统来说这种显式表达比什么都重要。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻