☰
为什么我不推荐随意用@Transactional:Spring事务的坑与更可控的替代方案
2026/10/15 13:29:43 网站建设 项目流程

我对@Transactional这玩意儿的感情很复杂。刚学Spring那会儿觉得它简直是神兵利器,一个注解甩上去,事务、回滚、提交全给你安排得明明白白,省了多少事。可后来在某金融项目里线上出了一次大事故,凌晨两点被电话叫起来,排查到天亮,最后定位到罪魁祸首就是几个写得很随意的大事务方法。从那天起,我对这个注解的态度彻底变了。不是说它没用,而是它太容易让人产生“我有了事务保护”的错觉,实际上边界、粒度、生效条件全是坑。这篇文章就好好聊聊,为什么不推荐随手就用@Transactional声明事务,以及我在实战中总结出来的替代方案。

如果你正在写Spring Boot或者Spring MVC的后端代码,或者你所在的团队对事务管理还停留在“加个注解就完事”的阶段,那这篇文章值得你认真看一遍。我会用踩过的坑、线上事故的复盘、以及最直白的大白话,把这个话题掰开揉碎了讲。

1. 表面省事的注解背后,事务边界其实很模糊

先回到最基础的问题:@Transactional到底做了什么?它不像你手写conn.setAutoCommit(false)、conn.commit()、conn.rollback()那么直观,而是基于Spring AOP,在你标注的方法外围悄悄包了一层代理。方法进入之前开启事务,方法正常返回就提交,方法抛出异常就回滚。听起来很完美,但问题恰恰出在这个“悄悄”上——你根本看不见事务从哪开始、到哪结束,也就很难判断它到底管住了哪些东西。

1.1 事务真正管不住的操作比你想象的多

很多人误以为@Transactional保护的是“这个方法里的所有操作”。但实际上它只能管住通过同一个数据库连接执行的关系型数据库操作。只要你的方法里混了这些东西,事务就管不着了:

  • 调用外部HTTP接口,对方拿到数据后自己写了库,你这边回滚了,对方可不会跟着回滚。
  • 发MQ消息,消息已经被消费方拉走处理了,你这边事务回滚,消费方已经落库的数据就变成脏数据。
  • 操作Redis、ES、MongoDB这类非关系型存储,Spring事务管理器默认完全不感知。
  • 调用另一个微服务的RPC接口,对方的事务和你的事务完全是两套连接、两个事务管理器。

我见过最典型的翻车现场是:下单接口里先写订单表,然后调库存服务的远程接口扣库存,再往里写入一条积分流水。结果积分流水写失败了触发回滚,订单倒是回滚了,但库存那边已经扣了,两边数据对不上。你找谁说理去?@Transactional管本地库管得再好,也管不住远程那个库。这不是注解的问题,而是你压根不该让一个数据库事务去承载跨系统的一致性诉求。

1.2 方法级的边界跟业务边界往往是错位的

@Transactional的最小粒度是“一个方法”。但业务上真正需要保证原子性的,常常是跨多个方法的完整业务流程。比如“下单”这个动作,可能要经历校验库存、锁定优惠券、写订单、更新用户等级这几个步骤。如果你把事务注解加在最外层那个placeOrder()方法上,那么整个方法内所有数据库操作都会挤在同一个长事务里。听起来没问题?问题是这个方法里如果混了远程调用,事务持有时间就会被拉长到秒级甚至分钟级,数据库连接被“钉住”的时间也就跟着拉长。

我习惯用一个比喻来解释这个问题:数据库连接就像公司厕所的坑位,事务就是你占着坑位蹲坑的过程。你蹲得越久,外面排队的人就越多。线上系统连接池一般就二三十个连接,正常数据库操作几十毫秒搞定,大家排队也就还好。可你非要蹲在坑里刷手机(等外部HTTP响应),那外面几十个请求全得堵着,连接池一打满,整个服务就僵住了。后面我会用一次真实事故详细算这笔账。

2. 你加了注解,但事务可能压根没生效

比“事务边界模糊”更坑的是“你以为生效了,实际上根本没生效”。这类问题不报错、不告警,要不是数据出现诡异的不一致,你可能永远发现不了。我自己至少踩过五种事务不生效的场景。

