☰
Spring Boot事务管理实战:从@Transactional到分布式事务避坑指南
2026/10/7 3:10:03 网站建设 项目流程

说起 Spring Boot 的事务管理,我的第一反应是:这是每个后端开发都绕不开的基础功,但也是坑最多的地方。很多人觉得事务不就是@Transactional注解加一下就完事了吗,可真到了生产环境,事务不生效、数据没回滚、锁超时、连接池被打满……这些问题一旦冒出来,排查起来往往让人头皮发麻。这篇文章我不打算从教科书定义讲起,而是结合这些年实际踩过的坑,从入门到实战把 Spring Boot 事务管理的来龙去脉说透,尤其是那些文档里不会明说、但真实项目里总会坑你一把的细节。无论你是刚接触 Spring Boot 的初学者,还是已经写过一段时间业务代码、准备系统排查事务问题的开发者,这篇文章应该都能帮上忙。

1. 事务的底层逻辑:数据库和 Spring 到底在忙什么

1.1 ACID 四个特性,说得轻松落地才知深浅

事务这个词,本质上解决的是“多步操作不能只做一半”的问题。最经典的场景不用我说你也知道——转账:A 扣钱、B 加钱,这两件事要么都成功,要么都失败。如果 A 扣了钱但 B 加钱失败,账就对不上了,这在金融系统里是要出大事的。

数据库事务围绕四个特性去做文章,就是教科书上常说的 ACID:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。我平时和团队里新人聊天时发现,大家最容易混淆的是“一致性”。很多同学以为一致性就是让数据库满足主外键、非空这类约束,这其实只是最表层的意思?。一致性真正强调的是业务层面的状态正确,比如转账前后、A+B 的总金额必须不变,这需要应用层配合事务边界来保证。数据库只负责约束层面的完整性,业务上的一致性还得你自己设计好。

隔离性就更有意思了。多个事务同时跑的时候,互相之间应该看到什么、不能看到什么?这引出了脏读、不可重复读、幻读这些概念。数据库提供四种标准隔离级别:读未提交、读已提交、可重复读、串行化。MySQL 默认是可重复读,Oracle 默认是读已提交。你写代码的时候如果没显式指定隔离级别,Spring 会用什么?答案是:用数据库默认的。这个细节很重要,因为后面排查隔离级别相关问题时,很多人第一反应是去看代码,其实根源在数据库配置上。

持久性相对好理解:事务一旦提交,数据就要永久保存,哪怕数据库宕机也要能从日志里恢复出来。这是数据库存储引擎做的事,和事务管理器没有直接关系,但在分布式系统里,持久性会极大影响恢复策略的设计,这点后文讲到分布式事务时再展开。

1.2 Spring 抽象了三方:三个“接口翻译官”

回到 Java 这一侧。一个很现实的问题是:我们不可能只用一个数据库连接,项目里往往同时存在 JDBC、MyBatis、JPA,甚至多个数据源。每种技术的“开启事务”“提交”“回滚”API 都不一样,Spring 想让你写业务代码时不用关心这些底层差异,于是做了抽象。

核心就是三个家伙:PlatformTransactionManager、TransactionDefinition、TransactionStatus。我在画架构图的时候习惯把它们叫做“三个翻译官”。PlatformTransactionManager负责真正去开启、提交、回滚事务,每个数据源技术都有对应的实现,比如 JDBC 对应DataSourceTransactionManager,JPA 对应JpaTransactionManager。TransactionDefinition定义事务的属性,包括隔离级别、传播行为、超时时间、是否只读。TransactionStatus则是事务运行时的状态,比如当前事务是不是新建的、有没有被标记为回滚。

你可能会问:那我是用编程式事务好,还是声明式事务好?我的建议是,大多数业务场景用声明式,也就是@Transactional注解。编程式事务没有 AOP 代理那些麻烦事,逻辑非常直白,适合在事务边界特别复杂、或者声明式事务实在没法解决的时候用。举个例子,Spring 给我们提供了TransactionTemplate:

