做后端开发这些年,Spring事务是无论如何都绕不开的一道坎。哪怕你日常工作只是写CRUD,只要一个接口里同时操作了两张表,事务就一定会在某个深夜跳出来教你做人。Spring事务封装了数据库底层的提交、回滚、锁、隔离级别等概念,用好了能让你的数据稳如老狗,用不好就是线上数据一堆烂账,半夜被运维电话叫醒。
这篇文章我想把所有关于Spring事务的实战经验一次性说透:从最基础的“事务到底在解决什么问题”开始,到声明式事务和编程式事务的正确姿势,再到事务传播行为、失效场景、隔离级别、日志排查,最后是分布式事务的落地选型。内容比较长,但每一节都是实际敲过代码、踩过坑之后沉淀下来的东西,看完之后再去处理事务相关的需求,心里会踏实很多。
1. 先搞清楚Spring事务到底在解决什么问题
1.1 一次崩溃引出的经典场景
拿电商系统里最常见的下单流程来说:用户点击“提交订单”,后端要做的事包括创建订单记录、扣减库存、生成支付流水、更新用户优惠券状态。这一串操作分布在四五张表里,任何一步失败,前面的操作如果已经写进数据库,数据就乱了。
假设没有事务,扣库存成功之后,创建订单因为参数校验失败抛出异常,结果就是库存莫名其妙少了,但订单根本不存在。更麻烦的是,用户过一会儿又下了一单,发现库存已经不够了,但后台查订单压根没有这笔消费记录,整个系统就变成了一个“薛定谔的库存”。事务就是为了解决这类问题而存在的:把多个数据库操作捆绑成一个“原子操作”,要么全部成功,要么全部回滚到操作之前的状态。
数据库事务本身提供四大特性,也就是教科书里反复强调的ACID:
- 原子性(Atomicity):一组操作要么全部生效,要么全部不生效。
- 一致性(Consistency):事务执行前后,数据始终满足业务规则和约束。
- 隔离性(Isolation):多个事务并发执行时,相互之间应该保持适当的隔离。
- 持久性(Durability):事务一旦提交,数据变更就是永久的,即使系统崩溃也不会丢失。
这四个特性里,原子性、一致性、持久性相对好理解,难点集中在隔离性上:两个事务同时写同一行数据怎么办?一个事务在中间过程中读到了另一个事务还没提交的修改怎么办?后面讲隔离级别的时候我会展开细说。
1.2 Spring在事务里扮演的角色到底是啥
经常有人把Spring事务和数据库事务混为一谈,但其实这俩不是一个层面的东西。数据库事务是InnoDB引擎提供的能力,Spring事务只是在这层能力之上做了一层调度和封装:帮你决定什么时候发起BEGIN,什么时候执行COMMIT,什么时候执行ROLLBACK,以及事务边界内的数据库连接怎么管理。
打个比方,数据库事务像一辆车,Spring事务像司机。车本身能跑,但谁踩油门、谁踩刹车、什么时候挂挡,得有个统一控制的人来操心。没有Spring的时候,你得自己在代码里写:
Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行各种SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }每个业务方法都这么写一遍非常折磨人,而且只要忘记在某个分支上写rollback(),数据就出问题。Spring通过IOC和AOP把这段重复劳动封装掉了:你只需要在一个方法上加@Transactional注解,Spring会创建一个代理对象,在进入方法之前自动开启事务,方法正常返回则提交,方法抛出异常则回滚。
这就是声明式事务的核心逻辑。它的底层依赖Spring AOP的代理机制,这句话现在听起来没什么感觉,但到后面讲“事务失效场景”时,你会发现几乎所有经典的失效问题,根源都出在“代理”这两个字上。
2. 声明式事务和编程式事务怎么选
2.1 @Transactional的用法和默认行为
日常开发中绝大多数场景用声明式事务就够了,也就是直接加注解。用法非常简单:
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrder(OrderCreateRequest request) { orderMapper.insert(request.getOrder()); stockMapper.deduct(request.getSkuId(), request.getCount()); couponMapper.consume(request.getUserId(), request.getCouponId()); } }这里有一个特别值得注意的默认值问题:@Transactional默认只在抛出RuntimeException和Error的时候回滚,对于受检异常(比如FileNotFoundException、SQLException)是不回滚的。这个设计让很多刚接触Spring的人吃过亏:方法里抛了个受检异常,代理一看“哎这个异常不在回滚列表里”,直接给提交了,数据就这么错了。
所以我的建议是,公司内部项目中统一约定:业务方法上加注解时,一律显式写上rollbackFor = Exception.class。不要依赖默认值,不要觉得自己一定记得住这个规则,因为代码是合作出来的,你不知道别人哪天在方法里抛了一个受检异常。
另外@Transactional有几个常用的配置项需要留意:
propagation:事务的传播行为,默认REQUIRED,后面专门讲。isolation:事务隔离级别,默认跟随数据库。timeout:事务超时时间,默认-1,表示不超时。线上建议设置一个合理的超时时间,避免慢SQL把事务拖得很长。readOnly:是否为只读事务,设置为true时会对数据库做优化,但前提是事务内确实没有增删改操作。
readOnly这个属性有一些误解,它不是强制不允许写操作,而是给数据库一个提示,让MySQL根据只读去优化一些场景,比如不分配回滚段之类的。如果你在readOnly = true的事务里执行了INSERT或UPDATE,大多数情况下MySQL并不会直接报错,但语义上不推荐这么做,因为结果不可预期。
2.2 编程式事务TransactionTemplate的使用姿势
声明式事务在大部分场景够用,但有一种情况你会想要编程式事务:同一个方法里,一部分操作需要事务保护,另一部分操作不需要,而且你希望手动控制提交的时机,还希望在提交失败后做一些额外的善后工作。
Spring提供了TransactionTemplate来支持这种细粒度控制。先注入一个模板对象:
@Service public class PaymentService { private final TransactionTemplate transactionTemplate; public PaymentService(TransactionTemplate transactionTemplate) { this.transactionTemplate = transactionTemplate; } public void processPayment(PaymentRequest request) { String paymentId = transactionTemplate.execute(status -> { // 事务内:扣款、写流水 accountMapper.deduct(request.getUserId(), request.getAmount()); paymentMapper.insert(PaymentRecord.create(request)); // 如果业务上判定需要回滚,可以手动调用 if (request.getAmount() > 10000) { status.setRollbackOnly(); return null; } return paymentIdGenerator.nextId(); }); // 事务外:发送通知、异步对账等 notifyService.sendSuccessMessage(paymentId); } }TransactionTemplate.execute()接收一个TransactionCallback,回调里返回什么,事务提交后execute就返回什么。如果想中途放弃事务,调用status.setRollbackOnly(),Spring会放弃提交并执行回滚。
我个人的经验是:能用@Transactional就尽量用注解,只有遇到“部分逻辑不能进入事务”“事务提交后需要基于结果做一些外部动作”等复杂场景,再考虑TransactionTemplate。编程式事务给你灵活性的同时,也把边界责任交到了你手里,代码可读性会差一些,而且容易忘记关闭模板,需要格外仔细。
3. 事务传播行为:代码一旦嵌套,事情就开始复杂了
3.1 七种传播行为到底各是什么意思
单个方法的事务很好理解,方法一进去开事务,一出来就提交或回滚。但实际业务里方法之间是有调用关系的:ServiceA.methodA()调用了ServiceB.methodB(),两个方法上都有事务注解,那执行时到底应该共用一个事务,还是各自开各自的事务?这就涉及到传播行为。
Spring定义了七种传播级别:
| 传播级别 | 语义 | 典型使用场景 |
|---|---|---|
| REQUIRED | 当前有事务就加入,没有就新建。默认值 | 绝大多数业务方法 |
| SUPPORTS | 当前有事务就加入,没有就以非事务方式执行 | 查询类方法,可有可无 |
| MANDATORY | 当前必须有事务,否则抛异常 | 强制在事务内执行的操作 |
| REQUIRES_NEW | 挂起当前事务,新建一个独立事务 | 记录操作日志、发MQ等不希望被主事务回滚的动作 |
| NOT_SUPPORTED | 挂起当前事务,以非事务方式执行 | 内部为了减少锁竞争 |
| NEVER | 不能存在事务,否则抛异常 | 测试或特殊校验逻辑 |
| NESTED | 有事务时保存点方式嵌套回滚 | 批量处理希望部分回滚 |
这里的重点有两个:REQUIRES_NEW和NESTED。很多人会把它们搞混,其实差别非常大。
用REQUIRES_NEW,内层事务被挂到一个全新的连接上,完全独立提交和回滚。外层事务回滚时,内层已经提交的成果不会被回滚。用NESTED,内层事务不是“新事务”,而是基于外层事务的保存点(Savepoint)来实现嵌套,内层回滚时只会回滚到保存点,外层后续可以继续提交,但如果外层最终回滚了,内层的成果也会跟着一起没。
3.2 两个最容易踩坑的嵌套场景
第一个坑来自REQUIRED嵌套导致的“大事务回滚问题”。A方法有事务,调用B方法,B方法也有事务且是默认的REQUIRED,那么B会加入A的事务,两者共享一个物理事务。如果B内部抛出异常,整个A事务就被标记为rollback-only,哪怕你在A里用try-catch捕获了B的异常,最后A提交时依然会抛UnexpectedRollbackException,因为事务已经被标记为只能回滚。
这种场景最常见的报错是:
Transaction silently rolled back because it has been marked as rollback-only很多人第一次遇到这个错误都懵了:明明捕获了异常,方法也正常返回了,为什么最后还是回滚?原因就是rollback-only标记不是本地变量,它属于整个共享事务。理解了这一点,你会明白为什么在嵌套调用里,捕获异常后再继续跑业务是个危险操作。
第二个坑来自REQUIRES_NEW连接被占满的问题。内层用独立事务,意味着要额外占用一个数据库连接。如果外层事务里循环100次调用一个REQUIRES_NEW方法,而连接池最大只有20,第21次就会因为拿不到连接而卡死。所以REQUIRES_NEW要慎用,尤其不能放在大循环里。
如果确实需要“批量处理一部分失败不影响另一部分”,我更倾向于使用NESTED。它不额外占用连接,失败时只回滚到保存点,对连接池的压力小得多。当然NESTED依赖数据库对Savepoint的支持,MySQL的InnoDB是支持的。
4. 再聊透事务失效的七个经典现场
Spring事务失效是面试高频题,也是线上事故高发区。下面这些失效场景全部来自真实项目,每一条都值得你在代码里做一次排查。
4.1 同一类内部调用导致this调用绕过了代理
这是最常见也最隐蔽的失效方式。Spring声明式事务基于AOP代理,本质上你调用的orderService.createOrder()实际上是代理对象的方法,不是原始Bean的方法。但如果你在OrderService里写了一个普通方法,直接通过this调用另一个带@Transactional的方法,这时候this指向的是原始Bean,代理根本没进来,事务自然就不生效。
@Service public class OrderService { public void outerMethod() { // 这里通过this调用,@Transactional完全不生效 this.innerMethod(); } @Transactional(rollbackFor = Exception.class) public void innerMethod() { orderMapper.insert(...); } }解决办法有几种:把innerMethod拆到另一个独立的Service类里调用;或者注入自己的代理(@Autowired自己)来调用;或者用ApplicationContext.getBean()获取代理对象再调用。最简单的就是拆类,这也是代码结构上最清晰的做法。
4.2 方法不是public或者类没有交给Spring管理
Spring官方文档明确说了,@Transactional放在public方法上才会被代理拦截。原因不难理解:CGLIB或JDK动态代理生成的子类只能重写public和protected方法,private方法根本不能被子类调用。如果你把事务注解写在private方法上,IDE可能会给个Warning,但多数人不会注意,注解就悄悄失效了。
另外如果类本身没有加@Service、@Component之类的注解,Bean没有注册进Spring容器,那你通过new创建对象调用方法,Spring对这个对象一无所知,事务框架根本不会介入。
4.3 异常被catch住,事务压根看不到异常
异常被捕获,这个问题比诡异报错更麻烦,因为代码运行得“很成功”,数据却错了。下面这段代码就是典型反面教材:
@Transactional(rollbackFor = Exception.class) public void updateStock() { try { stockMapper.deduct(skuId, count); // 这里抛了异常,但被吞掉了 throw new RuntimeException("库存不足"); } catch (Exception e) { log.error("扣库存失败,但事务不会回滚,因为异常没抛出去"); } }Spring判断是否回滚的依据只有一个:方法有没有把异常抛到代理外面。一旦你在方法内部把异常吞了,代理那边收到的是“方法正常返回”的信号,就执行提交了。所以如果你的业务逻辑确实需要捕获异常做善后,处理完一定要throw e或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
4.4 数据库存储引擎不支持事务
这个坑在今天不算太多,因为MySQL默认InnoDB引擎已经支持事务。但如果你接手的老系统里,某张表用的是MyISAM引擎,那在这张表上的操作无论如何都不会有事务效果。MyISAM连BEGIN都不支持,你的@Transactional注解对它来说就是一张废纸。
排查方法很简单,执行:
SHOW TABLE STATUS LIKE 'your_table_name';看Engine字段是不是InnoDB。顺便提醒一句,不要为了图省事把表全部改成InnoDB,要考虑数据量、索引、全文检索等需求,但业务核心表基本都应该是InnoDB。
4.5 事务传播行为设置成了不会开启事务的值
有些同学会把@Transactional(propagation = Propagation.SUPPORTS)用在写操作上,意思是“有事务就加入,没事务就算”。如果调用链上游没有事务,这个方法就会以非事务方式执行,写操作一旦出错根本不会回滚。
这是一个容易忽视的配置问题。写操作的方法,传播级别最好用默认的REQUIRED或者干脆显式写REQUIRED,不要让“是否开启事务”变成一个不可预期的开关。
4.6 多线程环境下的独立连接导致事务隔离
Spring事务默认和线程绑定:事务信息保存在ThreadLocal里,一个事务对应持有这个事务的线程所用的那一个数据库连接。如果你在事务方法里起了一个子线程,在子线程里执行数据库操作,这个子线程拿到的是另一个独立的连接,它不参与主线程的事务,也不会跟着主线程一起提交或回滚。
@Transactional(rollbackFor = Exception.class) public void createOrder() { orderMapper.insert(...); new Thread(() -> stockMapper.deduct(...)).start(); // 子线程里扣减库存失败,主线程事务也不会感知到 }多线程共享事务是一个复杂的问题,没有银弹。常规做法有两种:一是避免在事务里使用子线程处理数据库操作,把异步动作放到事务提交之后再执行;二是设计独立的异步事务,配合消息队列做最终一致性。
4.7 代理模式的底层限制和误配置
Spring Boot 2.x默认使用CGLIB代理,Spring Boot 3.x强制使用CGLIB。如果你还在配置@EnableTransactionManagement(proxyTargetClass = false)强制使用JDK动态代理,而你的类没有实现接口,那代理创建就会失败或者不生效。另外如果类本身是final的,CGLIB没法生成子类,代理也会失败。
这类问题的共性排查方式是:启动时加入-Dspring.aop.proxy-target-class=true或者确认代理类是否成功生成,再或者直接在Bean里打印一下自己对象的Class,看是否是代理类。
5. 事务隔离级别一次讲清楚
5.1 四种隔离级别与三个并发问题
事务隔离级别解决的问题是并发事务之间的干扰。这种干扰总结下来是三个问题:脏读、不可重复读、幻读。
- 脏读:事务A读到了事务B未提交的修改。如果B回滚了,A读到的数据就是错的。
- 不可重复读:事务A在同一事务内两次读取同一行数据,结果不一样,因为中间被事务B修改并提交了。
- 幻读:事务A按条件查询一批数据,期间事务B插入了符合条件的新记录,A再次查询时多出来几行“幻觉”数据。
Spring事务里对应四种隔离级别:
| 隔离级别 | 能防脏读 | 能防不可重复读 | 能防幻读 |
|---|---|---|---|
| READ_UNCOMMITTED | 否 | 否 | 否 |
| READ_COMMITTED | 是 | 否 | 否 |
| REPEATABLE_READ | 是 | 是 | 否(MySQL默认可以防) |
| SERIALIZABLE | 是 | 是 | 是 |
可以这样记忆:隔离强度从低到高,性能开销也是从低到高。SERIALIZABLE最安全,代价是并发能力极差,基本是串行化了,生产环境除非特殊数据校验,一般不会用。
5.2 MySQL InnoDB的默认行为和MVCC
特别说明一个容易造成困惑的点:MySQL InnoDB默认的隔离级别是REPEATABLE_READ,但在InnoDB里,这个级别通过MVCC多版本并发控制机制已经解决了大部分幻读问题,尤其是快照读(普通的SELECT语句)。
MVCC的原理一句话概括:每一行数据在更新时都会生成一个新的版本,读操作根据事务开始时的快照来决定应该看到哪个版本的数据。所以同一个事务里,普通的SELECT多次执行,结果是稳定的,即使其他事务插入或修改了新数据也不受影响。
但MVCC只对快照读生效。如果你的SQL使用了SELECT ... FOR UPDATE或者INSERT ... ON DUPLICATE KEY UPDATE这类当前读,读到的就是最新版本数据,此时REPEATABLE_READ级别下幻读保护需要依赖间隙锁(Gap Lock)和临键锁(Next-Key Lock)来实现。这也是为什么同样一个事务,用普通SELECT和用FOR UPDATE查出来的数据可能不一样。
生产环境里,我建议不要轻易把隔离级别调整到SERIALIZABLE,更不要随便降到READ_UNCOMMITTED。MySQL默认的REPEATABLE_READ在绝大多数场景是够用的。如果你希望每次读到最新已提交数据,可以单独修改当前连接的事务隔离级别为READ_COMMITTED,但要注意这是对整个Session生效的,影响面比较大。
5.3 @Transactional的isolation配置注意点
Spring允许在注解上指定隔离级别:
@Transactional(isolation = Isolation.REPEATABLE_READ) public void doSomething() { }这里有个很重要的经验:不要过度依赖在应用层配置隔离级别。事务隔离级别最终要数据库支持才行,而且应用层指定级别意味着每个数据库连接都可能会切换级别,频繁切换会带来额外开销。更合理的做法是:数据库端预设好默认隔离级别,应用层只在个别特殊业务里指定,并且做好注释说明为什么要这里更高或更低。
还有一点,READ_COMMITTED和REPEATABLE_READ在MySQL上的行为差异在锁方面体现得很明显。READ_COMMITTED下InnoDB的间隙锁定相对少,死锁概率更低,并发能力略好,但你可能需要承受不可重复读的问题。具体选哪个,取决于业务对一致性的要求。比如账户余额类查询,通常不能接受不可重复读,优先考虑REPEATABLE_READ;普通订单状态查询,READ_COMMITTED就够用了。
6. 项目落地:事务日志排查与性能优化
6.1 如何在日志里确认事务真正开启
线上事务出问题时,第一步是确认事务到底有没有开启。Spring的日志默认不会输出事务边界,需要打开对应包的日志级别:
logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction: DEBUG打开后能看到类似这样的日志:
Acquired Connection [...], not (re)using existing Registering transaction synchronization for SqlSession Releasing transactional SqlSession after transaction Committing JDBC transaction on Connection [...]看到Committing JDBC transaction和Rolling back JDBC transaction on Connection,说明事务确实在起作用。如果只有SQL执行日志,没有事务开启和提交的回执,就要怀疑上节说的失效问题了。
另一个排查手段是直接用数据库端观察。在MySQL里执行:
SHOW ENGINE INNODB STATUS;查看TRANSACTIONS段,能看到当前活跃的事务和持有的锁。线上操作这个要用只读权限的账号,最好在业务低峰期做,大事务会在这份状态里留下很明显的痕迹。
6.2 大事务的危害和优化方向
大事务没有统一的定义,通常指的是“执行时间长”或“涉及数据行数多”的事务。大事务的危害主要体现在三方面:
- 持有的数据库连接时间更长,连接池很快被占满。
- 持有的行锁或间隙锁范围更大,其他事务被阻塞,系统整体吞吐下降。
undo log会累积大量数据,可能撑爆回滚段的存储空间,也拖慢后续的版本链读取。
优化大事务,我的常规思路是:先找出事务代码里哪些操作不是必须放在事务里的。比如一进入方法就调用远程RPC接口,这个接口可能需要几百毫秒,但你只是拿到一个结果之后再操作数据库,那么这段远程调用完全应该放在事务外面。把不相关的耗时操作挪出事务,可以大幅缩短事务持有连接和锁的时间。
其次考虑分批提交。一次批量插入十万条数据,全部放在一个事务里,回滚和提交都极其笨重。如果业务允许,可以拆成每1000条一个事务,失败时只需重试失败批次。
最后是索引。事务里更新的字段如果没有走索引,会导致范围锁升级,加大并发冲突概率。检查一下事务SQL的执行计划,确保关键查询用上了合适的索引。
6.3 嵌套事务的性能陷阱
嵌套事务除了有我们前面说的回滚语义问题,还有显而易见的性能问题:事务嵌套层级越深,持有连接的时间越长,事务管理器需要处理的同步点越多,稍不留神连接就被线程池里的几个大事务耗尽。
我写代码时的一个硬性约定:所有事务方法必须是扁平的。也就是说,一个事务方法内部只做数据操作,不做业务编排,不调用另一个同样需要事务的方法。业务编排放在一个非事务的外壳方法里,由它来协调多个顶层事务方法。这样虽然牺牲了一点代码上的模块化,但换来的是事务边界极其清晰,排查问题时一眼就能看清。
如果确实需要在一个事务方法里调用另一个事务方法,必须先想清楚它们之间的失败关联关系,确认好传播级别再动手。不要所有方法都放着默认的REQUIRED,等到线上出了问题才来排查“为什么整个链路回滚了”。
7. 单库事务搞定了,分布式事务怎么办
分布式事务是事务话题里最让人头疼的部分,也是面试官最喜欢问“刁难题”的地方。它的核心难点在于:本地事务只能管理单个数据库连接,但微服务架构下订单、库存、积分、物流可能分散在不同的库、不同的服务,跨服务的数据一致性没法靠单库事务解决。
7.1 分布式事务为什么这么难
一个下单操作通常经历以下链路:
- 订单服务写入订单表。
- 库存服务扣减库存表。
- 积分服务增加用户积分。
这三个操作分布在三个独立数据库里,每个库都有自己的事务,不可能用同一份BEGIN和COMMIT去控制全局。这时就出现了两种流派的思想:一种是要强一致,希望全局事务要么全部成功要么全部失败;另一种是追求最终一致,允许中间状态,但一段时间后数据能自动对齐。强一致方案通常性能和可用性受损,最终一致方案更贴合高并发业务。
7.2 常见分布式事务方案横向对比
我把实际项目中能落地的方案梳理了一遍,列在下面:
| 方案 | 一致性类型 | 侵入性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 高,需要数据库支持XA协议 | 差 | 对一致性要求极高的内部系统 |
| TCC(Try-Confirm-Cancel) | 最终一致 | 高,每个业务都要实现三个方法 | 中 | 资金类、账务类核心链路 |
| 本地消息表 + MQ | 最终一致 | 中 | 好 | 大多数互联网业务,比如订单和库存 |
| Saga | 最终一致 | 中 | 中 | 长流程、多步骤业务编排 |
| Seata AT模式 | 最终一致 | 低 | 中上 | 追求自动化补偿和低侵入 |
2PC的问题在于它的“两阶段提交”过程需要事务协调器,协调器一旦出问题,参与者就会一直卡在待提交状态,可用性极差。现在业务系统里直接使用JTA/XA的已经很少了。
TCC需要你为每个服务写Try、Confirm、Cancel三套逻辑,代码量成倍增加。但资金类业务里,这种手动补偿反而最可靠,因为它把业务判定权完全放在业务团队手里。
Seata是我个人比较推荐的中间件方案。AT模式的工作原理可以这样理解:它会在业务SQL执行前后自动生成undo_log,记录数据镜像,然后通过全局事务协调器控制分支事务,异常时自动反向补偿。侵入性低,业务代码改动少,适合追求效率的团队。
7.3 订单和库存这个经典场景到底怎么落地
以“订单与库存”为例,我给出一个生产中落地过的方案。核心思路是本地消息表加MQ做最终一致,再加一个定时对账兜底。
步骤拆开是这样的:
- 订单服务在本地事务里完成两件事:插入订单记录,同时插入一条“扣减库存消息”到本地消息表。这两个操作在同一个本地事务里,保证订单一定存在对应消息。
- 后台任务扫描消息表,把消息发送到MQ,消费者是库存服务。
- 库存服务收到消息后扣减库存,扣减成功发一条结果通知。
- 如果消息发送失败,定时任务会重复扫描重新投递。如果库存扣减失败,订单服务会通过回调或定时任务感知到,然后给订单置为失败状态并回滚优惠券等资源。
这个方案最核心的锦囊就是“本地消息表和业务操作共用一个本地事务”,它把分布式事务问题降级成了消息可靠投递问题。虽然做不到强一致,但订单从创建到库存扣减成功,间隔通常只有几十毫秒,用户体验几乎无感,而且还不需要引入TCC那样多的代码量。
需要特别注意的是,这个方案依赖消费者具备幂等性。同样的消息可能被投递多次,消费端必须通过业务唯一键或者去重表来保底。
最后说两句落地心得
项目里真正频繁出问题的地方,其实集中在事务失效和大事务两点上。写代码的时候多想一想当前的调用链是不是能形成代理,多想一想当前这个事务方法会不会变成一个持有几千条记录锁的怪物。Spring事务用好了,它就是保证数据一致的利器;用不好,它就是数据脏乱差的温床。
我个人在实际项目里最常用的一套组合拳是:业务方法一律@Transactional(rollbackFor = Exception.class),事务方法保持扁平化,复杂跨库链路优先本地消息表加MQ,绝不把外部RPC调用放在事务里面。这套东西看着简单,但把它坚持下来,线上关于数据一致性的告警会减少九成以上。