☰
@Transactional深度解析:失效场景、长事务危害与TransactionTemplate替代方案
2026/10/10 18:59:55 网站建设 项目流程

1. @Transactional到底帮我们做了什么

1.1 先从JDBC手动事务说起

要搞懂@Transactional为什么不推荐,得先回到最底层:在只用JDBC的年代,一个数据库事务是怎么控制住的。那时候写代码,拿到Connection之后要手动执行conn.setAutoCommit(false),业务操作做完之后手动conn.commit(),任何一步出了异常都要在catch块里手动conn.rollback(),最后还要在finally里把连接关闭。稍微漏一个分支,数据就脏了。这段经历现在很多年轻人都没经历过,但恰恰是这段经历,让我对后面Spring帮你做的那些“隐形操作”特别敏感。

后来Spring的声明式事务出现了,一个@Transactional注解就能替代上面那一堆样板代码。它是什么原理?本质上就是Spring容器在启动的时候,会为目标Bean创建一个代理对象,你调用的时候走的其实是代理。代理在方法执行前开启事务,方法正常结束就提交,抛异常就回滚。这套机制叫AOP增强,具体干活的类叫TransactionInterceptor。说白了,注解只是告诉代理“你在这个方法外头给我包一层事务逻辑”,真正开事务、提交、回滚,都是代理在替你干。

1.2 代理机制:JDK动态代理还是CGLIB,直接影响注解是否生效

这是解读注解的第一道坎。如果目标类实现了接口,Spring默认用JDK动态代理;如果没实现接口,用CGLIB通过生成子类的方式做代理。这俩有啥区别?JDK代理创建出来的对象,只能拦截接口里声明的方法;CGLIB则能拦截类里几乎所有非final方法。很多新人问“为什么我的@Transactional没生效”,排查第一步就是看这个Bean到底有没有被代理,以及你调用的是不是代理对象上的方法。

这里插一个最常见的翻车案例:同一个类里的方法A调方法B,A没加事务注解,B加了。你以为B会开一个事务,实际上B根本不会走代理,因为这是this调用,不是通过注入进来的代理对象调用。Spring的声明式事务基于代理,代理只能拦截从外部进来的调用,内部自调用绕过了代理,注解自然就成了一张废纸。这个问题我后面会展开讲。

1.3 隔离级别和传播行为:注解默认值真的够用吗

@Transactional的默认传播行为是REQUIRED,意思是如果当前没有事务就新建一个,如果有就加入当前事务。默认隔离级别是数据库默认的隔离级别,MySQL一般是REPEATABLE READ。这两个默认值在大多数CRUD场景下够用,但一旦涉及复杂的写业务,比如一个Service方法里循环调用了多个模块的更新逻辑,REQUIRED就会让所有写操作都进同一个大事务,锁的持有时间会特别长,这在并发高的系统里几乎必然出事。

大厂不是觉得注解本身有罪,而是觉得它把事务的边界藏起来了。你以为你在一处开启、处处生效,实际上一个方法嵌套调多个方法,整个调用链都被卷进同一个事务,数据库连接从进方法到方法结束一直不能释放,锁一直持有,业务高峰期就是灾难。所以很多团队干脆在规范里写死一句话:除非是简单到不能再简单的单表单行更新,否则不要用@Transactional,要用编程式事务,把事务边界控制在你想要它存在的那个窄范围里。

2. 大厂最怕的几件事:@Transactional的经典失效场景

2.1 自调用陷阱:同一个类里的方法互相调用,事务注解形同虚设

这个问题我真的见过太多次,而且出问题的代码往往是老员工写的,看起来特别像“标准答案”。比如:

