
做微服务做到一定规模迟早会撞上分布式事务这堵墙。我自己在负责订单与库存拆分的项目时就踩过一整轮的坑单体时代一个Transactional搞定的事拆成订单服务、库存服务、支付服务之后跨服务的数据一致性突然变得无比棘手——订单建了、库存没扣成功超卖库存扣了、订单失败库存被白白占用。这不是理论问题是线上直接报故障、对账发现问题、用户投诉的大问题。这篇内容围绕“微服务集成分布式事务”展开核心讲清楚三件事第一分布式事务到底解决什么问题本质是什么第二主流方案XA、TCC、Saga、本地消息表怎么选型尤其结合Seata落地要怎么做第三实际项目中常踩的坑和排查经验。内容适合有Spring Cloud微服务基础、正在面临跨服务数据一致性问题的后端开发者也适合准备在项目中引入分布式事务组件但还没想清楚方案的团队参考。下面全部是基于我实际项目操作的经验总结。1. 微服务拆了以后事务问题到底出在哪1.1 从订单和库存说起一个典型场景先看最经典的“下单扣库存”。单体时代一个订单方法里先插入订单表、再更新库存表两个操作放在同一个数据库本地事务里。只要任何一个SQL失败整体回滚一致性由数据库保证开发者基本不用操心。微服务拆分之后订单表和库存表分别归属订单服务、库存服务各自用自己的数据库。此时“下单扣库存”就变成了两次跨服务的RPC调用订单服务插入订单记录本地事务A → 远程调用库存服务扣减库存本地事务B问题在于事务A和事务B是各自独立的。如果订单插入成功、远程扣库存失败订单服务可能会捕获异常并尝试回滚本地事务A——但这时调用链已经走了一半库存服务那边的事务可能已经提交或部分提交了反过来如果库存扣减成功、订单服务在后续环节返回失败库存已经被扣了。无论哪种情况两个服务的数据都不一致。这个场景的关键点在于栗子虽老但真实项目里几乎每天都在发生。我参与的项目里除了订单和库存还有账户余额扣减、优惠券核销、积分发放、物流单创建这些数据分属不同微服务都存在同类问题。只要服务拆分粒度变大跨服务写操作就逃不开一致性保障。1.2 分布式事务的本质CAP与BASE讲分布式事务之前必须先说清楚理论基础。CAP理论是绕不开的在网络分区P不可避免的前提下系统只能在一致性C和可用性A之间做取舍。分布式事务本质上就是在协调多个数据源之间的状态让它们在“某个可见范围内”保持一致。实际操作中绝大多数业务系统不会选择严格的强一致而是接受BASE理论基本可用Basically Available、软状态Soft State、最终一致Eventually Consistent。也就是说数据允许在短时间内不一致但经过一段时间的补偿或重试后最终会达到一致。理解这个之后你再看各种分布式事务方案就清楚多了有的方案追求强一致比如XA有的方案追求最终一致比如TCC、Saga、本地消息表。没有绝对的好坏只有适不适合当前业务。很多团队一上来就问“哪个方案最厉害”其实更该问的是“我这个业务允许多长时间的数据不一致”。这是方案选型的第一个分岔口。2. 主流方案对比XA、TCC、Saga、本地消息表怎么选2.1 四种方案的核心思路先简单梳理一下当前主流的四种方案每种都有典型代表。XA协议两阶段提交数据库层面的分布式事务。事务管理器TM先向所有资源管理器RM发送准备指令各RM本地执行并锁住资源全部准备成功后统一提交否则统一回滚。代表实现是各种数据库自带的XA支持以及Atomikos等组件。强一致但锁时间太长高并发下吞吐量掉得厉害。TCCTry-Confirm-Cancel业务层面的两阶段。第一阶段Try做资源预留比如冻结库存第二阶段Confirm做确认扣减如果某个参与方失败则执行Cancel释放冻结库存。优点是不依赖数据库锁性能较好但需要为每个操作写三套逻辑开发量大业务侵入性强。Saga长事务补偿把一个长事务拆分成一系列本地事务每个本地事务都有对应的补偿操作。比如下单成功后如果后续步骤失败则执行取消订单的补偿。适合业务流程长、参与服务多的场景。缺点是没有隔离性需要业务上容忍中间态。本地消息表异步确保核心思路是“业务操作和消息记录写在同一个本地事务里”然后通过定时任务扫描消息表把消息发送出去下游消费成功后确认。这是最容易理解、成本最低的方案最终一致性靠重试和幂等来保证。2.2 选型对照别只盯着“强一致”很多团队选型的时候容易犯一个错误只从技术层面看“哪个最一致”。实际上要在一致性、性能、开发成本、运维复杂度之间找平衡点。我给一个对比表方便你直接参考方案一致性业务侵入度性能影响实现/运维成本典型场景XA两阶段强一致低改了数据源即可高锁范围大中银行转账、低频强一致场景TCC准实时一致高三套逻辑中高资金类、需要预留资源的场景Saga最终一致中需写补偿低中高长流程业务、跨多服务本地消息表最终一致中需幂等低低异步化业务、可容忍延迟单看数据说不出结论关键是看业务要求。我见过一个做支付的团队账务部分必须用TCC做准实时一致但同一套系统里发送通知短信就用本地消息表。强一致和最终一致在一个系统里共存这才是常态。2.3 我最终选择的组合Seata AT 本地消息表讲完方案对比说我自己的实践结论。我在实际项目中最终选择了Seata的AT模式作为主力再搭配本地消息表处理异步化场景。为什么选Seata AT模式而不是其他核心原因在于它对业务代码的侵入非常低。AT模式本质上是在XA的基础上做了优化通过数据源代理拦截SQL自动生成undo_log来实现回滚开发者只需要在业务方法上加一个GlobalTransactional注解。相比TCC那种为每个接口写三套逻辑的工作量AT模式的学习成本和落地速度是最友好的。我所在的团队两周内就完成了核心链路的接入。同时我也保留了本地消息表来处理那些不需要实时一致的场景比如下单成功后的短信通知、积分累计。原因是这些操作如果强行塞进全局事务里会因为跨服务RPC耗时拖长整个事务反而降低核心链路的可用性。这个问题会在第4节详细展开。3. 基于Seata的落地实操3.1 环境准备Server端部署配置Seata分为TC事务协调器、TM事务管理器、RM资源管理器三个角色。TC是独立部署的Server端需要先把它跑起来。我用的版本是Seata 1.5.2对应的Spring Cloud Alibaba版本是2.2.x这个版本组合在兼容性上已经比较稳定。部署TC需要准备三件事拉取Seata Server安装包修改conf/application.yml配置存储方式和注册中心然后启动。我的项目里注册中心和配置中心用的是Nacos存储方式用MySQL不推荐file模式做生产环境因为TC重启后事务记录会丢失无法恢复。核心配置如下简化版# conf/application.yml server: port: 7091 seata: registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP username: nacos password: nacos store: mode: db db: datasource: druid db-type: mysql driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata_server?useUnicodetruecharacterEncodingutf8 user: root password: your_password注意Seata Server的db存储模式需要先初始化global_table、branch_table、lock_table三张表官方脚本在script/server/db/目录下线上环境建议先执行一遍。3.2 Spring Cloud服务端集成配置服务端集成Seata的客户端步骤比较固定引入依赖、配置数据源代理、加注解。先看Maven依赖Spring Cloud Alibaba方式dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version2.2.5.RELEASE/version exclusions exclusion groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId /exclusion /exclusions /dependency !-- 手动指定Seata版本避免依赖冲突 -- dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.5.2/version /dependency然后是配置文件每个服务都要声明自己的事务分组。这里是最容易出错的地方事务分组名必须和服务名关联清晰否则TM找不到TCspring: cloud: alibaba: seata: tx-service-group: order-service-group # 事务分组名 seata: registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP username: nacos password: nacos tx-service-group: order-service-group service: vgroup-mapping: order-service-group: default # 映射到TC集群名与Server端一致 >Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource druidDataSource() { return new DruidDataSource(); } Bean(dataSource) Primary public DataSource dataSource(DataSource druidDataSource) { return new DataSourceProxy(druidDataSource); } }配置完之后先在订单服务里建一张undo_log表这是AT模式分支事务记录回滚日志的表。官方建表SQL在script/client/目录下表名和字段都不能改。3.3 业务代码改造要点环境配好之后改造业务代码本身不复杂核心就一个注解。发起全局事务的服务方TM在业务方法上加GlobalTransactionalService public class OrderService { Resource private OrderMapper orderMapper; Resource private InventoryClient inventoryClient; GlobalTransactional(name create-order, rollbackFor Exception.class) public void createOrder(OrderCreateRequest request) { // 1. 本地事务插入订单记录状态为待扣库存 orderMapper.insert(Order.builder() .orderNo(request.getOrderNo()) .productId(request.getProductId()) .amount(request.getAmount()) .status(CREATED) .build()); // 2. 远程调用库存服务扣减库存 inventoryClient.deductStock(request.getProductId(), request.getQuantity()); // 3. 如果有后续更新操作继续在本地事务内完成 } }库存服务侧不需要加全局事务注解它只需要作为一个RM参与分支事务即可。分支事务里正常的Transactional就能生效因为数据源已经被Seata代理了。这里有一个很关键的实践心得GlobalTransactional应该加在微服务调用链的最前端入口而不是每个服务都加。全局事务只应该有一个发起者如果每个服务都加全局事务注解会出现事务嵌套逻辑变得非常混乱。我在项目中就见过有人把注解加在库存服务上结果订单服务调用它时库存服务自己也开了一个全局事务两边都尝试协调最后回滚时状态错乱。3.4 参数调优与性能注意事项AT模式不是没有代价的它通过全局锁来保证并发安全。在高并发场景下如果全局事务运行时间过长锁等待就会成为一个瓶颈。我给出几个实操中需要重点关注的参数配置项推荐值说明client.rm.lock.retry-interval10ms获取全局锁失败后的重试间隔client.rm.lock.retry-times30次重试次数30次约3秒超时则分支回滚client.tm.commit-retry-count5提交失败后的重试次数client.tm.rollback-retry-count5回滚失败后的重试次数client.rm.report-retry-count5分支事务状态上报失败重试次数这些参数都在application.yml的seata.client下配置。我的经验是锁重试次数不要设得过大否则一个事务卡住了会阻塞大量其他事务造成雪崩。笔者的一个真实案例是某个链路的全局事务里调用了外部第三方接口第三方接口响应超时3秒导致全局锁被持有3秒以上库存服务的其他下单请求全部在排队最终QPS直接掉到平时的十分之一。后来优化了一下把第三方接口调用移出全局事务问题立刻缓解。所以AT模式下的铁律是全局事务范围要尽可能小事务内的RPC要尽可能快绝对不要在里面调用响应慢的第三方接口。4. 本地消息表方案不引入额外中间件的折中4.1 核心流程与实现要点有些业务场景根本不需要实时一致性比如下单成功以后发短信、累计积分、生成统计分析数据。把这些操作塞进全局事务等于用“高成本的手段解决低成本的问题”。这时更适合用本地消息表。核心流程不复杂业务方法本地事务 1. 插入业务数据如订单记录 2. 同时插入一条消息记录status待发送 → 事务提交 定时任务 扫描消息表中status待发送且创建时间早于当前时间-1分钟的记录 逐条发送到MQ或直接调用下游接口 收到成功ACK后更新status已发送 失败则记录重试次数达到上限后标记为死信并告警与主流方案对比本地消息表最突出的优势是它不需要额外部署事务协调器只要业务数据库本身没问题消息就不会丢。因为“业务操作”和“消息写入”在同一个本地事务里要么一起成功要么一起失败不存在业务成功了但消息没记上的情况。下游消费端只需要保证接口幂等重复投递不会产生重复数据。4.2 幂等设计的落地细节本地消息表方案的成败一半要看幂等设计做得好不好。因为定时任务发送消息后如果ACK丢失或者网络超时消息会被重复发送。下游接口必须能识别“这条消息我处理过了”。一个比较通用的做法是建立消费记录表用唯一键约束来防重。比如积分服务接收到“给用户加100分”的消息处理前先往points_record表插入一条记录消息ID作为唯一索引如果insert时出现主键冲突说明已处理过直接返回成功。Service public class PointsConsumer { Resource private PointsRecordMapper pointsRecordMapper; public void consume(Message message) { // 用idempotent_key做唯一索引保证消息只处理一次 try { pointsRecordMapper.insert(PointsRecord.builder() .messageId(message.getId()) .userId(message.getUserId()) .points(message.getPoints()) .build()); } catch (DuplicateKeyException e) { // 已处理过直接确认 return; } // 继续执行真正的业务逻辑 } }这个方案比单纯查询判断要可靠得多因为在并发场景下“先查询再判断”会出现竞态条件唯一索引从数据库层面杜绝了重复插入。4.3 与Seata的正确搭配按需分流我项目中的最终实践是这样的强一致的核心链路订单创建、库存扣减、支付回调使用Seata AT模式保证用户能实时看到正确的结果非核心的异步链路短信通知、积分累计、日志统计使用本地消息表允许延迟几秒甚至几分钟。这样划分的理由是基于业务容忍度的分层。如果用户下单后库存没扣住那属于严重的资损问题必须强一致如果用户下单后短信晚到一分钟体验上完全无感。另外一个重要建议是不要把本地消息表的发送动作放到Seata全局事务里面去执行。我测试过如果你在GlobalTransactional方法里调用MQ发送这一发消息的时间会被计入全局事务的执行时间一旦MQ秒级抖动整个全局锁的占用时间就会被拉长。正确做法是全局事务只提交业务数据提交完成后由Spring的事件机制或定时任务来触发消息发送。5. 常见问题与排查技巧实录5.1 高频故障速查表实际操作中Seata相关的问题集中在下面几类我整理成了速查表现象可能原因排查思路全局事务不回滚数据不一致入口方法没加GlobalTransactional查看调用链入口补注解分支事务报Could not found global transaction服务没正确注册到TC或事务分组映射错误重点检查vgroup-mapping配置查看TC日志报undo_log相关错误业务库缺undo_log表建表SQL执行一遍注意字符集和字段全局锁等待超时事务内RPC耗时过长或并发冲突激烈用Seata的lock_table查看当前持有锁的事务回滚时报BufferPool异常并发回滚时undo_log读取冲突调大客户端线程池参数数据源被重复代理配置了多个DataSource而没有排除检查数据源配置只代理一个这些错误在日志中的典型关键字包括GlobalTx、BranchTx、GlobalLockConflictException、UndoLogException。排查的第一步永远是打开TC Server的日志因为全局事务的状态变更全部由TC记录分支服务只上报状态。很多团队只盯着业务服务日志查半天查不出来一翻TC日志立刻定位问题。5.2 分布式事务的调试技巧调试分布式事务比调试普通业务逻辑要难得多因为涉及多个服务和多个数据源。我分享几个实际项目中管用的方法第一在事务入口的日志里输出全局事务IDRootContext.getXID()并手动塞进MDC在后续的分支事务日志中打上同一个标识。这样按事务ID串联日志整个调用链就一目了然。GlobalTransactional public void createOrder(...) { String xid RootContext.getXID(); MDC.put(xid, xid); try { // 业务逻辑 } finally { MDC.remove(xid); } }第二回滚验证时不要只靠眼睛检查数据库。我习惯用“模拟中间态”的方式故意让库存服务扣减SQL报错观察订单记录是否被回滚再故意让下游积分服务连接超时观察本地消息表重试机制是否生效。这种主动的故障注入测试比写一堆理论检查要管用得多。第三排查全局锁等待问题时直接查数据库的lock_tableSELECT * FROM lock_table WHERE row_key 库存表主键 AND locked 1;如果大量行处于锁状态说明当前有长事务占用全局锁。用SQL查一下关联的global_table定位是哪个事务、运行了多久然后决定是杀事务还是优化业务逻辑。5.3 那些年我踩过的坑独家避坑建议最后分享几条我用真金白银换来的经验。第一RPC超时时间必须大于全局锁重试时间。Seata官方的AT模式中RM获取全局锁的重试总时间大概在3秒左右见3.4节的配置。如果Feign的调用超时设置只有2秒第一个分支事务还没完成注册就被超时断开了全局事务回滚时就会报“分支事务未注册”的错误。正确做法是Feign的readTimeout要大于全局锁重试总时长我给项目里设成了5秒。第二消息表方案里定时任务的扫描频率不要盲目调高。我见过把扫描频率改成1秒钟一次的数据库压力直接翻倍但业务收益几乎为零。本地消息表天然允许分钟级延迟每10秒扫一次配合MQ的实时投递已经能cover绝大多数场景。第三不要轻易尝试在Seata AT模式下把隔离级别调成“读已提交”。AT模式的默认隔离级别是“读未提交”因为全局事务未提交前其他事务通过普通SELECT可能读到分支事务的中间数据。如果业务非常敏感需要加SELECT ... FOR UPDATE来手动加锁而不是改隔离级别改全局隔离级别会影响所有分支事务的性能。第四本地消息表的status字段一定要设计好查询索引。这个表是高频读写表随着时间推移会越来越大如果没有索引扫描待发送消息的SQL会越来越慢最终拖垮业务库。我一般会建一个(status, create_time)联合索引同时每天定时清理已处理且超过3天的消息数据。写在最后的一个实操心得分布式事务这块做技术选型的时候真的没必要追求“最完美”的方案。我在项目里最深的体会是没有银弹只有适合场景的拼图。Seata AT负责强一致核心链路本地消息表兜底异步场景TCC和Saga留在真正需要业务补偿的时候再用——这才是微服务环境下处理数据一致性的成熟姿态。最后分享一个小技巧把全局事务ID和消息ID统一纳入日志系统每次排查数据不一致的问题第一步先按ID拉取全链路日志。这个习惯帮我省掉了至少一半的时间强烈建议你也试试。