Mall电商实战:分布式事务把我坑惨了!下单扣库存老不一致,三步搞定Seata+可靠消息
description: 基于mall-Pro电商项目,手撕分布式事务的痛点、排查思路和最终落地方案,面试高频考点+实战避坑指南
Mall电商实战:分布式事务把我坑惨了!下单扣库存老不一致,三步搞定Seata+可靠消息
一、背景:你以为的"下单",其实是"分布式"的噩梦
先交代下背景。我在公司的电商项目用的是mall-Pro这套架构,微服务拆分得很细:订单服务、库存服务、积分服务、支付服务…… 每个服务独立一套数据库。听起来很美对吧?直到我第一次处理下单流程,直接被线上脏数据教做人。
事情是这样的——用户下单时,系统要做三件事:
- 订单服务创建订单(写订单库)
- 扣减库存(写库存库)
- 增加用户积分(写积分库)
看上去很简单的三步,跨了三个微服务、三个数据库。
刚开始我图省事,每个服务调接口失败了就抛异常、打日志,心想"失败了就回滚呗"。结果上线第一天就出事:订单创建成功,库存扣了,但积分接口超时——数据不一致了。用户下单成功却没拿到积分,客服那边炸了。
这就是典型的分布式事务问题。单机数据库里一个@Transactional就能搞定的事,在微服务架构下变成了一颗定时炸弹。今天就把我踩过的坑、用过的方案、面试被问烂的套路,一次性给你讲透。
二、核心问题:为什么分布式事务这么难搞?
咱们先捋清楚,这玩意儿到底难在哪。
单机事务靠的是ACID——原子性、一致性、隔离性、持久性。数据库通过undo log(回滚日志)和redo log(重做日志)保证要么全做要么全不做。但拆成微服务后,每个服务有自己的数据库,事务边界跨了进程和网络,就不能用一套 undo log 了。
核心矛盾就一句话:无法用单个数据库的本地事务控制多个服务的写操作。
我这边遇到的三种典型情况:
| 场景 | 问题表现 |
|---|---|
| 订单创建成功,库存扣减超时 | 用户能下单但实际没库存,超卖! |
| 库存扣减成功,订单回滚 | 库存扣了但没生成订单,少卖! |
| 积分接口网络抖动 | 订单库存都正常,积分丢了,用户体验差! |
有个面试官问过我一句特经典的话:“如果A服务调B服务,B成功了但A宕机了,怎么办?”我当时答不上来,后来自己踩了坑才明白——分布式事务的本质不是在"做"的时候保证一致,而是在"出问题"的时候能兜底。
三、排查过程:一步步被现实教育
3.1 第一阶段:天真的 try-catch 回滚
第一版代码大概长这样:
@TransactionalpublicvoidcreateOrder(OrderDTOdto){// 1. 创建订单orderService.save(order);try{// 2. 扣库存 —— 远程调用库存服务stockClient.deduct(dto.getSkuId(),dto.getCount());}catch(Exceptione){// 扣库存失败,手动抛异常触发订单回滚thrownewRuntimeException("扣库存失败",e);}try{// 3. 加积分pointClient.add(dto.getUserId(),dto.getAmount());}catch(Exceptione){log.error("加积分失败",e);// 这里不敢抛异常了,怕影响主流程。。。}}结果:炸了。
问题出在哪?
- 库存扣减成功但返回超时 → 订单回滚了,库存扣了,库存数据丢了
- 积分接口失败我吞了异常 → 用户永远拿不到这单积分
@Transactional只能管订单自己的数据库,管不了远程调用- 还有一个更隐蔽的坑:库存服务调成功了,订单服务自己后续逻辑抛异常 → 订单回滚,库存已扣
3.2 第二阶段:补偿接口的"伪解决"
发现问题后我上了补偿机制——扣库存失败时调库存服务的"归还库存"接口,加积分失败时调积分服务的"扣回积分"接口。大概这样:
try{stockClient.deduct(dto.getSkuId(),dto.getCount());}catch(Exceptione){stockClient.compensate(dto.getSkuId(),dto.getCount());// 补偿归还thrownewRuntimeException("下单失败");}看着合理吧?但依然有坑:
- 补偿接口自己也可能失败—— 如果补偿调用也超时了呢?
- 补偿和原操作之间没有隔离性—— 比如库存扣了5个,补偿调用期间其他请求看到库存是少的,可能触发补货逻辑
- 幂等性问题—— 用户重试下单,库存多扣了,补偿多还了
这就是为什么TCC(Try-Confirm-Cancel)模式被提出来了——每个操作都得预留资源(Try),然后统一确认(Confirm)或取消(Cancel)。但 TCC 实现成本太高,每个接口都得写三套逻辑,我们小团队搞不动。
四、最终方案:Seata AT + 可靠消息最终一致性
被现实教育了两轮之后,最后我上了组合拳:
核心思路
强一致性用 Seata AT 模式,最终一致性用 RocketMQ 可靠消息。
具体分工:
| 场景 | 方案 | 理由 |
|---|---|---|
| 订单+库存 | Seata AT 分布式事务 | 核心链路,必须强一致 |
| 积分/日志 | RocketMQ 可靠消息 | 非核心链路,允许异步,最终一致就行 |
关键原则:不要所有操作都塞进一个分布式事务,能异步的就异步,核心链路才用强一致。
Seata AT 原理一句话
Seata AT 的原理其实不复杂:
- TM(事务管理器)告诉 TC(事务协调器):我要开始一个全局事务了
- RM(资源管理器)执行本地 SQL,同时记录undo log(执行前后的数据快照)
- 所有 RM 执行完了,TM 问 TC:提交还是回滚?
- 如果全部成功 → TC 通知各 RM 删除 undo log(正式提交)
- 如果有失败的 → TC 通知各 RM 用 undo log 回滚数据
关键:第二阶段回滚时,Seata 是通过逆向 SQL 来恢复数据的,不需要你写补偿代码。
整合步骤
Step 1:引入依赖
<!-- Seata --><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-seata</artifactId><version>2021.0.5.0</version></dependency><!-- RocketMQ --><dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId><version>2.2.3</version></dependency>Step 2:Seata 配置
每个参与分布式事务的微服务都要配置seata.properties:
# 事务分组名称,要和 TC Server 对应 seata.tx-service-group=my-mall-tx-group seata.service.vgroup-mapping.my-mall-tx-group=default # 数据代理 —— 这个很重要,Seata 需要通过代理数据源来记录 undo log seata.enable-auto-data-source-proxy=true另外每张业务表都要加一个字段(其实加在业务表对应的 undo_log 表里,Seata 自动维护):
-- 每个业务库都要建这张表CREATETABLE`undo_log`(`id`bigint(20)NOTNULLAUTO_INCREMENT,`branch_id`bigint(20)NOTNULL,`xid`varchar(100)NOTNULL,`context`varchar(128)NOTNULL,`rollback_info`longblobNOTNULL,`log_status`int(11)NOTNULL,`log_created`datetimeNOTNULL,`log_modified`datetimeNOTNULL,PRIMARYKEY(`id`),KEY`idx_union`(`xid`,`branch_id`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;踩坑注意:数据源必须使用 Seata 代理过的,否则 undo log 不会生效。如果你项目里自定义了数据源配置,一定要加上@SeataDataSource或者用DataSourceProxy包装一层。
Step 3:核心代码实现
@Service@Slf4jpublicclassOrderServiceImplimplementsOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateStockFeignClientstockClient;@AutowiredprivateRocketMQTemplaterocketMQTemplate;/** * 核心下单方法 * 订单+库存强一致,积分异步最终一致 */@Override@GlobalTransactional(name="create-order",rollbackFor=Exception.class)publicOrderVOcreateOrder(OrderDTOdto){// 1. 创建订单(本地事务)Orderorder=Order.builder().orderSn(generateOrderSn()).userId(dto.getUserId()).totalAmount(dto.getTotalAmount()).status(OrderStatus.UNPAID.getCode()).createTime(newDate()).build();orderMapper.insert(order);// 2. 扣减库存 —— Seata 会代理这个远程事务// 注意:stockClient.deduct() 内部也需要 @TransactionalStockResultresult=stockClient.deduct(dto.getSkuId(),dto.getCount());if(!result.isSuccess()){thrownewBusinessException("库存不足");}// 3. 发送积分消息 —— 异步,使用 RocketMQ 事务消息保证可靠性PointMessagepointMsg=newPointMessage(dto.getUserId(),dto.getTotalAmount().intValue()/10,order.getOrderSn());// 这里用普通消息就行,因为积分是可以容忍短暂不一致的rocketMQTemplate.convertAndSend("point-add-topic",pointMsg);returnOrderVO.from(order);}}库存服务的接口(也要参与全局事务):
@ServicepublicclassStockServiceImplimplementsStockService{@AutowiredprivateStockMapperstockMapper;@Override@Transactional(rollbackFor=Exception.class)publicStockResultdeduct(LongskuId,Integercount){// 乐观锁扣减库存intaffected=stockMapper.deductStock(skuId,count);if(affected==0){returnStockResult.fail("库存不足");}returnStockResult.success();}}Step 4:RocketMQ 可靠消息配置(防止消息丢失)
积分虽然可以异步,但不能丢。RocketMQ 的事务消息刚好解决这个问题:
@Component@RocketMQTransactionListener@Slf4jpublicclassPointTransactionListenerimplementsRocketMQLocalTransactionListener{@AutowiredprivatePointRecordMapperpointRecordMapper;/** * 执行本地事务 —— 消息发送成功后会回调这里 */@Override@Transactional(rollbackFor=Exception.class)publicRocketMQLocalTransactionStateexecuteLocalTransaction(Messagemsg,Objectarg){try{// 解析消息PointMessagepointMsg=(PointMessage)arg;// 插入积分记录(本地事务),同时作为"半消息是否可提交"的判断依据PointRecordrecord=PointRecord.builder().userId(pointMsg.getUserId()).points(pointMsg.getPoints()).orderSn(pointMsg.getOrderSn()).status(0)// 未发放.build();pointRecordMapper.insert(record);returnRocketMQLocalTransactionState.COMMIT;}catch(Exceptione){log.error("积分本地事务执行失败",e);returnRocketMQLocalTransactionState.ROLLBACK;}}/** * 回查 —— 如果长时间没收到 commit/rollback,RocketMQ 会来回查 */@OverridepublicRocketMQLocalTransactionStatecheckLocalTransaction(Messagemsg){PointMessagepointMsg=(PointMessage)msg.getPayload();// 查数据库看积分记录是否已经落库PointRecordrecord=pointRecordMapper.selectByOrderSn(pointMsg.getOrderSn());if(record!=null){returnRocketMQLocalTransactionState.COMMIT;}returnRocketMQLocalTransactionState.UNKNOWN;}}五、避坑经验总结(面试官就爱问这些)
5.1 Seata AT 性能问题怎么处理?
Seata AT 在第一阶段会获取全局锁,高并发场景下会有锁竞争。如果单表 TPS 超过 2000,建议考虑两种优化:
- 把热点商品的库存操作从 Seata 管理中去掉,改用库存流水表+异步对账
- 或者用Seata TCC 模式,预留库存代替真实扣减,性能会好很多
5.2 消息丢失/重复消费怎么搞?
- 生产者端:使用 RocketMQ 事务消息或同步发送 + 回调确认,不要用 oneway
- 消费者端:业务幂等去重,每条消息带上唯一业务键(比如 orderSn),消费前查表去重
- 兜底:定时任务扫描异常记录,手动补偿
5.3 Seata 回滚失败怎么办?
undo log 虽然能回滚,但如果回滚时数据已经被其他事务修改了(脏写),Seata 默认会抛异常并人工介入。解决方案:
- 业务设计上尽量避免并发操作同一条数据(比如库存预扣)
- 开启 Seata 的全局锁机制(默认开启),防止脏写
5.4 到底什么时候用 Seata,什么时候用消息?
这个问题面试几乎必问。我自己的经验是:
核心链路 + 高一致性要求 → Seata AT 核心链路 + 极高并发 → Seata TCC 非核心 + 可异步 → RocketMQ 事务消息 非核心 + 容忍丢失 → 普通 MQ + 定时对账说白了就是没有银弹。Seata 能解决一致性问题但引入性能开销,消息队列能异步解耦但有延迟。好的架构不是选最牛的方案,而是给每个场景选最合适的方案。
5.5 监控告警不能忘
分布式事务最怕"静默失败"——某个环节挂了没人知道。建议加以下监控:
// 自定义注解 + AOP,监控 Seata 全局事务执行情况@Around("@annotation(GlobalTransactional)")publicObjectmonitorGlobalTx(ProceedingJoinPointpjp){longstart=System.currentTimeMillis();Stringxid=RootContext.getXID();try{Objectresult=pjp.proceed();// 上报成功 metricsreportMetrics(xid,true,System.currentTimeMillis()-start);returnresult;}catch(Exceptione){// 上报失败 metrics + 钉钉告警reportMetrics(xid,false,System.currentTimeMillis()-start);dingTalkAlarm.send("分布式事务失败,XID: "+xid);throwe;}}六、写在最后
分布式事务这个东西,理论上看多少遍都不如线上踩一次坑来得深刻。我花了一周时间,从 try-catch 到补偿机制再到 Seata+RocketMQ,最大的感悟就是:
别想着用一个方案干掉所有问题,分而治之才是微服务的精髓。
强一致性保核心,最终一致性兜非核心,消息可靠性靠机制不靠运气。面试官问你分布式事务,你把这套组合拳讲清楚,再聊聊踩过的坑和优化思路,基本就稳了。
如果你也在搞 mall 系电商项目,欢迎评论区聊聊你遇到过的分布式事务神坑,一起涨经验!
如果觉得文章有帮助,点赞收藏关注走一波,后续继续更新 mall-Pro 实战系列:服务熔断、配置中心、链路追踪……