@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { saveOrder(dto); updateStock(dto); } public void updateStock(OrderDTO dto) { // 扣减库存 } }

看起来好像没问题,createOrder开了事务,里面调updateStock,一动全动。但如果哪天你直接在Controller里调了updateStock,你会发现扣减库存这个操作本身没有事务保护。原因我刚才说了,Spring事务基于代理,Controller拿到的是代理对象,createOrder是从代理入口进的,这一层没问题。可如果某个方法没加注解却被卷进事务,那你得时刻牢记:@Transactional只对代理入口方法生效,对方法内部的this调用完全不生效。

更隐蔽的版本是把@Transactional注解加在private方法上。Spring对private方法是无能为力的,因为代理根本拦不到private方法,物理上也没法对它做增强,注解会被静默忽略。还有同类里两个public方法互相调用,被调用方即使加了注解也没用。有些人会为了“让事务生效”而把内部调用改成注入自己,这是把问题搞复杂,后面我会说更干净的解法。

2.2 异常被吞、checked异常、private方法——哪天失效了你都不知道

这是第二个高频翻车点。默认情况下,@Transactional只在运行时异常(RuntimeException)时回滚,遇到受检异常(checked exception)是不会回滚的。这是什么意思?你方法里catch住异常丢了个自定义的业务异常,而业务异常如果不继承RuntimeException,事务就直接提交了。更阴险的是,有些开发为了“不把异常漏出去”,在事务方法里写try-catch把异常吞掉,然后返回个错误码,事务层根本不知道发生了异常,于是脏数据就这么落库了。

所以正经项目的第一个规范就是:事务方法里的异常要么不catch,要么catch之后重新抛出RuntimeException,绝对不能在事务方法内部把异常吃掉。如果有队友跟你说“我catch一下记个日志就行”,你最好直接按住他的手。你记了日志,数据已经写坏了,日志救不了业务。

还有一类失效场景是数据库层面的:如果连的是MyISAM引擎的表,InnoDB才有事务支持,MyISAM根本不支持事务,注解再多也没用。另外就是方法所在的类没有被Spring管理,比如new关键字手动new出来的对象,注解同样无效。

2.3 代理机制引发的连锁问题:绕过代理的N种姿势

我上面提过JDK动态代理和CGLIB,这里再展开一个连锁问题:有些人用@Transactional的时候,为了让它生效,会在类上直接加注解,甚至把整个Service类都加上。这样做有个隐患:类的所有public方法都会开启事务,哪怕是一个只读查询也会尝试开启事务。读操作开事务虽然不会造成数据错误,但会占用数据库连接,拖慢连接池周转。更重要的是,如果你把事务加在类级别,再配合一些方法级别的覆盖配置,配置一旦写错,排查起来非常痛苦。

还有一种是多线程场景。@Transactional的使用范围是单线程的,它开启的事务和当前线程绑在一起。如果你在事务方法里用CompletableFuture异步执行子任务,子线程里根本不会共享到主线程的事务上下文。有人以为子任务里再标个@Transactional就能参与主事务,实际上REQUIRED传播行为在线程B里发现没有事务,会直接新开一个,两个事务各管各的,主事务回滚,子事务照样提交,数据照样错。

说实话,这些问题单独拎出来每一个都不难理解,但实际项目里往往是好几个叠在一起:异步、自调用、异常被吞、跨库、连接池打满,叠完之后你再想查是哪个环节导致的一致性问题,那真是海底捞针。这也是大厂一般不敢让@Transactional“裸奔”的根本原因:它把风险藏在了注解背后,等出事的时候你已经很难还原现场。

3. 比失效更可怕的是长事务:它在如何拖垮数据库

3.1 连接被长事务占死:连接池耗尽连锁反应

失效充其量是“没有保护”,最惨的是事务真的生效了,但它生效太久。长事务最直接的影响就是数据库连接迟迟不释放。假设你的连接池最大20个连接,一个事务方法里你调了外部接口,这个外部接口平均响应3秒,高峰时5秒甚至超时。这段等待时间里,事务一直开着,连接一直被占着。如果同时有20个请求打进来,连接池就空了,后面的请求只能排队等连接,等待超过超时时间就直接报获取连接失败。

这还不是最可怕的。MySQL里,长事务会让Undo Log不断膨胀,因为要支持MVCC,事务隔离要能看到自己开始之前的数据版本。事务开得越久,旧的版本链就越长,其他查询要回滚到那个越来越远的历史版本,磁盘IO和内存压力都会上来。很多线上“突然变慢”的诡异问题,最后查出来就是有一个大事务在后台没提交,把性能拖垮了。

3.2 锁升级与死锁:为什么线上事故总在深夜发生

长事务第二个大招是锁。一个事务修改了一行数据,这行数据上的锁直到事务提交或回滚才会释放。事务等待一个不存在的锁,就可能导致锁等待超时;两把锁互相等待,就是死锁。死锁常见的现场是两个事务都先update了表A再update表B,但顺序相反。在低并发下根本不会发生,因为一个事务很快就执行完了,锁刚产生就释放;但在长事务场景下,第一个事务拿着A锁迟迟不提交,第二个事务拿着B锁等A,第一个事务又等B,就卡死了。

有一次我们排查线上死锁,发现罪魁祸首是一个同步批量操作,循环里对一批数据逐条做update,每条update之间还穿插着远程调用。正常人写代码不会这么干,但业务就是这么要求的,结果一个事务里包含了几十条update加十几秒的远程调用,锁持有时间长得离谱。后来我们把批量改成单条短事务,把远程调用挪到事务外面,死锁基本绝迹。长事务不一定会死锁,但它把死锁的概率放大了几十倍。

3.3 主从延迟与缓存不一致:事务边界影响的放大效应

再说一个容易被忽略的点:主从延迟。以前我们有个项目,写完订单后立刻更新Redis缓存,更新逻辑里先查数据库再写缓存。因为主库事务特别长,从库同步延迟高,查从库查到的还是老数据,就把老数据写回缓存了。结果前端看到的数据一会新一会旧,用户疯狂反馈。最后定位到根因还是长事务:主库的写入迟迟不提交,从库拿不到最新数据,读库和缓存全被带偏。

所以你会发现,长事务的影响从来不是“业务多等了零点几秒”这么简单,它会顺着连接、锁、版本链、主从同步、缓存链路一路传染出去。一个@Transactional写得不谨慎,可能影响的是整个服务集群的可用性。正因为这个放大效应,大厂在核心写路径上对事务边界都有近乎苛刻的要求:能不开就不开,能短则短,开之前先问自己“这个锁我到底需要持有多久”。

4. 为什么大厂项目不默认推荐@Transactional

4.1 不可控:声明式事务带来的“隐式行为”

说到根子上,大厂不推荐@Transactional,不是这个注解本身写得烂,而是它能藏的东西太多了。你写一行注解,等于把“何时开启事务、何时提交、何时回滚、事务传播怎么串”全部交给了框架的默认行为。开发者在代码里看不到这些控制流,Review的人也看不到,只有出了事故之后才去翻日志和监控。代码的可控性,在大厂里比便利性值钱得多。快三个月一次的版本发布,如果每个事务行为都靠注解约定,那线上出问题就不是“如果”而是“什么时候”。

再说调试成本。声明式事务的回滚和提交发生在代理层,跟你的业务代码是分离的。你在业务代码里打断点,只能看到方法执行完返回了,根本看不到提交动作发生的那一刻。真要在复杂业务里排查一条数据是被谁回滚的、回滚发生在哪一行,你得把AOP、代理、事务同步管理器全部翻一遍,成本非常高。编程式事务虽然代码看起来“啰嗦”,但每个步骤都是显式的,逻辑清楚,出问题的时候一行一行跟就能跟上。

4.2 编程式事务TransactionTemplate:把控制权拿回自己手里

既然声明式事务不可控,那替代方案是什么?我个人最常用的就是TransactionTemplate,Spring自带的编程式事务模板。用法很简单,把要执行的任务塞进execute回调里,回调返回正常就提交,抛异常就回滚。事务的边界、回滚规则、隔离级别全都一目了然。代码量确实比注解多几行,但换来的是“每一行都在我掌控之中”的安全感。

@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void createOrder(OrderDTO dto) { transactionTemplate.executeWithoutResult(status -> { // 1. 保存订单 // 2. 扣减库存 // 3. 其他写操作 }); } }

有人可能会说,这不就是把注解换成了模板吗,底层不还是同一套事务管理器?没错,底层是一样的,但收益在于边界明确。注解是“整个方法都进事务”,模板是“只有execute回调里的代码在事务里”。如果你要把远程调用、计算逻辑放在事务外,Annotation根本没法精细控制,模板代码却能轻松做到。这一点在写核心写链路的时候极其重要。

4.3 什么时候仍然值得用@Transactional:适用场景判断

上面说了这么多坏话,也不是要把@Transactional一刀切打死。它最适合的场景是那种“单个方法、单一数据库、短且快”的简单写操作。比如一个简单的订单状态更新、一个库存字段扣减,方法本身没有远程调用,没有批量循环,执行时间毫秒级,这种时候用注解完全没问题,代码也确实简洁。

但一旦方法里出现下面这些信号,我建议你马上把注解拿掉,改用TransactionTemplate:一是方法内部有远程调用、消息发送、定时任务回调;二是方法里有循环批量的写操作;三是方法会被同类其他方法调用(自调用场景);四是方法里会有catch块用于捕获业务异常;五是你需要自定义回滚条件。识别这些信号的能力,其实比背任何框架API都重要。框架只是工具,你真正要管理的是事务的边界、时长和风险。

5. 事务替代方案与代码落地实操

5.1 TransactionTemplate的标准化写法

我平时写代码有个习惯:凡是事务代码,先写清楚哪些操作必须在事务里,哪些可以放到事务外。事务内的操作尽量只包含数据库写操作和必要的校验,事务外再去做日志、发消息、远程调用、缓存更新。这看起来是小事,但在高并发下差别巨大。比如一个下单流程,扣库存必须在事务里,但给用户发短信、通知仓库系统这些完全可以在事务提交之后再异步做,放在事务里只会白白拉长锁时间。

标准写法我一般这样组织:

public void createOrder(OrderDTO dto) { // 事务外:前置校验、幂等判断 String orderId = transactionTemplate.execute(status -> { // 事务内:只做数据库写操作 Order order = orderRepository.save(new Order(dto)); stockService.decreaseStock(dto.getSkuId(), dto.getCount()); return order.getId(); }); // 事务外:发消息、清缓存、异步通知 mqSender.sendOrderCreatedEvent(orderId); }

这个结构的核心价值是把事务的“宽度”控制在最小。很多线上问题不是事务逻辑错了,而是事务里塞了太多不该塞的东西。记住一个原则:事务里跑的每一毫秒,都是数据库连接和锁在为你买单。能用1毫秒解决的事,别拖成100毫秒。

5.2 事务边界设计:把锁范围缩到最小

再聊一个实战细节:如果你的事务内必须做多次写操作,尽量保持它们之间没有业务计算、没有远程调用、没有等待。比如一次下单,先插入订单表,再扣库存,这两步中间如果插入了一个“根据订单内容调用商品服务计算价格”的远程调用,那锁就莫名其妙多持有了几秒。正确做法是把价格计算放到事务外,把计算结果作为参数传进来,事务内只管写入。

另外,如果事务内需要读取数据再决定是否写入,要注意锁和快照读的区别。默认的RR隔离级别下,普通SELECT是快照读,不锁行;但如果业务要求“先查后改”且必须防止并发修改,就要考虑是否需要for update,或者干脆用乐观锁版本号。这块很多人会踩坑:以为事务里查到的数据就是最新的,其实在高并发下快照读可能读到旧版本,导致更新覆盖。做扣减库存这类业务,强烈建议用数据库原子操作(比如update stock = stock - count where stock >= count)来替代“先查后判再改”。

还有一点是关于传播行为。TransactionTemplate默认也是REQUIRED,如果当前已有事务,它会加入现有事务。如果你希望某个操作无论如何都开一个新事务、独立提交回滚,那就得设置Propagation.REQUIRES_NEW。REQUIRES_NEW使用场景听起来很酷,但它意味着数据库会有两个并发事务同时持锁,使用前一定要想清楚,否则很容易出现两个事务互相等锁导致死锁。我在实际项目里见到太多人看到REQUIRES_NEW觉得很高级、一定要用,结果用出好几个死锁事故,所以我的建议是:默认REQUIRED,REQUIRES_NEW只在“必须独立失败不影响主事务”的场景下少量使用。

5.3 隔离级别与回滚规则的实际选择

隔离级别也是容易被忽略的点。MySQL InnoDB默认REPEATABLE READ,对绝大多数业务来说够用。但如果你在读多写少的报表统计场景,可以考虑用READ COMMITTED来降低间隙锁的影响,不过要结合DBA的意见和具体业务场景。这里我特别想强调:隔离级别不是越高越好,越高往往意味着锁的范围越大、并发度越低。有些团队动不动把注解的隔离级别配成SERIALIZABLE,等于让所有并发写操作排队,性能直接崩盘。

回滚规则方面,TransactionTemplate里可以用setRollbackOn、setNoRollbackOn之类的配置,但说实话大部分业务用不上这么细。我一般只遵循两条铁律:一是运行时异常(RuntimeException)默认回滚,不要在事务里catch掉;二是如果碰到必须处理受检异常的场景,catch之后要么重新抛RuntimeException,要么在回调里通过setRollbackOnly()强制标记回滚。这样虽然啰嗦,但行为可控,不会出现“我以为回滚了,结果提交了”的惨剧。

6. 故障排查实录:事务相关的典型问题与心法

6.1 常见异常速查

写事务代码这些年,我在生产环境见过的事务相关异常,来来去去就那么几个,嚼碎了讲给大家。第一个是UnexpectedRollbackException,这个异常的意思是事务已经被标记为rollback-only,但方法正常返回了,Spring发现“你说你要提交,但内部已经标记了回滚”,就抛这个异常。根源往往是内部嵌套的事务方法抛了异常但被外层catch掉了,异常被吞,回滚标记却留下了。看到这个异常,第一反应就是去查内部事务方法里有没有被吞掉的异常。

第二个是CannotAcquireConnectionException,这个异常一看就知道是连接池没连接了,但连接池为什么没连接,十有八九就是某处有长事务或连接没释放。第三个是DeadlockLoserDataAccessException,一听就是死锁,MySQL会选一个事务回滚,业务代码里如果没做重试,这次请求就失败了。死锁不代表代码逻辑错了,很多时候是锁顺序不一致导致的,我后面会讲排查方法。

下面整理个速查表,方便以后排查直接对照:

异常常见原因第一反应
UnexpectedRollbackException内部事务标记了rollback-only,外层吞了异常找被吞异常
CannotAcquireConnectionException连接池耗尽查长事务、连接占用
DeadlockLoserDataAccessException并发锁顺序冲突检查锁顺序、事务时长
TransactionSystemException事务同步异常、连接异常查数据库状态、事务管理器配置
IllegalStateException(DataSourceUtils)连接关闭、事务不同步查是否混用了手动连接与Spring事务

这些异常虽然名字吓人,但基本上都能从日志里顺藤摸瓜找到根因。怕的是异常日志不全,或者事务方法里的catch块把异常吞了,那才是真的排查地狱。

6.2 排查事务问题的三板斧

第一板斧是看日志。Spring的事务日志级别调到DEBUG之后,会打印事务开启、提交、回滚的记录,TransactionInterceptor类会输出类似“Completing transaction for [xxx]”这类信息。如果你看不到回滚日志,说明事务根本没事;如果看到回滚日志但数据还是不对,那问题就在“有没有真回滚、谁把数据写进去的”。

第二板斧是查数据库。看当前有哪些事务在跑:MySQL里可以用information_schema.innodb_trx表查到未提交事务的开始时间和状态;再用performance_schema查锁等待情况。这一步能快速定位是不是有长事务或者锁等待。我记得有一次线上卡顿,一查innodb_trx,发现一个事务已经挂了40多分钟没提交,那行数据被锁得死死的,排查过程直接缩短到十分钟。

第三板斧是复现。事务问题往往具有并发触发条件,单线程跑很难复现。我会写一个小的复现用例,用两个线程模拟并发操作,把隔离级别、锁顺序、事务时长都按线上配置来,一点点逼近线上场景。这个方法虽然费点功夫,但比看代码猜来猜去靠谱得多。事务问题本质上就是并发问题,不用并发复现,永远都像在黑暗中摸象。

6.3 我的一点实操心得

这篇文章快写完了,最后分享一点我个人的偏好和习惯。我在项目里定过一条规矩:新代码一律用TransactionTemplate,旧代码里有@Transactional的,但凡在代码Review里碰到,一律追问三个问题——事务边界在哪、锁持有多久、异常会不会被吞。三个问题答不上来,就当场重写。听起来苛刻,但效果特别好,推行一年后,线上事务相关的告警几乎消失了。

还有一个心得是关于代码Review的:@Transactional这种声明式的东西,最大的问题不是运行时出错,而是代码读起来太“顺”了,顺着顺着就没人再去质疑它。反倒是TransactionTemplate那几行略显冗余的代码,每次看到都会让人本能地问一句“这里为什么要把事务圈起来”。这种“结构上的不舒服”,恰恰是代码质量最有效的守护。事务这件事,坦诚讲没有银弹,你要选的从来不是哪个API更优雅,而是哪个方案更容易让你和你的队友看清真相。

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

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

立即咨询