@Service public class TransferService { @Autowired private TransactionTemplate transactionTemplate; public void transfer(String fromAccount, String toAccount, BigDecimal amount) { transactionTemplate.execute(status -> { try { accountDao.decrease(fromAccount, amount); accountDao.increase(toAccount, amount); return Boolean.TRUE; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); } }

这段代码把事务边界的控制权完全交到你手里,什么时候回滚、什么时候提交一目了然。但代价是模板代码多,而且事务边界散落在业务代码里,后期改起来比较累。所以正常项目里我建议默认使用@Transactional,只有在它搞不定的时候,或者说你在事务里需要非常精细的异常判断时,才考虑TransactionTemplate。

2. @Transactional 使用边界:注解里的每个属性都在回答一个问题

2.1 注解放哪才有效:类、接口还是方法

@Transactional可以放在类、接口或方法上。放在类上表示类里所有公有方法都默认开启事务;放在方法上则只对这个方法生效。如果类和方法的注解同时存在,方法上的配置会覆盖类上的配置,这个覆盖机制别记反了。

有一个很容易踩的坑是:把注解放在接口上。Spring 官方的建议是放在实现类或具体方法上,而不是接口上。为什么?因为@Transactional的生效依赖 AOP 代理,而 AOP 代理分成 JDK 动态代理和 CGLIB 两种。当你的 Service 实现了接口,Spring 默认使用 JDK 动态代理,这时候接口上的注解理应是能被加载到的,但实现类上的注解反而加载不到?实际上 Spring 对接口上的注解支持并不稳定,尤其当你用了某些增强方式或类继承关系时,极容易出现注解被忽略的情况。所以我的习惯很简单:注解一律写在实现类的方法上,接口只定义方法签名,完全不要碰事务注解。

2.2 传播行为:嵌套方法之间的事务怎么搭

传播行为这个概念,我第一次接触时也觉得绕,但它确实是事务设计里最重要的一个开关。简单说,当一个带事务的方法去调用另一个带事务的方法时,这两个事务要怎么合并。

Spring 定义了七种传播行为,其中日常项目里用得最多的是这三个:

传播行为说明推荐场景
REQUIRED有事务就加入,没有就新建,默认值绝大多数业务方法,比如同一次请求里的多个写操作
REQUIRES_NEW无论如何都新开一个事务,挂起旧事务日志记录、操作审计,就算主体事务回滚了也要留下来
NESTED嵌套事务,底层是 Savepoint 机制批量处理中希望单个失败不影响整体,但整体还能回滚

用个实际例子让你直观感受下。下单方法createOrder()是事务 A,里面调用了deductStock()扣库存,它本身也有@Transactional。如果扣库存用的是默认 REQUIRED,它会直接加入事务 A,整个过程只有一个事务;如果扣库存方法里还有其他不需要跟着下单回滚的逻辑,比如扣完库存要发一条操作日志,即使下单失败库存回滚了,这条日志也不想丢,那日志方法就应该用 REQUIRES_NEW,让它开一个新事务独立提交。

很多项目里常见的设计失误是:不分场合全都用 REQUIRED,结果日志也跟着业务一起回滚,排错时连审计记录都找不到。反过来,如果到处都是 REQUIRES_NEW,又会导致数据库连接占用时间变长、事务过于碎片化。传播行为的选择,本质是在回答“这个子操作的生命周期该不该跟主流程绑定”。

2.3 隔离级别:越高越安全,但性能代价你考虑过吗

隔离级别前面提到一点,Spring 这里用isolation属性指定。可选项就是READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。我见过不少团队把隔离级别统一设置为READ_COMMITTED,理由是并发性能好、能避免脏读。这在绝大多数互联网业务里是没问题的,但要注意一个前提:MySQL 默认的 REPEATABLE_READ 下,普通 SELECT 使用的是快照读,并不会因为级别调低引发额外问题;真正的麻烦往往出现在你用了SELECT ... FOR UPDATE这类当前读时,不同隔离级别下锁的释放时机完全不同。

我在实际排查里发现,很多“事务之间互相阻塞”的故障报告,最后都指向隔离级别设置太高或锁范围太大。比如订单金额统计这种纯查询场景,设置 REPEATABLE_READ 加间隙锁,很容易导致大批量插入操作被卡死。如果你只是读多写少的数据统计场景,READ_COMMITTED通常就够了。核心原则一句话:不要无脑追求最高隔离级别,串行化只适合极少数强一致且并发不高的场景,日常业务用数据库默认级别并且接受它即可。

2.4 rollbackFor:为什么 CheckedException 默认不回滚

这是最容易被误解的一个点,也直接关联到很多“事务明明加了注解却不回滚”的问题。Spring 声明式事务的默认规则是:只对 RuntimeException 和 Error 进行回滚,对 CheckedException 不回滚。为什么?Spring 当初的设计哲学是:受检异常通常代表业务上可预期的错误,比如“余额不足”“库存不够”,这种场景未必需要让整个事务失败,也许你可以补偿、降级,所以默认不触发回滚。

可现实是,我们的业务里面把很多检查异常当成了致命错误看待。比如用 Feign 调用第三方接口,抛出的可能是IOException包装的异常,如果你不在@Transactional里显式声明rollbackFor = Exception.class,那这段代码里前面执行的写操作就全部留下来了,而且你不会从日志里发现任何异常,因为异常本身被事务管理器“放过”了。我见过最惨的例子:一个对账系统,A 表批量插入了 5000 条数据,后面一条数据因为字段过长爆了Exception,结果是整个方法返回了失败,但前 5000 条数据已经提交留在库里了,最后还是人工核对账目才发现。

所以作为一个习惯,我给团队定的规矩是:凡是标注了@Transactional的方法,如果在业务语义上只要发生任何异常数据都不能落库,那就必须写rollbackFor = Exception.class。这行字不写,你就等于把决定权留给了“这个异常是不是运行时异常”这种概率游戏。

2.5 timeout 和 readOnly:看起来简单,用起来有讲究

timeout是事务超时秒数,默认 -1 表示不限制。设置超时的意义不只是防单个 SQL 跑太久,更重要的是防止长事务长时间占着数据库连接。比如一个批量导入方法里循环更新了 10 万条数据,中间某条 SQL 发生了行锁等待,整个事务可能挂在那里几分钟,连接池里其他请求全被阻塞。我自己的习惯是,凡是可能操作大量数据的写方法,至少要给一个合理的超时时间,比如 10 秒、30 秒,至少不要让一条不知道哪里来的慢 SQL 拖垮整个应用。

readOnly标记这个事务只做读操作,不能写。它在底层会给数据库传递一个只读提示,部分数据库会做优化,MySQL 下基本没有强制的只读约束,只是 Spring 层面上帮你省了几步检查,对性能提升微乎其微。所以不要指望加了readOnly = true事务就飞快,也不要在只读事务里做写操作然后指望它报错——MySQL 下它并不会报错,只是白白多了一层事务开销。

3. 避坑实录:事务失效的 5 大经典场景,我都替你踩过

下面这部分我认为是整篇文章最有价值的地方。上面讲了那么多理论,实际项目里事务失效的原因,十有八九跑不出下面这几类。我把它们整理成这样一张速查表,再一个个展开:

失效场景根本原因解决方案
同类内部方法调用this调用没走代理对象注入自身代理、拆分不同 Bean、用TransactionTemplate
方法不是 publicAOP 代理机制限制修改访问权限为 public
异常被 try-catch 吞掉事务管理器感知不到异常重新抛出异常或手动 setRollbackOnly
rollbackFor 没设置CheckedException 默认不回滚显式指定rollbackFor = Exception.class
跨线程调用事务上下文只保存在当前线程里把事务边界控制在异步任务内部

3.1 同类内部方法调用:最隐蔽的“这注解没用”

这个坑我至少帮人排查过十几次。场景是这样的:OrderService里有个create()方法,它内部调用了同类里另一个标注@Transactional的updateStock()方法。结果是,updateStock()里发生的异常根本不会触发回滚。

原因得回到 AOP 上。Spring 的声明式事务是靠代理对象实现的。容器注入给你的OrderService并不是你写的那个原始对象,而是一个经过代理包装的对象。你在类内部写this.updateStock()的时候,this指向的是原始对象,而不是代理对象,所以那层事务增强压根没被激发。这就是为什么大家常说“Spring 的@Transactional只有通过外部调用才生效”。

解决办法有三种。第一种,往自己类里注入一个代理引用,比如加一个@Lazy的自身依赖,然后用self.xxx()去调用;第二种,把updateStock()拆到另一个 Service 里,通过跨 Bean 调用自然能触发代理;第三种,也是最直观的,用前面说的TransactionTemplate,把内部事务逻辑包进模板里,不依赖 AOP 代理。我个人建议优先用第二种,拆类往往还能同时优化代码结构,比借助self注入这种技巧更干净。

3.2 非 public 方法:注解写了也白写

@Transactional注解作用于非 public 方法时,即使不报错,事务也不生效。原因是 Spring 对私有方法不做代理包装,CGLIB 生成的子类也没办法把父类的 private 方法增强掉。如果你的方法修饰符是 private、默认包访问权限,或者 final 方法,那事务管理就跟没关系了。

我们团队在这上面还真踩过一次:一个同事把内部定时任务调用的事务方法写成了 package-private 类型,结果事务全程没生效,数据写了一半,定时任务还报成功,最后靠对账才发现。所以检查事务失效问题的时候,第一眼就要看方法修饰符是不是 public。这不是什么高深原理,就是 Spring 代理机制的硬性约束。

3.3 异常被吞掉:事务怎么知道你错了

还有一种非常常见的误用:事务方法里套了 try-catch,异常被抓住之后既不重新抛出,也不标记回滚,然后这个方法就“正常返回”了。事务管理器看到的是:方法没抛异常,那好,提交吧。于是前面所有操作全部落库。

有些时候这个坑是新手无意造成的,但有时也来自对业务补偿逻辑的错误理解。比如你在方法里处理一条记录,其中某一步失败了你把它写进了一个 error 消息队列,然后方法继续执行,你希望“这一步失败不影响整体”。这时你的诉求不是让整个事务回滚,而是“部分操作要保留”。但如果没有仔细设计边界,事务就会把不该提交的数据也一起提交了。

解决方案看场景:如果你确实希望整体失败,那就不要 catch,或者 catch 之后重新抛出RuntimeException;如果你希望“主流程成功,子步骤失败影响可控”,那就不要把子步骤放进同一个事务里,拆成独立事务或者用REQUIRES_NEW。最怕的是你什么都想要,最后得到一个说不清语义的代码块。

3.4 跨线程执行异步任务:事务上下文跟不过去

Spring 的事务实现里,事务信息是通过ThreadLocal保存的。这意味着它天然跟当前线程绑定。当你用@Async把某个方法丢到另一个线程池去执行,或者在事务方法里手动new Thread(...)去操作数据库,新线程里根本没有事务上下文。最典型的是:外面一个大事务方法里调了一个异步发送消息的方法,结果异步线程里报错,外面的事务却一点感知都没有。

这一点在排查“消息发送失败但数据库还是提交了”之类问题时特别容易忽视。解决建议是:事务边界和异步边界别交叉。要么把异步操作放到事务提交之后(比如事务同步器TransactionSynchronizationManager.registerSynchronization),要么让整个异步任务内部自己开一个事务,总之别指望事务上下文能自动跨线程传递。

3.5 存储引擎不支持事务:数据库层面的硬伤

这一点不常发生,但一旦发生就是全场事故。Spring 的事务管理委托给数据库,如果表用的存储引擎不支持事务(比如 MySQL 的 MyISAM),那即使你在应用层把@Transactional写得再完美,底层也不会回滚。MySQL 8 默认的 InnoDB 是支持事务的,但如果你用了某些老版本的默认配置或者手动建表时指定了 MyISAM,那就白搭。

我一般建议项目在启动时加一个数据库校验,把核心业务表的存储引擎统一检查一遍,至少不要让生产环境出现 MyISAM 表。这不算复杂,但可以省下一整晚的排查时间。

4. 数据一致性升级:多数据源、分布式事务与订单场景实践

4.1 多数据源下的事务:谁才是真正的事务管理器

现实项目里,一个 Spring Boot 应用连多个数据库是很常见的事。比如读写分离,比如多商户商城系统里订单库和商品库分开。如果只配了一个DataSource,Spring Boot 会自动装配一个DataSourceTransactionManager,你用@Transactional也就自动走这个管理器。但一旦配多个数据源,你就得手动指定当前事务走哪个TransactionManager。

通常的配置是在@Transactional上写transactionManager = "orderTransactionManager",同时在配置类里定义多个事务管理器并指定@Primary。这里有个坑:Spring 在自动注入PlatformTransactionManager的时候,如果容器里有多个,它会去寻找@Primary标注的那个。如果你忘了加@Primary,启动时会因为依赖注入不明确直接报错,或者你碰巧侥幸启动了,但事务却走错了数据源。用一句话总结:多数据源情况下,事务管理器和数据源要一一对应,别让 Spring 猜。

不过要注意,这种多数据源事务只是“多个独立的本地事务”,而不是跨库的全局事务。比如订单库写成功、商品库写失败,两边是不会自动一起回滚的。如果业务要求“跨库必须一起成功或一起失败”,那就进入分布式事务范畴了。

4.2 分布式事务:从两阶段提交到最终一致

谈到分布式事务,先泼一盆冷水:Spring Boot 本地事务管理再成熟,也解决不了“跨服务、跨数据库的全局一致性”。分布式事务要面对的是 CAP 理论里的取舍,现实世界中很少存在完美的全局事务方案。

业内常见的方案有这么几类:两阶段提交(2PC)是最古典的强一致方案,借助 XA 协议协调多个资源,能保证全提交或全回滚,但性能开销大、对资源锁定时间长、协调者本身容易成为单点,在互联网高并发场景下用得越来越少。TCC(Try-Confirm-Cancel)是业务层面的补偿方案,把事务拆成预留、确认、取消三个阶段,对业务侵入性强,但性能好、适合跨服务场景。Saga 则把长事务拆成一系列局部事务,每步都有对应的补偿动作,更偏最终一致。

日常业务里我见得最多的,其实是本地消息表加消息队列的最终一致方案。比如你要做“创建订单”和“扣减库存”两个操作,这两个操作分属不同服务。可以这样做:订单服务在本地事务里同时写订单表和一条“库存扣减消息”,事务提交后,通过消息队列把这条消息发出去,库存服务消费消息再做扣减。如果库存扣减失败,消息还能重试,直到成功或触发人工补偿。这种方案不需要全局锁,对系统性能影响小,是最贴合互联网业务实践的思路之一。

4.3 多商户商城的实战取,别把事务标注当成分区

结合多商户跨境商城这种常见业务形态,事务设计有一个我特别想强调的教训:别把一个大方法里所有数据库操作都圈进一个事务里,尤其当这个方法还涉及第三方接口调用、文件上传、消息发送的时候。比如下单流程本身并不复杂,但很多人习惯在一个@Transactional方法里连着做:写订单表、扣库存、调支付接口、发短信通知。结果一次支付接口响应慢了几秒,整个事务就占了数据库连接几十秒,连接池一满,其他请求全部排队甚至超时。

我建议的拆分方式是:只把“必须同生共死”的本地写操作放一个事务里,比如写订单表和扣本地库存,事务提交后再异步去调支付、发通知。如果支付失败,那属于业务补偿层面的事,可以通过订单状态机、定时任务去处理,而不是让一个长事务把所有资源锁住。事务设计的目标从来不是“所有操作都要在一个事务里”,而是“哪些操作必须同生共死”。

5. 生产环境中的监控与优化:别让事务成为性能杀手

5.1 长事务的危害:连接池耗尽不是危言耸听

事务本质上握着数据库连接和锁,事务一长,连接占用时间就长,锁持有的时间也长。我处理过一次故障:一个每日对账任务,代码里一个大@Transactional方法循环了几千次查询,其中还夹着一次远程 HTTP 调用,结果整个任务跑了 20 多分钟。期间这个任务独占的那条数据库连接一直不释放,连接池大小一调再调都顶不住,最终所有依赖这个数据源的下游服务全部报连接池耗尽。

这个故障的教训非常典型:连接池大小通常只有几十,每个连接同时只能执行一个事务。长事务不仅影响自己,还会把整个应用的吞吐拖垮。生产环境里一定要重视“事务持续时间”这个指标,一旦发现某个事务执行超过几百毫秒,就该怀疑事务边界是不是划得太大了。

5.2 用 Spring Boot Actuator 和 Admin 盯住事务指标

我们聊监控,绕不开 Spring Boot Actuator 和 Spring Boot Admin。尤其 Spring Boot Admin,它可以把 Actuator 暴露的指标可视化,方便观察数据库连接池使用情况、HTTP 请求耗时、线程池状态。虽然事务管理器本身没有直接的“事务耗时”指标,但你可以借助连接池指标来间接判断。

比如 HikariCP 的hikaricp_connections_pending这个指标,如果一直居高不下,说明连接在等待释放,大概率背后有长事务。再比如数据库侧的information_schema.innodb_trx表,能看到当前所有未提交事务的运行时长和 SQL 语句,这是排查长事务最直接的入口。我在没有专业 APM 工具的团队里,是这样组合使用的:代码里用@Timed或者 AOP 切面统计每个事务方法的执行耗时,数据库侧定时拉取innodb_trx快照,两边一对照,谁在拖慢数据库、哪个方法事务时间异常,基本上一眼就能找出来。

5.3 事务优化的五个抓手

事务性能优化不是靠一个神器,而是几个习惯叠加出来的效果。我在实际项目里总结出下面五条,每条都经过线上验证:

第一,事务范围最小化。只把必要的写操作放进事务,查询、外部调用、组装数据全部移出去。想想看:你已经有数据库连接和锁了,为什么还要让它陪着你去等一个第三方接口?

第二,避免事务内网络调用。这个前面提过,但值得再强调一遍——数据库事务和远程 IO 是天敌。如果一定要调用,至少把调用放到事务提交之后。

第三,批量操作尽量合并 SQL。一条 UPDATE 语句更新 1000 条记录,比循环 1000 次 UPDATE 少了 999 次网络往返和事务里的事务日志写入,性能差距可以到数倍。

第四,合理设置超时。给关键事务方法设置 timeout,避免一条异常 SQL 把整个应用拖死。超时的设置要考虑正常业务耗时余量,别拍脑袋定个 1 秒导致误杀,也别天真地填 600 秒。

第五,readOnly 别乱用。只在明确纯查询场景使用,不要指望它解决性能问题,更不要因为加上它就省略对真正耗时查询的优化。

事务优化这件事,本质上就是四个字:收窄边界。收得越小,扛住的并发就越大,这也是很多高并发系统不把数据库事务当核心协调手段的根本原因——它们把事务拆小了,用状态机、消息队列、补偿机制去保证最终一致,这套思路在复杂度上升时比单纯依赖数据库长事务可靠得多。

最后再分享一个我个人的小习惯:每写完一个事务方法,我会习惯性地问自己三个问题——这个方法会被同类里的其他方法调用吗?异常真的能被传出去吗?rollbackFor 设置对了吗?这三问能筛掉 80% 的事务失效场景。另外,如果条件允许,强烈建议在测试环境把数据库隔离级别、存储引擎调成和生产完全一致,因为很多事务问题只会特定配置、特定并发量下才冒头。希望这篇文章能帮你把 Spring Boot 事务管理这条路上的坑提前填平,至少别在同一个泥坑里栽第二次。

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

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

立即咨询