2.1 同类调用绕过了代理

Spring AOP是基于动态代理实现的,代理对象会在你调用方法时插入事务逻辑。但注意:代理只在你从外部调用Bean的时候才生效。如果你在同一个类里面写了两个方法,methodA()调用了methodB(),而methodB()上标了@Transactional,那这次调用走的是this.methodB(),不是代理对象,事务注解直接被无视。

我举个实际代码例子:

@Service public class OrderService { // 事务注解加在这个方法上,但其实是无效的 @Transactional public void createOrder(OrderDTO dto) { // 此处直接调用同类方法,绕过代理,事务不会开启 deductStock(dto.getSkuId(), dto.getCount()); orderMapper.insert(dto.toEntity()); } public void deductStock(Long skuId, Integer count) { stockMapper.deduct(skuId, count); } }

这里createOrder()不是被代理对象调用的吗?当外部调用orderService.createOrder()时,肯定走代理,事务会开启。我上面说的是deductStock()上的@Transactional不生效。但如果createOrder()本身没加注解,依赖deductStock()上的注解去开启事务,那纯属想多了。坑就在这:新人很容易把事务注解不加在最外层方法上,而是分散到各个内部方法里,然后靠“自调用”堆出一个伪事务。

验证是否生效的办法:在方法里故意抛一个运行时异常,去看数据库数据有没有回滚。如果没回滚,先检查是不是自调用。

2.2 异常被吞了,事务就只能“成功提交”

这是最常见的坑,没有之一。@Transactional默认的触发回滚条件是“方法抛出RuntimeException”。但很多人写代码喜欢把异常catch住,然后打个log,继续往后走:

@Transactional public void doSomething() { try { orderMapper.insert(order); sendSms(order.getMobile(), "下单成功"); } catch (Exception e) { log.error("发送短信失败", e); // 异常被吞了,事务认为一切正常,会提交 } userMapper.updateLevel(order.getUserId()); }

这段代码里,订单插入成功,短信发送失败被吞了,最后方法正常返回,事务提交。如果业务上“短信发送失败”需要订单回滚,那这代码就是错的——订单照插,用户收到不了通知,两边对不上。问题的本质是:Spring事务感知异常的机制依赖异常往外抛,你一旦catch住,它感知不到,自然就不会回滚。

我之前在代码评审里反复强调过一句话:不要在事务方法里catch住任何你希望触发回滚的异常。如果非要catch,要么catch之后重新抛出业务异常,要么显式调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),手动标记整个事务回滚。注意,setRollbackOnly()这个姿势属于“事后再补”,能解决问题但不是最好看的方案。

2.3 private方法、final方法、非public方法一律无效

Spring默认使用JDK动态代理或者CGLIB代理。JDK动态代理要求目标方法所在的类实现了接口,而且代理只拦截public方法(实际上非public方法根本不会被代理逻辑处理)。CGLIB虽然可以代理非public方法,但Spring官方对@Transactional的生效范围约定得死死的:只有public方法上的注解才被事务拦截器识别。

所以如果哪个同事在private方法上标了@Transactional,那这个注解就是彻头彻尾的装饰品,看起来挺好,用起来啥用没有。我之前在代码里见到过这种注释:“// 不要动这个事务注解,加了才能保证数据一致性”,我一看是个private方法,当场血压就上来了——这信息不仅没用,还会误导后来的人认为这里有事务保护,实际上完全没有。

2.4 多线程调用,事务各走各的

Spring的事务是基于ThreadLocal实现的,每个线程绑定自己的数据库连接和事务上下文。如果你在@Transactional方法里用new Thread()、线程池、CompletableFuture开了子线程去执行数据库操作,子线程拿到的是另一个连接,不会加入当前事务。一旦主线程回滚,子线程写的数据仍然会提交。而且子线程如果有异常,也不会影响主线程的回滚判断。

我见过一个库存扣减的场景,主线程写主订单状态,子线程去更新明细表。结果明细更新失败抛了异常,子线程挂了但主线不知道,主线程提交了事务,数据就缺了一块。这在排查的时候特别恶心:日志里明明看到子线程报错了,但主线程日志显示一切正常,事务也提交了。最终只能靠对账任务拉出差异,反过来修数据。

2.5 Spring Boot 版本差异与自管理事务

还有一类隐蔽场景和Spring Boot版本有关。按道理Spring Boot会自动装配TransactionAutoConfiguration,@Transactional开箱即用。但如果你自己定义了一个DataSource,或者引入了多个数据源,而没有指定PlatformTransactionManager,Spring会懵掉——它不知道该用哪个事务管理器。这种情况下@Transactional要么不生效,要么报“No qualifying bean of type 'PlatformTransactionManager'”。所以一旦你涉及多数据源、分库分表之类的场景,别再指望一个注解搞定一切,事务管理器你必须亲自指定。


上面这几类坑,本质是@Transactional把复杂度藏起来了。藏起来不等于不存在,你迟早得为这个“隐式”买单。接下来我用一次线上事故完整复盘,让大家直观感受一下长事务的破坏力。

3. 长事务是连接池黑洞:一次线上事故的完整复盘

那是在某电商系统的订单服务上,下午两点多,运营反馈说后台系统打开特别慢,很多页面接口超时。接着订单服务告警,连接池活跃连接数持续打满,请求大量超时。

3.1 事故现场:连接池归零

我们当时用的是HikariCP,最大连接数配置了30。监控面板上,活跃连接数在大约一分钟内从十几条直线冲到30,之后一直顶格。新进来的请求拿不到连接,只能排队等connectionTimeout,默认30秒,然后抛SQLTransientConnectionException。雪球越滚越大,整个服务眼看就要瘫。

初步看代码变更记录,发现前一天上线了一个“订单导出”功能。导出逻辑在业务层方法上加了个@Transactional,然后循环查询订单明细、组装数据、导出到Excel文件。单量大概五万条,整个方法跑了将近两分钟。也就是说,这一个线程在事务期间,连续持有数据库连接长达两分钟。

3.2 一张表算清楚为什么单个慢接口能拖垮整个服务

假设连接池最大连接数为30。如果所有接口平均事务耗时是30毫秒,那么单个连接每秒钟能处理大约33个事务,30个连接加起来理论吞吐接近1000 QPS。这时候你随便加一个耗时2分钟的导出接口,它占住一个连接,相当于把整个池子的吞吐砍掉1/30,听起来不多?

但现实不是这么算的。高并发流量是持续不均匀的,任何一个时刻都可能来几十个请求。平时大家事务快,连接很快释放,池子还能轮转。可当慢事务占住一部分连接后,池子剩余的连接根本不足以应付瞬时峰值。更致命的是,Spring默认的事务传播行为是REQUIRED——只要事务开着,连接就一直被占着。慢事务越多,活跃连接数的积压就越严重,最终池子被打满,所有依赖数据库的接口集体超时。

曾经有篇文章把长事务比作“往一个繁忙的公共厕所里塞了一个在里面洗澡的人”,虽然粗俗但足够形象——你不是不能用坑位,但你洗澡得去浴室,不能在坑位里占着不走。

那次事故之后,我用一个非常简单的公式给团队讲清楚了为什么长事务要不得:

连接占用时长 ≈ 事务内所有数据库操作耗时 + 事务内所有非数据库操作耗时(外部调用、文件IO、计算等)

很多人的直觉是“事务的消耗只跟SQL语句有关系”,这完全错了。只要事务没提交,连接就一直被攥在手里,你在事务里等HTTP响应、写Excel、调远程接口,每一秒钟都算在连接占用头上。

3.3 快速止血与根因修复

当时止血手段很简单粗暴:把导出接口的@Transactional去掉,改成读已提交的隔离级别下用只读连接逐页查询,分页批次写Excel。同时给导出操作加上一个基于分布式锁的互斥开关,防止多个运营同时导大数据量。

根因修复层面,我们立了一条铁律:事务方法内禁止出现远程调用、文件IO、消息发送、循环内单个执行慢SQL等行为。代码评审里看到事务方法里有这些操作,直接打回。

这场事故让我明白一个道理:@Transactional最大的问题不是在技术上有什么缺陷,而是它太容易让人忽略“事务是有成本的”这件事。它的成本不是注解本身,而是你往事务里塞进去的那些东西,塞得越多,锁定的资源越久,风险越大。

4. 回滚语义比想象中窄:不是所有异常都触发回滚

很多人以为只要方法抛异常,@Transactional就会回滚。这种想法非常危险。默认情况下,Spring只对RuntimeException和Error执行回滚,对于受检异常(Checked Exception),比如IOException、SQLException(其实SQLException本身是受检异常),事务不会回滚,而是正常提交。

@Transactional public void updateUser() { userMapper.update(...); try { FileWriter writer = new FileWriter("/data/backup/user.txt"); writer.write("backup"); writer.close(); } catch (IOException e) { throw new IOException("备份文件写入失败", e); // 受检异常,事务不会回滚! } }

上面这段代码,IOException抛出后,事务不仅不回滚,还照常提交。你期望的是“备份失败就别更新用户表”,实际却是“用户表更新了,备份文件没写成功”。這種数据不一致非常隐蔽,因为没有报错到数据库层,数据库该提交还是提交。

如果你想让受检异常也触发回滚,需要显式指定:

@Transactional(rollbackFor = Exception.class)

但这里也有个细节:rollbackFor = Exception.class会让所有异常都回滚,包括业务上可以容忍的异常。所以更稳健的做法是定义一套业务异常体系,让所有需要回滚的业务异常都继承自RuntimeException,然后什么都不用配置,默认行为就足够。

另外一个常见的回滚认知误区是“事务会回滚到方法开始前的状态”。这个说法大体正确,但要明白回滚的单位是整个事务,不是方法内某个操作点。你事务里先插了A表,再更新了B表,B表更新失败后,A表的插入也会全部撤销,不会部分保留。这是原子性的含义:要么全有,要么全无。很多人以为“失败那个点之前的不都成功了吗?应该留着啊”,这是把事务和游标保存点搞混了。如果你真的想保留部分操作,你需要的是Savepoint,而不是整个事务注解。

还有一点,回滚只对数据库操作有效,对Redis、ES、MQ、文件系统一概无效。所以如果你的业务是“写数据库 + 发短信 + 加积分”,然后指望@Transactional能让这三者保持原子,那注定是要失望的。数据库事务的边界就是数据库,出了这个边界,事务就管不着了。

5. 传播行为与锁:嵌套事务的连锁副作用

@Transactional还提供了一个看起来很好用的propagation属性。默认是REQUIRED,意思是“如果有事务就加入,没有就新建”。另外还有REQUIRES_NEW、NESTED、NOT_SUPPORTED、MANDATORY等好几种传播级别。看着很灵活,用起来全是坑。

5.1 REQUIRED:你以为是嵌套,其实是汇入同一个事务

REQUIRED的行为是加入已有事务,不会另起一个。换句话说,外层A方法开了事务,内层B方法标了@Transactional,B的数据库操作直接融入A的事务。这在大多数场景下没毛病,因为本来就需要整体原子性。但B如果内部抛了异常,整个大事务都会回滚,哪怕B本身只是“记一笔日志”这种无关紧要的操作,也会把你整个核心数据全部回滚掉。

我甚至见过一个活生生的案例:主业务方法更新用户资料,内部调用了一个“敏感操作审计日志”方法,这个方法也标了@Transactional。结果审计日志方法在某个老数据上抛了一个转换异常,整个用户资料更新全部回滚。业务方找过来的时候一脸懵:为什么更新失败?答:审计日志那边挂了。评价:一个日志操作,凭什么影响核心业务?这就是REQUIRED传播的代价——大家共用同一个坑位,一个人出事,全车人跟着倒霉。

5.2 REQUIRES_NEW:坑位确实换了,但数据一致性更尴尬

REQUIRES_NEW会挂起当前事务,新开一个事务执行。这解决了“日志失败拖垮主事务”的问题,但随之而来的是另一个问题:新事务一旦提交,主事务后续回滚对它无济于事。也就是说,你主事务回滚之后,这个子事务已经提交的数据就永远留在库里了。

典型的场景:积分系统里,用户下单积分+100,通过REQUIRES_NEW去子事务里更新积分明细。结果订单支付失败,主事务回滚,积分却已经加了。这等于你把“订单状态”和“积分明细”的一致性亲手拆开了。要修只能靠对账。

所以REQUIRES_NEW不是用来解决一致性问题的,它只是解决“某些动作不想受主事务影响”的问题。但如果你把这些动作和主事务放在同一笔业务里,本来就需要精确的一致性,那REQUIRES_NEW就会成为数据不一致的温床。

5.3 嵌套事务与热点数据锁竞争

默认的MySQL InnoDB事务隔离级别是REPEATABLE READ。在事务里更新一行数据,这行记录会被行锁锁住,直到事务提交或回滚才释放。你的事务方法如果包含外部调用、循环耗时操作,那锁的持有时间也跟着被延长。遇到高并发下多个线程更新同一行数据(比如库存、余额),后来的事务全部要等锁。等锁等得越久,系统吞吐越低,极端情况下多个长事务互相等对方持有的锁,直接死锁,数据库抛Deadlock found。

这个坑比连接池打满还隐蔽,因为从代码层面看,事务方法逻辑并不复杂,SQL也很快。但一旦嵌套了外部调用或者批量循环,锁等待时间就不可控了。我把这类问题统称为“锁的粒度被事务粒度绑架了”——你明明只需要锁一行,但因为事务太大,锁的时间被无限拉长。

6. 什么时候我还会继续用@Transactional

前面吐槽了这么多,也不是说这个注解就一无是处,该用的时候还是得用。它最快、最省事,对特定场景是合理解。我现在的原则很简单:

6.1 适合使用它的场景

  • 方法很小、很纯粹:比如一个DAO方法、简单更新、单表插入,事务几十毫秒内结束,没有外部依赖,没有循环批量的慢操作。
  • 不涉及跨系统一致性:只有本地库表之间的原子性要求,没有Redis、MQ、远程接口混在里面。
  • 并发热度可控:不频繁更新同一行热门数据,或者即使更新,单次事务很快,锁竞争无所谓。
  • 没有自调用需求:事务方法被代理外直接调用,而不是同类内互相调用。

在这些条件下,用一个@Transactional是最经济的选择。没必要把简单事情复杂化。

6.2 建议拆分的代码结构

我现在的习惯是“无事务代码”和“事务代码”分层放。尽量让事务方法成为最内层的一段“小而精”的代码。比如:

  • Service层入口方法只做参数校验、组装、调用。
  • 需要事务的操作单独抽成私有方法或者独立的Bean方法,方法体里只跑数据库操作,不夹带远程调用、文件IO、消息发送。
  • 远程调用、消息发送放在事务方法外层,保证事务方法一结束,连接就释放。
@Service public class OrderApplicationService { private final OrderRepository orderRepository; private final RemoteStockService remoteStockService; @Transactional public void createOrderTx(OrderDTO dto) { // 这个事务方法只做本地数据库操作,绝不混入远程调用 orderRepository.insert(dto.toEntity()); orderRepository.updateCount(dto.getUserId()); } public void createOrder(OrderDTO dto) { // 外部调用放在事务外面 remoteStockService.deduct(dto.getSkuId(), dto.getCount()); try { createOrderTx(dto); } catch (RuntimeException e) { // 事务失败,尝试远程补偿 remoteStockService.compensate(dto.getSkuId(), dto.getCount()); throw e; } } }

这种结构不是一个完美的分布式事务方案,但至少把事务边界控制在最短、最可控的范围内。远程调用的异常处理和本地事务的原子性被明确区分开了,不至于混在一起变成一团乱账。

7. 更可控的替代方案:TransactionTemplate与手动事务边界

如果你已经对@Transactional的隐式行为感到不安,完全可以换成Spring提供的编程式事务模板TransactionTemplate。它把事务的开启、提交、回滚都变成显式代码,写起来比注解多几行,但每一步都清清楚楚。

7.1 TransactionTemplate的基础用法

@Service public class UserService { private final TransactionTemplate transactionTemplate; public UserService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void updateUserWithExternalCall() { // 事务外操作:远程调用、文件IO、消息发送 remoteService.notify(userId, "start"); transactionTemplate.execute(status -> { // 事务内操作:只处理数据库 userMapper.updateName(userId, newName); userMapper.updateLevel(userId, newLevel); return null; }); // 事务结束后再执行外部操作 remoteService.notify(userId, "done"); } }

TransactionTemplate.execute()会帮你处理正常提交。如果回调里抛出RuntimeException,它自动回滚并往外抛。如果你想明确回滚,可以直接调用status.setRollbackOnly()。

这段代码的好处是:事务边界是肉眼可见的,你一眼就看到哪几行在事务内,哪几行在事务外。外部的远程调用、消息发送、文件操作,都不会被事务白白拖住。

7.2 最小事务边界原则

我常跟团队强调“事务内只做必要的数据库操作,事务外的操作尽量多”。这和“常用连接尽量短”是一个道理。你可以在事务外预先加载数据、调用远程、算好结果,然后把纯数据库写入丢进事务里,让事务只有短短几行SQL的时间。

有个常见误解是“既然要做原子性,必须把校验、计算、写库全部放进事务里,否则数据会变”。但实际上多数业务并不需要这么强的原子性。你完全可以先把数据查出来,在事务外做校验和计算,然后再开一个极短的事务去更新。唯一要注意的是并发冲突——如果两个线程同时读到旧值再更新,就可能出现覆盖问题。这种场景可以用乐观锁版本号或者UPDATE ... WHERE条件去兜底,而不是靠拉长事务来规避。

7.3 说什么场景下编程式事务更合适

  • 事务方法里必须调用远程接口,又实在没法挪到事务外的(说实话这类设计本身就值得重新考虑)。
  • 事务边界需要跨多个方法,或者事务操作带有循环、批量的,需要精细控制在某一小段。
  • 你想在事务内动态决定是否回滚,而不是只能依赖方法抛异常。
  • 你需要对回滚后的补偿逻辑做精准的try-catch,而@Transactional的回滚在第几层触发根本不好判断。

还有一个细节:TransactionTemplate是线程安全的,可以在Service里做成单例字段,不用每次new。事务管理器也可以直接用Spring容器注入。

7.4 有没有完全不用事务的办法

有的。对于一些允许最终一致性的业务,你可以彻底不走数据库事务,而是靠“本地消息表 + 定时任务补偿 + 对账”的方式保证数据最终一致。比如下单后需要更新积分,你可以在本地事务里同时插入订单表和积分消息表(这一步还是用短事务),然后由后台任务扫描消息表,异步去把积分更新到用户账户。即使中途失败,消息会重试,直到成功。

这种方案的优点是把事务拆得很碎、很短,每一个事务都只是“插入一行记录”级别的耗时,连接占用极低。缺点是需要额外开发和维护,不能指望一个注解兜住所有一致性。像订单金额对账、积分增减这类允许短暂延迟的业务,用这个是合适的。

8. 从“一个注解”到“一场设计”的事务认知升级

说实话,如果时间倒流回我刚接触Spring的时候,我大概率还是会先用@Transactional,因为它确实简单。但踩过那些坑之后,我最大的变化是:加注解之前,我会先想清楚这个事务到底要保护哪些数据、要持有多久、会不会拖住连接和锁、有没有远程操作混在里面。如果这四个问题回答不清楚,那我宁可先不写注解,把事务拆出来再说。

回过头看,“为什么不推荐使用@Transactional”这个问题,本质上不是反对这个注解本身,而是反对一种思维惯性——用最简单的写法,去掩盖一个本需要精心设计的复杂度。事务是做数据一致性的基本功,但它不是银弹。从声明式事务到编程式事务,从单库事务到分布式最终一致,每一步都是在“简单可控”和“精确一致”之间做取舍。希望这篇文章能让你在下次手痒想加@Transactional之前,多问自己一句:这个事务,真的只该是一个注解吗?

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

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

立即咨询