Spring事务注解8条铁律 十年踩坑经验总结

8 条@Transactional 规则,我在调试了太多生产环境 bug 后遵循这些规则。

使用 Spring Boot 已经超过 10 年了,在代码审查和生产环境中仍然经常看到同样的事务性 bug。所以我想分享一下我现在遵循的规则。

  1. 尽量缩短事务持续时间,仅进行数据库操作。如果你的方法在事务中调用外部 API、发送电子邮件或上传文件,它会在整个方法执行期间保持数据库连接,而不仅仅是在查询期间。在高负载情况下,连接池会耗尽,导致应用程序卡死。
  2. 永远不要在同一个类中调用事务方法。Spring 使用代理。在同一个类中调用方法时,实际上是直接调用 this.method(),代理会被绕过。注解完全不可见,不会发出任何警告或错误,它什么也不做。
  3. 对于关键操作,请使用 rollbackFor = Exception.class。这一点常常让人措手不及。默认情况下,事务仅在抛出 RuntimeException 时才会回滚。如果您的方法抛出 PaymentException 之类的已检查异常,则交易会提交。您的余额会被扣除,但付款实际上并未完成。
  4. 除非重新抛出异常,否则不要在事务处理中捕获异常。如果捕获了异常,代理会看到正常的返回结果,从而提交事务。数据库中会残留部分数据,造成静默损坏。要么让异常传播,要么在记录日志后重新抛出异常,要么调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
  5. 对所有只读方法使用 readOnly = true。这会告诉 Hibernate 跳过脏检查和快照比较,从而节省 CPU 和内存。一些代理会利用这一点将数据路由到只读副本。但不要依赖它来阻止写入操作。显式的 save()flush() 仍然会执行。
  6. 仅对公共方法有效。代理只能拦截公共方法。如果将事务属性应用于私有方法,则完全无效。Spring 甚至不会发出警告。
  7. 将可重试和事务性操作分别放在不同的 bean 中。如果两者位于同一方法中,并且抛出了已检查异常,则事务会在重试触发之前提交。重试会启动一个新的事务。相同的逻辑推理会再次运行。三次重试会导致三次逻辑推理。外部 bean 处理重试,内部 bean 处理事务。
  8. 在每个环境中启用这两个配置。logging.level.org.springframework.transaction.interceptor: TRACE 显示事务何时开始、提交和回滚。
spring.datasource.hikari.leak-detection-threshold: 15000 捕获保持时间过长的连接,并提供完整的堆栈跟踪。
涵盖大多数情况的规则是:uTransactional 仅对从 bean 外部调用的公共方法有效。其他所有调用都会被静默忽略。

详细解读

Spring Boot 事务注解用不对,数据库连接会被拖死,数据会莫名其妙少一块,重试三次能把业务逻辑跑三遍。我踩了十年坑,总结出八条铁律,每条都是用生产环境半夜宕机换来的。今天全抖出来,省得你再走一遍。

事务注解只管数据库操作,别在里面调用外部接口

事务方法里调用第三方支付接口,接口超时三秒,数据库连接就跟着等三秒。接口再慢一点,连接池里二十个连接全被占满,后面的请求全部排队。排队排到超时,应用直接卡死,用户刷出来的全是白屏。

外部接口调用放在事务方法前面。先把数据查出来,记下必要的信息,然后关掉事务,再去调接口、发邮件、传文件。数据库连接只在真正执行SQL那几十毫秒里占用,用完立刻释放。高并发下连接池的压力能降一大截,应用不会因为一个接口超时整个挂掉。

事务只干事务该干的事,就是增删改查。业务逻辑里的网络请求、文件操作、消息推送,统统挪到事务外面。代码拆分一下,一个方法专门做外部调用,另一个方法专门做数据库操作,事务注解打在数据库操作那个方法上。这样连接池永远不会被外部服务的延迟拖垮。

同一个类里调事务方法等于白写

类里有个saveData方法,上面标着@Transactional。同一个类里的另一个方法调用saveData,注解完全不起作用。Spring代理根本没经过,直接调的this.saveData(),事务管理器连这个方法被调用了都不知道。

这个坑在代码审查里出现频率最高。开发写了个内部方法调事务方法,本地测试跑起来一切正常,因为数据量小看不出问题。上了生产,数据稍微复杂一点,该回滚的时候不回滚,该合并的时候不合并,留下一堆半成品数据在数据库里躺着。

解决办法是把这个事务方法单独拎到一个新的bean里。或者通过Spring的ApplicationContext拿到代理对象,再用代理对象去调用。最直接的办法就是拆bean,事务方法放一个bean,调用它的业务方法放另一个bean,两个bean之间互相注入,这样代理肯定生效。

关键操作必须指定rollbackFor等于Exception.class

默认只回滚运行时异常,这个设定坑过无数人。方法里抛出一个自定义的PaymentException,这是个受检异常,继承的是Exception不是RuntimeException。事务管理器看到受检异常,默认行为是提交事务,不做回滚。

用户账户里的钱扣了,订单状态没更新成已支付,因为支付接口返回了失败状态,代码抛了PaymentException。数据库里余额已经减了,但支付记录没生成,客服查账怎么都对不上。这种静默的数据不一致最难排查,日志里只有个异常堆栈,没人会想到事务没回滚。

