news 2026/10/8 2:28:55

Spring Boot事务管理实战:从@Transactional到事务失效与边界设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot事务管理实战:从@Transactional到事务失效与边界设计

后端做久了,你会发现Spring Boot事务管理几乎每个月都能在群里被问一次。大家的第一反应都是“加个@Transactional不就完了吗”,可真到线上,订单支付成功但优惠券核销失败、库存扣了两遍、报表导出了半截——这些问题往往都不是数据库的错,而是事务边界划错了。这篇文章我会从Spring Boot里事务的运转原理讲起,把@Transactional背后那些参数掰开揉碎,再把我这几年最常遇到的事务失效和性能坑逐个列出来,每个都配排查链路。不管你是刚学Spring Boot的学生项目,还是在维护多商户商城、就业推荐系统这类正式业务,按这篇的思路过一遍,至少能少踩很多坑。

1. 事务到底由谁负责:Spring Boot在代理层做了什么

1.1 事务不是“加个注解”,而是一条完整调用链

很多人把事务理解得很玄,觉得只要写了@Transactional,数据库就会自动帮你搞定一切。实际上事务不是某一行代码的能力,而是一条完整调用链上的协作结果:你的业务方法、Spring的AOP代理、事务管理器、数据库连接,还有底层的存储引擎,缺一个环节都会出问题。

我习惯把Spring的事务机制类比成开会记账:数据库的事务就相当于打开一个大账本,你的业务代码在账本上写一行、又写一行,Spring事务管理器在旁边盯着,所有的读写都基于同一个Connection完成。最后Spring喊一声“今天这笔账整体生效”,Connection就commit;如果中间出了问题,Spring喊“作废”,Connection就rollback。关键点在于“同一个Connection”——如果业务代码里不小心开了别的连接,或者代码根本没走到Spring的监控范围,那这个“账本”就不是同一本,事务自然就成了空话。

落到代码层面,Spring事务其实做三件事:

// 伪代码:事务管理的核心流程 Connection conn = getConnectionFromPool(); // 1. 从连接池借连接 conn.setAutoCommit(false); // 2. 先关掉自动提交 try { doBusinessLogic(); // 3. 执行业务代码 conn.commit(); // 一切正常 -> 提交 } catch (Exception e) { conn.rollback(); // 有异常 -> 回滚 } finally { conn.setAutoCommit(true); // 恢复连接设置 closeConnection(conn); // 还回连接池 }

真正的commit和rollback是数据库执行的,但“什么时候提交、什么时候回滚、用哪个连接”这个调度权在Spring手里。这也是为什么我们常说Spring事务管理,而不是MySQL事务管理——数据库负责提供事务能力,Spring负责编排事务节奏。

1.2 Spring Boot其实已经帮你配好了事务管理器

在学习阶段很多人有一个误解:要用事务就得手动加@EnableTransactionManagement。在最早的Spring XML时代确实需要,但Spring Boot的自动配置已经把这层做掉了。你在classpath里引入spring-boot-starter-jdbc或者MyBatis相关starter之后,TransactionAutoConfiguration会自动向容器里注册一个DataSourceTransactionManager,对应的DataSource就是配置文件里那个数据源。

所以大多数项目里,你什么都不用配,直接在Service方法上写@Transactional就能用。真正需要手动配置的,是你有多个数据源、或者用了JTA分布式事务、或者要定制事务管理器行为的时候。

有个细节容易被忽略:Spring Boot 2到Spring Boot 3升级时,包名从javax变成了jakarta,连接池配置也可能要调整,但事务这层的核心API几乎没变,依然是org.springframework.transaction下面那一套。如果你在迁移项目时发现事务突然不正常,先检查一下是不是多个事务管理器Bean导致Spring不知道该用哪个,而不是怀疑注解本身。

1.3 一个事务方法从进入到提交,到底发生了什么

下面这个场景很典型:OrderService.createOrder()方法上标了@Transactional,方法里先扣库存,再写订单表,然后插入流水。

