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个补偿调用期间其他请求看到库存是少的可能触发补货逻辑幂等性问题—— 用户重试下单库存多扣了补偿多还了这就是为什么TCCTry-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 --dependencygroupIdcom.alibaba.cloud/groupIdartifactIdspring-cloud-starter-alibaba-seata/artifactIdversion2021.0.5.0/version/dependency!-- RocketMQ --dependencygroupIdorg.apache.rocketmq/groupIdartifactIdrocketmq-spring-boot-starter/artifactIdversion2.2.3/version/dependencyStep 2Seata 配置每个参与分布式事务的微服务都要配置seata.properties# 事务分组名称要和 TC Server 对应 seata.tx-service-groupmy-mall-tx-group seata.service.vgroup-mapping.my-mall-tx-groupdefault # 数据代理 —— 这个很重要Seata 需要通过代理数据源来记录 undo log seata.enable-auto-data-source-proxytrue另外每张业务表都要加一个字段其实加在业务表对应的 undo_log 表里Seata 自动维护-- 每个业务库都要建这张表CREATETABLEundo_log(idbigint(20)NOTNULLAUTO_INCREMENT,branch_idbigint(20)NOTNULL,xidvarchar(100)NOTNULL,contextvarchar(128)NOTNULL,rollback_infolongblobNOTNULL,log_statusint(11)NOTNULL,log_createddatetimeNOTNULL,log_modifieddatetimeNOTNULL,PRIMARYKEY(id),KEYidx_union(xid,branch_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4;踩坑注意数据源必须使用 Seata 代理过的否则 undo log 不会生效。如果你项目里自定义了数据源配置一定要加上SeataDataSource或者用DataSourceProxy包装一层。Step 3核心代码实现ServiceSlf4jpublicclassOrderServiceImplimplementsOrderService{AutowiredprivateOrderMapperorderMapper;AutowiredprivateStockFeignClientstockClient;AutowiredprivateRocketMQTemplaterocketMQTemplate;/** * 核心下单方法 * 订单库存强一致积分异步最终一致 */OverrideGlobalTransactional(namecreate-order,rollbackForException.class)publicOrderVOcreateOrder(OrderDTOdto){// 1. 创建订单本地事务OrderorderOrder.builder().orderSn(generateOrderSn()).userId(dto.getUserId()).totalAmount(dto.getTotalAmount()).status(OrderStatus.UNPAID.getCode()).createTime(newDate()).build();orderMapper.insert(order);// 2. 扣减库存 —— Seata 会代理这个远程事务// 注意stockClient.deduct() 内部也需要 TransactionalStockResultresultstockClient.deduct(dto.getSkuId(),dto.getCount());if(!result.isSuccess()){thrownewBusinessException(库存不足);}// 3. 发送积分消息 —— 异步使用 RocketMQ 事务消息保证可靠性PointMessagepointMsgnewPointMessage(dto.getUserId(),dto.getTotalAmount().intValue()/10,order.getOrderSn());// 这里用普通消息就行因为积分是可以容忍短暂不一致的rocketMQTemplate.convertAndSend(point-add-topic,pointMsg);returnOrderVO.from(order);}}库存服务的接口也要参与全局事务ServicepublicclassStockServiceImplimplementsStockService{AutowiredprivateStockMapperstockMapper;OverrideTransactional(rollbackForException.class)publicStockResultdeduct(LongskuId,Integercount){// 乐观锁扣减库存intaffectedstockMapper.deductStock(skuId,count);if(affected0){returnStockResult.fail(库存不足);}returnStockResult.success();}}Step 4RocketMQ 可靠消息配置防止消息丢失积分虽然可以异步但不能丢。RocketMQ 的事务消息刚好解决这个问题ComponentRocketMQTransactionListenerSlf4jpublicclassPointTransactionListenerimplementsRocketMQLocalTransactionListener{AutowiredprivatePointRecordMapperpointRecordMapper;/** * 执行本地事务 —— 消息发送成功后会回调这里 */OverrideTransactional(rollbackForException.class)publicRocketMQLocalTransactionStateexecuteLocalTransaction(Messagemsg,Objectarg){try{// 解析消息PointMessagepointMsg(PointMessage)arg;// 插入积分记录本地事务同时作为半消息是否可提交的判断依据PointRecordrecordPointRecord.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/rollbackRocketMQ 会来回查 */OverridepublicRocketMQLocalTransactionStatecheckLocalTransaction(Messagemsg){PointMessagepointMsg(PointMessage)msg.getPayload();// 查数据库看积分记录是否已经落库PointRecordrecordpointRecordMapper.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){longstartSystem.currentTimeMillis();StringxidRootContext.getXID();try{Objectresultpjp.proceed();// 上报成功 metricsreportMetrics(xid,true,System.currentTimeMillis()-start);returnresult;}catch(Exceptione){// 上报失败 metrics 钉钉告警reportMetrics(xid,false,System.currentTimeMillis()-start);dingTalkAlarm.send(分布式事务失败XID: xid);throwe;}}六、写在最后分布式事务这个东西理论上看多少遍都不如线上踩一次坑来得深刻。我花了一周时间从 try-catch 到补偿机制再到 SeataRocketMQ最大的感悟就是别想着用一个方案干掉所有问题分而治之才是微服务的精髓。强一致性保核心最终一致性兜非核心消息可靠性靠机制不靠运气。面试官问你分布式事务你把这套组合拳讲清楚再聊聊踩过的坑和优化思路基本就稳了。如果你也在搞 mall 系电商项目欢迎评论区聊聊你遇到过的分布式事务神坑一起涨经验如果觉得文章有帮助点赞收藏关注走一波后续继续更新 mall-Pro 实战系列服务熔断、配置中心、链路追踪……