在@Transactional注解里加上rollbackFor = Exception.class,不管抛出什么异常,受检的还是非受检的,全部触发回滚。这是一个保底配置,用在涉及资金、库存、核心业务状态更新的方法上。其他非核心操作可以不用,但关键链路必须加,少加一个就可能出事。

事务方法里捕获异常后必须重新抛出或者手动标记回滚

捕获了异常不往外抛,事务代理看到的是方法正常执行完毕,返回了一个结果对象。代理不知道里面发生了什么,按正常流程提交事务。数据库里插了一半的数据被提交了,但业务逻辑实际上失败了。

最常见的写法是try catch包住整个方法体,catch里打一行日志,然后返回个空对象或者错误码。调用方看到错误码知道出事了,但数据库那一半的数据已经写进去了。这种数据残渣会在报表里出现,会在统计里出现,唯独在业务操作记录里没有对应条目。

要么在catch里把异常重新往外抛,让事务代理感知到异常触发回滚。要么调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),手动告诉事务管理器这个事务需要回滚。打完日志之后千万别悄悄把异常吞掉,回滚是底线,数据一致性比什么都重要。

只读方法加上readOnly等于true能省不少资源

查询接口里标注readOnly,Hibernate会跳过脏检查,不会去比对快照和当前数据有没有差异。一个查询方法查出来一万条记录,没有脏检查就省了一万次比对操作,CPU占用明显下降,内存压力也跟着减小。

只读事务还有一个作用,某些数据源配置了读写分离,标注readOnly的事务会被路由到只读副本库,读操作不会压主库。但别指望这个配置能拦截写操作,方法里显式调了save或者update,数据库照样执行,只是Hibernate少做了一些内部检查而已。

所有纯查询方法都加上这个参数,查询列表、查询详情、统计报表,只要不涉及增删改,通通标注。对性能的提升在数据量大的时候特别明显,而且几乎零成本,加一个参数的事。

私有方法上标事务注解完全是摆设

Spring事务靠动态代理实现,代理只能拦截public方法。私有方法上的@Transactional编译器不会报错,IDE也不会给提示,启动日志里也看不到任何警告。方法正常执行,但事务完全没生效,该一起提交的没提交,该回滚的没回滚。

代码审查里经常看到有人把事务注解打在private方法上,问起来说这样封装性好,外部调不到。但封装性是保证了,事务性却丢了。开发本地跑单元测试,因为数据简单,没发现事务没开,推到测试环境才暴露出问题。

把方法改成public。如果必须控制访问权限,可以把事务方法放到另一个bean里,用包级私有或者protected,但同一个类里的private绝对不行。Spring官方文档写得清清楚楚,但文档没人看,坑照样踩。

重试逻辑和事务逻辑必须拆到两个bean里

方法上同时标了@Retryable和@Transactional,抛出受检异常的时候,事务先提交,然后重试才触发。重试的时候新开一个事务,之前提交的数据已经落库了,第二次重试又执行一遍同样的业务逻辑。三次重试下来,数据库里多了三份数据,库存扣了三次,余额减了三次。

这种叠加效应在幂等性没做好的接口上破坏力极强。订单创建重试三次生成三个订单号,扣库存重试三次扣成负数,发消息重试三次用户收到三条重复通知。问题的根源是事务提交发生在重试之前,这两个切面的顺序没控制好。

外层bean处理重试,内层bean处理事务。重试切面先拦截,调用内层事务方法,事务方法抛异常,回滚完成之后重试切面再决定是否重试。这样每次重试都是干干净净的新事务,不会出现数据叠加上去的灾难。

每个环境都打开事务日志和连接泄漏检测

日志级别配成TRACE之后,控制台会打印每次事务的开始时间、提交时间、回滚时间。方法执行时间异常的时候,看日志能一眼看出事务是不是卡在某个SQL上,还是在某个业务逻辑里耗了太久。没有这些日志,排查事务问题只能靠猜。

连接泄漏检测阈值设成十五秒。一个数据库连接被占用了超过十五秒,日志里会打印完整的堆栈跟踪,精准定位到哪个方法哪行代码一直没释放连接。这个配置在开发环境就打开,压力测试的时候观察哪些接口占连接时间过长,提前优化。

两个配置加起来不到五行,但生产上出的那些连接池耗尽、事务不回滚、死锁的问题,绝大多数靠这两行日志就能定位到根因。配置项放进去之后再也没人为半夜的数据库告警爬起来查半天。

事务注解只有从bean外部调用才生效

所有调用链路的根节点必须是从Spring容器里取出来的bean,外部代码调用这个bean的public方法,事务代理才会介入。内部调用、私有调用、静态调用,代理统统不处理。

这条规则覆盖了前面所有坑。理解了代理机制就知道为什么同一个类里调用无效,为什么私有方法无效,为什么捕获异常会导致事务提交。所有诡异行为归根结底就是一句话:事务代理只在bean边界上起作用。

写代码的时候时刻提醒自己,当前这个方法会不会被同一个类里的其他方法调用。会的话就拆出去。会不会被private方法包裹,会的话就改成public。有没有可能被异常捕获拦截导致代理看不到异常,有可能就别吞异常。十年经验浓缩成八条规则,每条背后都是凌晨两点被电话叫醒的痛苦回忆。

总结

事务注解只在bean外部调用时生效,记住这一条能避开大部分坑。加rollbackFor防受检异常不回滚,事务内不调外部接口,捕获异常必须重抛或手动回滚。开启日志和泄漏检测,把隐性问题变成显性日志。