@Service public class OrderService { @Resource private StockMapper stockMapper; @Resource private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderRequest req) { stockMapper.deduct(req.getSkuId(), req.getCount()); // 第一步:扣库存 Order order = OrderBuilder.build(req); orderMapper.insert(order); // 第二步:写订单 return order.getId(); } }

当外部调用方(比如Controller)拿到OrderService这个Bean时,它拿到的其实是一个代理对象,而不是你写的那个原始实例。这个代理是Spring AOP在Bean初始化阶段生成的,如果你用的是CGLIB,类名里会带明显的$$记号,用IDEA调试时能看到。

事务拦截器TransactionInterceptor会在进入方法前读取@Transactional的配置(传播行为、隔离级别、回滚规则等),然后走开头说的那套流程:拿连接、关自动提交、执行业务、提交或回滚。这个方法结束之后,连接归还到HikariCP连接池,一切恢复原样。

现在回到主题:为什么很多“加了注解却没有事务”的问题经常出现?答案几乎都指向同一个根源——调用方拿到的不是代理对象,或者异常根本没有经过代理。这一节的铺垫后面排坑全部要用到,先记在心里。

2. 把@Transactional拆开看:传播、隔离、回滚参数的真正语义

2.1 七种传播行为,真正需要重点区分的是三个

传播行为(propagation)是Spring事务里最容易被忽视、线上出事最多的一块。万事通列表先放这里:

传播行为含义典型使用场景
REQUIRED当前有事务就加入,没有就新建默认值,业务方法首选
SUPPORTS当前有事务就加入,没有就算了查询方法、非强制事务
MANDATORY必须已经有事务,否则抛异常强制在事务内执行的方法
REQUIRES_NEW挂起当前事务,新开一个独立事务日志记录、审计、独立重试
NOT_SUPPORTED挂起当前事务,以非事务方式执行事务内发邮件/执行耗时操作
NEVER如果当前有事务直接抛异常禁止事务介入的代码
NESTED保存点嵌套事务,内层回滚不影响外层外层能catch住内层异常的“部分回滚”

REQUIRED是默认项,绝大多数业务方法用这个就够了。REQUIRES_NEW是第二常用的,它跟REQUIRED最大的区别是:新开一个完全独立的事务,自己的提交和回滚跟外层毫无关系,外层回滚也不影响它的结果。举个例子,订单主流程失败要回滚,但每一步失败原因都要记到审计表里,审计记录就不能跟着主事务一起回滚,这时在审计方法上用REQUIRES_NEW就合理。

NESTED则比较微妙,它不是在物理上开新事务,而是基于数据库的Savepoint机制做“局部回滚”。假如外层方法已经插了一条订单,内层方法又插了一条明细,内层挂了,NESTED可以让明细的回滚不影响之前的订单插入。外层如果catch住内层异常,外层继续提交,订单仍在;外层如果没catch,整个事务回滚。MySQL的InnoDB支持SAVEPOINT,所以NESTED在MySQL上基本可用。但要注意:它本质上还是同一个事务,内层锁住的资源并不会因为“局部回滚”就释放,锁的问题是治标不治本。

2.2 隔离级别:先搞清默认值,再想有没有必要改

事务隔离级别是并发场景下最容易把系统拖垮的配置。写@Transactional的isolation属性很容易,真正难的是判断你的业务是否需要那么高的隔离。

隔离级别脏读不可重复读幻读说明
READ_UNCOMMITTED可能可能可能几乎没人用
READ_COMMITTED不会可能可能Oracle默认,互联网常用
REPEATABLE_READ不会不会可能(实际看锁)MySQL默认
SERIALIZABLE不会不会不会串行,性能极低

MySQL默认是REPEATABLE_READ,InnoDB通过MVCC(多版本并发控制)保证了普通查询的一致性快照。很多人以为“REPEATABLE_READ不会幻读”,其实不完全对:在InnoDB里,普通快照读确实看不到后来插入的行,但如果你用SELECT ... FOR UPDATE这种当前读去扫描一个范围,REPEATABLE_READ下会加上间隙锁(gap lock)来阻止幻读,这也就带来了更高的死锁风险。

我的建议是:绝大多数业务用数据库默认隔离级别就行,不要为了“防脏读”随手把隔离级别提到SERIALIZABLE。隔离级别每提高一档,意味着数据库要加更重的锁、持有更久,高并发下连接池很快就会被占满。如果确实有特殊场景,比如对账单查询要求很强的一致性,先评估能不能用SELECT FOR UPDATE锁行,再考虑动隔离级别。

2.3 回滚规则:为什么说checked exception是最隐蔽的坑

下面这段代码,看起来加了事务,但实际运行时会提交成功:

@Transactional public void processFile(String path) throws IOException { FileInputStream in = new FileInputStream(path); // 文件不存在会抛IOException orderMapper.updateStatus(...); // 这行已经写库 }

FileNotFoundException是IOException的子类,属于checked exception。Spring的默认回滚规则是:只回滚RuntimeException和Error,checked exception不回滚。也就是说,上面的updateStatus会真实提交,文件读取却失败了,数据处于一种“看似处理了,实际没处理”的脏状态。

为什么Spring要这么设计?它的官方逻辑是:checked exception通常表示外部资源问题,比如文件不存在、网络不通,这类问题很多时候需要人工介入或者重试,未必代表“业务失败”。但从业务系统角度讲,这个设计坑了太多人。

我的做法是:业务性的异常一律包成RuntimeException抛出来,或者在@Transactional里显式声明rollbackFor = Exception.class。如果你团队规范一点,可以在Service层做一个统一异常处理,任何业务失败都抛BizException(继承RuntimeException),这样事务默认规则就能兜住大部分场景。

再扩展一下noRollbackFor:如果某个异常抛了但你不希望回滚,比如“活动已结束”这种业务提示,就把异常类型填进去。要注意rollbackFor和noRollbackFor同时配置时,noRollbackFor优先级更高。

2.4 readOnly和timeout,真实效果比想象中要保守

事务方法上加readOnly = true,很多同学以为是“只读事务可以不加锁、执行更快”。在Spring+MyBatis这套组合里,它的实际效果比较有限:Spring会调用Connection.setReadOnly(true),MySQL驱动在一定条件下会把这个标记提交给服务器,但查询性能几乎不会有质的提升。它更大的意义是把一个约束写出来:这个事务方法不允许出现写操作,万一有insert/update就会报错。这种约定在团队协作时挺有用,能防止别人往你的查询方法里随手塞写操作。

timeout就实在很多。默认值是-1,也就是不超时。如果数据库层面的锁竞争很严重,一个事务可能无限等下去,把连接池占死。显式设置timeout = 5(单位秒)是说整个事务的执行时间上限,超过之后Spring会抛出TransactionTimedOutException并强制回滚。这在做批量导入、报表计算时要格外注意:方法执行越久,出问题的概率越大。

2.5 注解放在接口上还是实现类上,这是个规范问题

很多人习惯把@Transactional写在接口方法上,理由是“这是公共契约的一部分”。但从代理角度看非常危险:如果Spring用的是JDK动态代理,接口上的注解还能被读取;如果用的是CGLIB(Spring Boot 2.x以后默认对类代理),代理对象是通过继承类生成的,接口上的注解根本不会传给实现类,事务直接失效。

所以我的规范很简单:@Transactional只写在实现类的public方法上,不要写接口,也不要写private方法。类级别可以加@Transactional做兜底,但真实场景里我更推荐方法级配置,因为你不可能让一个类里所有方法都走同一套回滚规则,粒度太粗就是隐患。

3. 线上最常遇到的事务失效场景:一根根捋清楚

3.1 同类自调用导致代理失效,这是最经典的“加了等于没加”

我见过太多人这样写:

@Service public class UserService { public void updateUserAndLog() { this.updatePassword(); // 直接用this调用,事务不生效 } @Transactional public void updatePassword() { userMapper.updatePassword(...); logMapper.insert(...); } }

外部调用updateUserAndLog()时,进来的确实是代理对象,但代理调用到原始对象之后,你在updateUserAndLog里调this.updatePassword(),这个this是原始对象,不是代理。也就是说updatePassword上的@Transactional根本没被拦截器看到,它的执行等同于一个普通方法,没有任何事务。

排查手段很直接:在方法里加一行日志,看当前对象是不是代理类。

log.info("proxy class: {}", this.getClass().getName());

如果打印出来的类名里没有任何CglibAopProxy、$$EnhancerBySpringCGLIB之类的标记,说明这个Bean压根没被代理,或者你拿到的是原始对象。更严谨的做法是调用TransactionSynchronizationManager.isActualTransactionActive(),返回false就说明该方法确实没有活跃事务。

解决方案有三种:一是把需要独立事务的方法拆到另一个Service类里,互相注入调用;二是注入自身代理,通过代理调用内层方法;三是在方法内使用AopContext.currentProxy(),但要在启动类开启exposeProxy。我个人最推荐第一种,拆类是结构上的解法,后面维护更清晰。

3.2 private方法、非public方法与new出来的对象,根本没有代理可言

Spring AOP默认只对public方法生效,JDK动态代理更只代理接口方法。你把@Transactional写在private方法上,编译和启动都不会报错,但事务静默失效。public方法内部调用自己的private方法也一样,不管private方法上面写什么注解,都只是普通方法调用。

还有一种常见情况:把Service类new出来直接调用。

UserService userService = new UserService(); userService.createUser(user);

这个对象完全没有经过Spring容器,注入的Mapper大概率都是null,压根谈不上代理和事务。所以我有时候在群里看到“为什么我的@Transactional不生效”,第一反应先问:你有没有从Spring容器里拿这个Bean,还是自己new的?

3.3 异常被try-catch吞掉:不是事务不生效,是异常根本没出去

这种问题最隐蔽,因为代码看起来完全合理:

@Transactional public void createOrder(OrderDTO dto) { try { orderMapper.insert(order); // 写订单 stockMapper.deduct(skuId); // 扣库存 } catch (Exception e) { log.error("订单创建异常", e); // 只打日志,不往外抛 } }

库存扣减报错后,方法正常返回,Spring看到没有异常,就理所当然提交了。前面已经insert的订单、前面已经扣掉的库存全部保留。数据库层面没有错,Spring层面也没有错,错的是你把异常吞了。

正确的做法只有一条:try-catch可以加,但必须把异常重新抛出去,或者让异常直接冒到事务边界外。还有一条经验:不要在@Transactional方法内部写复杂业务逻辑然后试图“局部处理”,事务要么整体成功、要么整体失败,局部try-catch很容易制造脏数据。

3.4 抛了checked exception但没配rollbackFor,数据悄悄提交了

这个问题在2.3提到了原理,这里给个实际案例。有一次我们做文件导入,Service方法里解析Excel,中间有一行数据格式错误,代码抛了IllegalArgumentException(这是运行时异常),事务回滚正常。但后来需求改动,解析器改成了抛自定义的BusinessCheckedException(继承Exception),而Service上还是写着最朴素的@Transactional,导致每次部分失败时,前面已经解析成功并插入的数据全部留在库里。

从那以后我对团队的要求是:涉及写库的Service方法,@Transactional一律写全rollbackFor = Exception.class,表面上是多敲几个字,实际上是把默认行为改成了业务直觉上更合理的行为。

3.5 Rollback-only陷阱:内层事务把外层拉下水,提交时才知道

这个场景比前几个更反直觉,值得重点说。假设有A、B两个类,A方法加了@Transactional,内部调用B的方法,B的方法也加了@Transactional。默认传播行为是REQUIRED,所以B不会新开事务,而是加入A的那个事务。

@Service public class OrderService { @Resource private CouponService couponService; @Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO orderDTO) { try { orderMapper.insert(orderDTO); // 先写订单 couponService.deductCoupon(orderDTO.getUserId()); // 内层调用,B } catch (Exception e) { log.error("优惠券核销失败,主流程继续", e); // 想吞掉异常,让主流程继续提交 } // 到这里你觉得事务会提交成功 } } @Service public class CouponService { @Transactional(rollbackFor = Exception.class) // 默认REQUIRED,加入外层事务 public void deductCoupon(Long userId) { couponMapper.deduct(userId); // 抛异常 } }

看起来你已经catch住了内层异常,内层回滚不影响外层,但Spring内部遵循一个约定:REQUIRED机制下,内层参与的是同一个物理事务,一旦内层异常触发了回滚标记,整个事务就被标记为rollback-only。外层即使正常返回,提交时Spring发现rollback-only标志,直接抛出UnexpectedRollbackException,然后真实回滚。

也就是说,你最后看到的现象是:日志里“主流程继续”都打了,但数据全没了,控制台冒出一条UnexpectedRollbackException。排查这种问题,不能只看自己的catch,还要去排查有没有“被加入事务”的内层方法抛过异常。

如果业务真的要求内层失败不影响外层,合理姿势是给内层方法配REQUIRES_NEW,让它脱离外层事务;或者把内层的异常处理逻辑从写库方案改为“先记录失败原因,后续异步补偿”,而不是在同一事务里挣扎。

3.6 表引擎是MyISAM,事务在数据库层面就不存在

这个坑在历史项目里非常常见。数据库里的订单表是N年前建的,引擎还是MyISAM,它压根不支持事务。不管Spring事务配得多完美,COMMIT和ROLLBACK对MyISAM来说就是摆设。典型表现是:方法里先insert再故意制造异常,数据居然还在,而且没有任何Exception提示。

排查方式很简单,看表的建表信息:

SHOW TABLE STATUS WHERE Name = 'order';

看Engine字段是不是InnoDB。如果是MyISAM,需要做在线DDL迁移。我的建议是,任何新项目、任何新表,一律用InnoDB,MySQL 8默认就是InnoDB,但从历史库迁移过来的表一定要逐个检查,尤其那些通过脚本自动创建的表,很容易沿用旧引擎。

3.7 多线程和@Async:子线程里的事务跟父线程毫无关系

再写一个很容易踩的场景:

@Transactional public void processOrder(Long orderId) { orderMapper.updateStatus(orderId, "PROCESSING"); asyncTaskService.sendNotification(orderId); // 异步发通知 int result = 1 / 0; // 故意出错,想让整个流程回滚 }

asyncTaskService.sendNotification如果标记了@Async,Spring会把任务提交到线程池里执行。父线程的事务是通过ThreadLocal绑定的,子线程根本没有继承这个ThreadLocal,所以子线程里即使有@Transactional,它也是在新线程里新开的事务,父线程回滚时,子线程可能已经提交成功了。

更麻烦的是:父线程提交还没完成,子线程就开始了,子线程可能读不到父线程还没提交的修改,导致通知内容过期。

所以我的铁律是:不要在事务方法内部异步执行任何写操作。如果一定要做,优先用TransactionSynchronizationManager在afterCommit阶段触发异步任务,保证“先提交、再通知”。

4. 事务边界的艺术:大事务、并发锁与提交后的动作

4.1 事务是越短越好,不是“所有操作都包起来”越好

刚写Spring Boot时我也犯过这个错:一个方法里查点数据、调个远程接口、算半天报表,最后update一下,为了“保险”,整个方法加上@Transactional。后来线上出现连接池耗尽,排查才发现是几个大事务把连接占住不释放。

事务本质上是一个“持有资源”的状态:连接被占用、行锁被持有、undo log不断膨胀。你把远程调用放进来,事务就跟着网络IO一起等;你把循环批量插入放进来,几千次写操作之间其他事务全部排队。

我的拆分思路是:事务只保护“必须同时成功或同时失败”的那段写库操作。查询、校验、远程调用、Excel解析这些都放到事务外面。比如创建订单:先在事务外做参数校验、幂等判断、风控检查,然后开启事务,扣库存、插订单、插流水,提交。整个过程控制在几十毫秒以内。

还有一个反直觉的点:不是所有查询都适合放在事务外。如果业务要求“查询到的数据必须和后续写入在同一快照”,比如对账场景,先查余额再更新余额,这个“查”如果放在事务外,中间被别人改了,后续update就基于过期数据了。这种要么显式开启事务,要么用乐观锁(version字段)兜底,不能一概而论。

4.2 事务里别发MQ、别调第三方API:把提交后动作拆出来

发消息这个操作在代码里看起来只是一行,但它引入了网络调用和外部系统的不确定性。想象一个场景:事务方法里insert订单成功,然后往MQ里发“新订单”消息,这时候事务还没提交。如果紧接着事务回滚了,那MQ消息已经发出去了,消费者去查订单——查不到,或者查到的是旧数据。轻则一条告警日志,重则业务状态错乱。

更稳的做法是把“投递消息”放到事务提交之后:

@Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insert(dto); // 写库 OrderEvent event = buildEvent(dto); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { mqSender.send(event); // 只有事务真正提交了,才发消息 } }); }

afterCommit里如果发消息失败,不会影响主事务结果,但需要记录日志、做重试。更彻底的方案是本地消息表:业务表写库的同时往message表插一条待发送记录,同一个事务保证一致性,然后由后台任务扫表投递。这个思路在第五章的多数据源场景还会再提到。

4.3 事务与并发锁:死锁不是玄学,是访问顺序问题

有一次我排查一个库存扣减的死锁问题,两个线程几乎同时在做“扣商品A库存+扣商品B库存”的操作。A线程先更新sku1再更新sku2,B线程先更新sku2再更新sku1。两个事务各自持有一把行锁,然后互相等待对方释放另一把锁,InnoDB检测到死锁后回滚其中一方。

死锁日志怎么查?MySQL里执行SHOW ENGINE INNODB STATUS,看LATEST DETECTED DEADLOCK部分,里面会列出两个事务持有哪些锁、等待哪些锁。但日志只能事后分析,真正要解决的是设计问题:多条记录的更新顺序必须全局一致。比如所有业务都按“skuId从小到大”排序后再更新,就不会出现交叉等待。

另外还有个容易忽视的点:不加索引的update会从行锁退化成表锁。MySQL的UPDATE如果where条件没有走索引,会把全表记录都锁一遍,高并发下一碰就死锁。建表检查索引,是事务配套的基本功。

4.4 事务监控:没有指标你只能猜

排了很多坑之后,我意识到事务问题很多时候是“事后才知道”。如果能早一点看到指标,很多故障完全可以提前预警。Spring Boot自带Actuator,配合Micrometer可以暴露HikariCP连接池的实时状态。重点看的指标有这么几个:

指标名含义风险信号
hikaricp_connections_active当前活跃连接数持续接近最大值说明连接不够用
hikaricp_connections_pending等待连接的线程数出现持续等待说明有大事务占连接
hikaricp_connections_idle空闲连接数长期为0要小心
hikaricp_connections_max最大连接数确认是否被长期占满

如果你项目里集成了Spring Boot Admin,可以用它的界面看连接池状态和线程状态,很方便,但它不会告诉你“哪个方法占用了连接”。想定位到方法层面,可以自己做一个简单的AOP拦截器,记录每个@Transactional方法的执行耗时和当前活跃连接数。耗时超过500毫秒的事务方法优先排查,大事务通常都在这个名单里。

5. 多数据源和异步任务:复杂场景下的事务管理实践

5.1 一个@Transactional只能管一个数据源,这要先认清

很多多商户商城项目会做读写分离,或者按业务拆多个数据库。有一个基本事实必须先明确:Spring的DataSourceTransactionManager绑定的是一个DataSource,你在哪个DataSource上开启事务,事务就只覆盖这个数据源的写操作。如果你在一个方法里操作了两个库,@Transactional只能保证其中一个库的事务性,另一个库的操作完全不受控制。

多数据源项目里常见启动报错是NoUniqueBeanDefinitionException:Spring发现容器里有多个PlatformTransactionManager,不知道用哪个。解决办法是给每个DataSource配自己的事务管理器,并在@Transactional里显式指定:

@Bean public PlatformTransactionManager orderTransactionManager( @Qualifier("orderDataSource") DataSource orderDataSource) { return new DataSourceTransactionManager(orderDataSource); } @Bean public PlatformTransactionManager userTransactionManager( @Qualifier("userDataSource") DataSource userDataSource) { return new DataSourceTransactionManager(userDataSource); }

使用的时候按需指定:

@Transactional(transactionManager = "orderTransactionManager", rollbackFor = Exception.class) public void createOrder(...) { // 这个方法内事务只覆盖orderDataSource }

多数据源场景下最常见的问题不是“事务不生效”,而是“事务管理器指错了数据源”。我见过一个项目,两个数据源都配了,结果主库的事务管理器被标了@Primary,另一个事务在方法里指定了错误的transactionManager,静默写到了从库,查问题查了整整一天。所以显式指定transactionManager,比依赖@Primary兜底靠谱得多。

5.2 跨库强一致:XA、Seata、本地消息表,没有银弹

如果需要在一个业务操作里同时更新两个库,并且要求强一致,这就升级成了分布式事务问题。Spring Boot里可以引入Atomikos或Narayana搭配JTA来做XA两阶段提交,但实际效果往往很骨感:性能损耗很大,全局锁和协调器容易成为瓶颈,维护成本也不低。对于高并发互联网业务,我一般不推荐上XA。

更符合实际的做法是绕开“同步强一致”,采用最终一致性。

举一个多商户跨境商城的经典例子:用户支付成功后,系统要更新订单状态、给商户结算余额、再记一笔平台手续费。这三个动作如果强行放进同一个数据库事务,等于把订单库和资金库耦合在一起,并发一高就出问题。

实际设计可以拆成:订单库一个本地事务,把订单状态改成已支付,同时往本地消息表插入一条“待结算”记录;资金服务消费消息,再在资金库里做结算。这个过程中订单状态和资金状态可能短暂不一致,但通过消息表驱动,最终能对齐。如果消费失败,消息表重试;如果一直失败,告警人工介入。这套方案看起来不如分布式事务“完美”,但在工程上稳定得多。

如果项目已经决定用Seata这类分布式事务框架,AT模式可以做到对业务代码侵入较小,但同样要接受性能损耗和回滚补偿的复杂度。关键是团队要有能力处理异常分支,而不是以为“框架接管了就万事大吉”。

5.3 @TransactionalEventListener:把提交后动作优雅地剥出来

前几节反复提到“提交后再做点什么”,Spring其实给了原生支持:@TransactionalEventListener。它跟普通@EventListener的区别在于,可以监听事务的提交、回滚等阶段。

常见写法是这样:

@Component public class OrderEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onOrderCreated(OrderCreatedEvent event) { // 事务提交后才执行 notificationService.send(event); } }

对应业务侧:

public Long createOrder(OrderDTO dto) { orderMapper.insert(dto); applicationEventPublisher.publishEvent(new OrderCreatedEvent(dto)); return dto.getId(); }

事件发布发生在事务方法内部也没关系,listener会等到事务提交后再执行。这样既不用手动注册TransactionSynchronization,代码也更好读。

要注意的是:AFTER_COMMIT阶段如果listener里抛异常,主事务已经提交了,不会回滚,所以listener内部要做好异常捕获和重试。另外,默认情况下如果当前没有事务,@TransactionalEventListener是不执行的(fallbackExecution可以改变这个行为),这个细节很多人踩过。

最后说一点个人习惯

这篇文章写到这里已经很长了,但如果你只记一条,我希望是:先想清楚事务边界,再写@Transactional。不要把所有方法都加上事务,也不要让一个事务跨越太多资源。我自己动手前会先在草稿纸上画出“哪些写库动作必须同生共死、哪些动作可以晚一点做”,边界一旦清楚,传播行为、隔离级别、异步补偿方案的选型都会变得很自然。事务管理这东西,普通项目里看不出来,等高并发、分布式场景一到,边界划得好不好直接决定你半夜会不会被电话叫醒。希望这篇踩坑记录能帮你少熬几个夜。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 2:28:08

企业网络视频监控方案:教你算清码率、存储与带宽,避免返工

简介:这是一份面向企业安防与系统规划人员的网络视频监控方案文档,聚焦传统模拟监控在性能、稳定性、布线工程量和造价等方面的痛点,并给出基于TCP/IP协议的全数字化网络视频监控系统整体设计思路。文档以VL网络摄像机系统为例,对…

作者头像 李华
网站建设 2026/10/8 2:28:04

m3u8下载失败原因与MP4转换技术解析

简介:这是一款轻量级在线m3u8视频提取与转MP4工具,面向视频爱好者、内容创作者及前端开发者,解决HLS流媒体无法直接下载和本地播放的痛点。用户无需安装软件,仅通过浏览器输入m3u8链接即可完成在线解析、分片合并与格式转换&#…

作者头像 李华
网站建设 2026/10/8 2:27:54

深入解析下一个排列算法:字典序与原地修改

1. 项目概述与核心需求解析1.1 “下一个排列”到底是什么第一次在LeetCode上遇到“下一个排列”这道题时,我其实有点懵。因为“排列”这个词在高中数学里就学过,但题目要求的东西,跟我想象中那种全排列输出的场景完全不同。题目是这么描述的&…

作者头像 李华
网站建设 2026/10/8 2:27:28

宠物商城系统实战:SpringBoot+Vue前后端分离开发与部署全解析

宠物用品交易网站听起来是个很“传统”的练手项目,但把商品、购物车、订单、用户、后台管理这一整套流程用 SpringBoot Vue MyBatis MySQL 跑通,你会发现里面全是前后端分离项目实战的经典知识点。这个项目我前后搭了三遍,第一遍败在版本搭…

作者头像 李华
网站建设 2026/10/8 2:27:25

应急响应体系化建设:Linux备份恢复策略与实战

1. 应急响应为什么必须重视备份恢复这件事干了这么多年运维和应急响应,我见过太多让人跺脚的场景:业务被入侵了、数据被删了、系统崩溃了,第一反应是赶紧找人修,结果修了半天发现自己根本没留一口“气”——没有可用备份。服务器上…

作者头像 李华
网站建设 2026/10/8 2:27:25

FDL数据管道实战:破解数据孤岛,业务人员也能上手

上个月帮一家制造企业做数据摸底,IT负责人给我看了个统计:公司大小系统17个,每个月财务要出经营分析,光取数就得花四五天,遇到数据对不上还要来回找。他说,这些系统自己都知道“有数据”,但互相…

作者头像 李华