先交代一下背景。上个月某天凌晨快三点,我迷迷糊糊被手机震醒,群里丢进来一条告警:对账任务跑完出现差异,某渠道订单表状态和库存扣减记录差了三笔。那天晚上正好赶上大促后的首次结算,业务量是平时的十几倍,我第一反应是补偿机制没兜住,但查了半小时才发现,根本不是补偿的问题,是下单主流程里那次跨服务调用根本没有走任何事务方案——接口超时后直接返回失败给前端,可数据已经写进库里了。
这种事大概不少人都遇到过。微服务拆到一定粒度以后,单库事务的抓手就没了,一笔订单要同时扣库存、扣余额、记账,任何一个环节失败都会留下“半截数据”。今天这篇就把分布式事务这件事从头到尾捋一遍,结合我自己的落地经历,讲清楚各种方案的原理、适用边界、选型逻辑,以及真正踩到过的坑。适合正在拆服务、或者已经在用分布式事务但总感觉哪里不对劲的同学,看完至少能建立一张完整的地图。
1. 从一次凌晨差账告警说起:分布式事务到底在解决什么问题
1.1 单库事务为什么在拆分后就失效了
很多人刚接触分布式事务时,心里想的是“有没有一种东西,能像本地事务一样,让多个服务之间的操作要么全成功、要么全失败”。这个想法本身没错,但理解上缺一层——本地事务之所以能回滚,是因为所有数据都在同一个数据库里,事务管理器可以对同一套存储引擎下发回滚指令,锁、UNDO日志、隔离级别这些东西都有共同的载体。
拆分之后,订单数据在订单库,库存数据在库存库,余额在资金库,事务的边界从“一个数据库连接”变成了“多个网络调用”。这时候ACID里最要命的其实是I(隔离性)和C(一致性),因为没有全局的锁机制,也没有全局的可见性视图,你没法让库存服务看见订单服务未提交的数据,更没法让它们感知彼此的存在。本地事务里那一套“要么都做要么都不做”的语义,在网络环境下天然不成立。
我接手过一个模拟项目X,下单链路涉及三个节点,改造前是同步RPC,主服务里try-catch后手动调补偿接口。听着像是那么回事,但补不出来。库存扣了、余额没扣,补偿接口又因为重试被幂等挡掉,结果对账时候怎么都对不上。问题根源不是代码写得差,而是没有一个统一的协调机制去管“每个节点到底处于什么状态”。
1.2 分布式事务的正确定位:一致性不是“同时成功”
所以分布式事务真正要解决的问题,不是让多个服务像单库一样同步提交,而是在分布式环境下,保证数据最终进入一个约定的正确状态。这个状态可能是“所有节点都成功”,也可能是“所有已执行操作都被补偿掉”,关键是系统不能留下中间态、脏数据、或者无法恢复的悬空状态。
这里面有两类思路,对应两类一致性级别:
- 强一致:让多个节点的数据在同一时刻对外完全一致,常见于对账类、资金类强约束场景。代表方案是2PC/XA,代价是吞吐下降和可用性风险。
- 最终一致:允许短时间内有不一致窗口,通过重试、补偿、对账等手段让数据在较短时间内收敛到一致。代表方案是TCC、本地消息表、MQ事务消息、SAGA。
实际业务里百分之八九十的场景都应该走最终一致路线,因为互联网系统的核心诉求是可用性和用户体验,用户不需要“看到扣库存和扣金额在同一毫秒发生”,他只需要“下单后状态正确、不超卖、不会钱扣了东西没发”。把这个定位想清楚,选型才不会跑偏。
2. 四大主流方案的选型逻辑:各自的原理代价与适用边界
2.1 2PC/XA:强一致的理想模型,但别轻易上生产
两阶段提交是三段式流程:协调者先向所有参与者发送“准备”请求,每个参与者写本地UNDO日志、锁住资源后返回“就绪”;协调者收到所有就绪响应后,再广播“提交”指令。任何一个参与者返回失败或超时,协调者广播“回滚”。
这个模型在理论上是干净的,强一致、满足ACID,但工程上非常脆:
- 第一阶段锁资源的时间可能很长,事务吞吐直接崩
- 协调者是单点,挂掉之后所有参与者要么一直等,要么还是等
- 参与者从“就绪”到“提交”之间如果宕机,它自己不知道到底该提交还是回滚,需要协调者恢复后继续通知
我在一个内部系统里见过XA实装,跑的是支付路由配置这种低频数据,量不大还算稳,但一旦遇到网络抖动,整个事务卡死,数据库连接池被占满,一行配置变更能把核心服务拖垮。所以2PC更适合单机多库、或者数据变更极低频的强一致场景,互联网核心交易链路基本不碰。
2.2 TCC:预留资源换可用性
TCC是补偿型方案的典型代表,核心思想是不要直接操作最终数据,而是先预留资源。它把一次业务操作拆成三步:
- Try:完成资源检查与预留。比如下单时,不直接扣库存,先冻结库存;不直接扣余额,先冻结余额
- Confirm:真正提交。把冻结的库存扣掉,把冻结的余额扣掉,订单状态置为“已创建”
- Cancel:释放预留。把冻结的库存加回来,把冻结的余额解冻,订单状态置为“失败”
优势和代价都很明显。优势是各个服务可以独立部署、独立做资源预留,参与者之间没有长时间持锁,吞吐比2PC高一个量级。代价是业务侵入性强——每个操作都要写Try/Confirm/Cancel三个方法,都得实现幂等,还得设计资源预留的数据结构。很多团队选TCC的时候没意识到,它不只是“改接口”,而是要重新设计数据模型。
TCC适合的典型场景:订单创建、资金账户操作、库存扣减这种“先检查再操作”的强约束链路。我后面第三节会完整展开一次落地过程。
2.3 本地消息表与MQ事务消息:最终一致性的轻量路线
如果链路里的某个环节允许异步化,消息方案是性价比最高的选择。核心逻辑一句话概括:业务操作和消息写在同一个本地事务里,消息可靠投递到MQ,消费方处理完业务后手动确认。
本地消息表的经典做法是:在业务库建一张message表,开启本地事务后同时插入业务数据和一条待发送消息,事务提交后由独立任务扫描该表并投递到MQ。消费方成功处理后再回调或者靠消息表状态去标记完成。
MQ事务消息(比如RocketMQ的事务消息)把这张表搬到了Broker侧,通过半消息加回查机制实现类似效果,业务方不用自己建表,但代价是依赖MQ的特定能力。
这个方案的优点非常直观:性能损耗小、实现相对简单、不需要侵入业务方法。缺点是不适合强实时性场景——因为最终一致窗口取决于消息投递和消费的速度,而且“消息表扫描投递”机制如果设计不好,可能出现重复投递、消息积压、延迟过长。
2.4 SAGA与长事务补偿:流程编排的另一视角
SAGA的思路是把一个长事务拆成有序的子事务序列,每个子事务都配一个补偿操作。比如下单链路依次执行“创建订单→扣库存→扣余额”,如果扣余额失败,就依次执行“取消订单→回补库存”。它和TCC的区别在于,SAGA不需要预留资源,每个子事务直接执行真实操作,失败后再反向补偿。
SAGA最大的好处是模式天然适合长流程编排,比如一个跨天审批、一个多渠道发货流程,用TCC去预留几天资源不现实。代价是隔离性差——中间结果对外可见,如果某个后续步骤长期失败,前面已经执行完的事务就会留下“中间态数据”,补偿操作的复杂度也随链路长度上升。
实际工程里SAGA大多是靠状态机或编排引擎实现的,不像是TCC那样需要框架级别的协调者支持,反而跟工作流引擎结合的更多。
2.5 方案对比总表
| 维度 | 2PC/XA | TCC | 本地消息表/MQ事务消息 | SAGA |
|---|---|---|---|---|
| 一致性 | 强一致 | 最终一致 | 最终一致 | 最终一致 |
| 资源锁 | 长锁,重 | 短锁,预留 | 无锁 | 无锁 |
| 侵入性 | 低 | 高,需实现三方法 | 中,需建表或依赖MQ | 高,需拆事务+补偿 |
| 性能 | 低 | 中 | 高 | 中高 |
| 架构复杂度 | 低 | 中 | 低 | 高 |
| 适用场景 | 低频强一致 | 实时强约束链路 | 允许异步的松耦合链路 | 长流程编排 |
| 典型风险 | 阻塞、单点 | 悬挂、空回滚 | 重复投递、延迟 | 中间态可见,难排查 |
3. TCC链路落地实操:订单、库存、余额的一次完整改造
3.1 业务建模与接口拆分
我当时接到的需求是改造某订单中台的创建订单链路,原链路是同步RPC连续调用库存服务和资金服务,失败后由主服务手动调用补偿接口。改造第一步是理清资源模型,因为TCC要求Try阶段能“冻结资源”。库存不是把stock字段直接扣掉,而是拆成available(可用库存)和frozen(冻结库存);余额同理,拆成balance(可用余额)和frozen(冻结余额);订单表增加一个status字段标记“预创建/已确认/已取消”。
这里有个设计要点:Try阶段的接口入参和返回值必须和Confirm/Cancel保持一致,否则协调者重试时无法保证上下文完整。我见过一个团队把Try做成“扣库存并返回新库存量”,Confirm又改成“按订单号扣库存”,结果协调器重放时参数对不上,直接抛参数缺失错误。正确做法是三个方法都接收同一个业务上下文对象:订单号、商品SKU、购买数量、用户ID。
3.2 Try/Confirm/Cancel的代码骨架
下面是一个简化版的TCC参与者示例,以库存服务为例,使用Java配合Spring框架的TCC注解风格实现(框架本身可以替换,理解模式即可):
@Component public class InventoryTccAction { @TccTry public boolean freezeStock(InventoryContext ctx) { // 幂等校验:如果该订单号已存在冻结记录,直接返回true if (freezeRecordMapper.exists(ctx.getOrderId())) { return true; } int rows = inventoryMapper.freezeStock(ctx.getSkuId(), ctx.getQty()); if (rows == 1) { freezeRecordMapper.insertByOrder(ctx.getOrderId(), ctx.getSkuId(), ctx.getQty()); return true; } throw new BizException("库存不足"); } @TccConfirm public boolean confirmDeduct(InventoryContext ctx) { int rows = inventoryMapper.confirmDeduct(ctx.getSkuId(), ctx.getQty()); if (rows == 1) { freezeRecordMapper.markConfirmed(ctx.getOrderId()); return true; } // 如果rows为0,可能是重复confirm,此时直接查状态即可 return freezeRecordMapper.isConfirmed(ctx.getOrderId()); } @TccCancel public boolean cancelFreeze(InventoryContext ctx) { if (!freezeRecordMapper.exists(ctx.getOrderId())) { // 空回滚场景,直接返回成功 return true; } if (freezeRecordMapper.isConfirmed(ctx.getOrderId())) { // 已经confirm了就不能取消,这种情况走人工介入或对账 return false; } inventoryMapper.releaseFrozen(ctx.getSkuId(), ctx.getQty()); freezeRecordMapper.updateToCanceled(ctx.getOrderId()); return true; } }这段代码有几个细节值得展开:
- 幂等判断放在第一位。TCC的Try/Confirm/Cancel都可能因为网络超时被协调者重放,不判断直接操作,就会出现超扣、重复释放
- Cancel里加了一个isConfirmed检查。正常情况下协调者不会让你在Confirm之后再Cancel,但分布式环境下指令乱序是可能的,这个检查是最后的防线
- 每个方法都尽量做成“查状态→操作→更新状态”三步,操作动作本身只针对特定状态生效,避免状态覆盖
资金服务、订单服务的结构类似,只是资源维度不同。协调者会按照全局事务ID记录每个参与者的执行状态,哪个失败就触发对应Cancel。
3.3 联调中真实遇到的两个问题
第一个问题是空回滚。某次压测里库存服务Try阶段超时了,协调者判定失败并触发Cancel,可实际上Try的SQL因为数据库慢查询还没来得及执行——Cancel执行时发现没有冻结记录。我当时Cancel里直接抛了业务异常,导致协调器重试了十二次,消息排队、告警轰炸。后来改成:Cancel时先查记录,没有记录就按成功处理,什么都不做。空回滚的语义本来就是“我没预留过资源,所以也不需要释放”。
第二个问题是Confirm重复调用导致SQL更新0行。第一次Confirm执行成功,但是响应在网络传输中丢了,协调者重试第二次Confirm,库存已经扣完了,rows=0。这个时候如果抛异常,协调者会触发Cancel,可就变成“扣了库存又释放、订单还失败了”。我在Confirm里加了状态判断:如果已经confirmed,就直接返回成功。TCC有个铁律:幂等不是可选项,三方法都必须幂等。
3.4 别忽视协调者的超时与重试配置
TCC框架的协调者有几个参数一定要仔细调:Try阶段的超时时间、Confirm阶段的重试次数、Cancel阶段的重试间隔。我在环境里用默认配置跑到第3轮压测就出问题——默认超时是5秒,但库存服务高峰期要6秒多,导致大量事务被误判失败。后来把超时调成10秒,重试次数保持3次,重试间隔指数退避,压测通过率从97.2%一路升到99.98%。
另外,协调者一般都有事务日志表,记录每个全局事务的状态流转。线上排查问题时这表就是唯一可靠的时间线。我习惯了把主键设计为全局事务ID,并加上participants、status、update_time三个字段组合索引,查询效率会高很多。
4. 事务消息工程细节:本地消息表与补偿任务的设计
4.1 消息表和业务表的同库写入
事务消息方案里最核心的一条铁律是:业务操作和消息插入必须在同一个本地事务中完成。否则可能出现两种情况:业务成功了消息没发出去,或者业务失败了你发了条假消息。
下单场景里,本地事务会同时执行两个操作:
-- 业务操作 UPDATE orders SET status = 'PAID' WHERE order_id = ?; -- 消息表插入 INSERT INTO outbox_message (msg_id, topic, payload, status, retry_count, create_time) VALUES (?, 'ORDER_PAID', '{"orderId":...}', 'PENDING', 0, NOW());两个操作共用一个数据库事务,要么一起提交,要么一起回滚。提交之后,独立的消息发布组件定期扫描PENDING状态的消息,投递到MQ,投递成功后把状态改为SENT。这套方案很多人叫“发件箱模式(Outbox Pattern)”,实现成本低,不依赖特定MQ能力,属于最稳妥的兜底方案。
4.2 可靠投递的重试与告警
消息发布组件写起来要特别注意重试的“退避策略”。如果消息一直投递失败,重试间隔不能固定,否则高峰期可能把MQ打爆。我一般这样设计:
- 重试次数小于10次时,每隔5秒投递一次
- 超过10次后,退避到1分钟一次,最多再试50次
- 超过60次还没成功,消息状态置为DEAD,同时触发告警
重试到底应该幂等,实际上是的——因为消息表里同一msg_id不会重复insert,MQ端通过msg_id做幂等消费,所以重投本身不污染数据。
这个方案里最容易被忽略的是消息表的清理策略。PENDING和SENT的历史数据会越积越多,必须要有归档任务。我习惯把状态为SENT且超过7天的消息定期挪到outbox_message_arch,DEAD消息保留30天供人工排查。如果不做归档,这张表很快就会变成几千万行的怪兽,扫描查询直接把库拖垮。
4.3 消费端的幂等保障
消息发出去只是第一步,消费端能不能安全处理重复消息才是关键。即使发布端做了一次投递,MQ本身也可能因为消费者Ack超时触发重投。消费端的幂等有很多做法,我比较推荐去重表方式:
- 业务处理前,先检查唯一索引:
consumer_dedup (msg_id, biz_type) - 如果msg_id存在,直接返回成功,不重复执行
- 如果不存在,插入记录后执行业务逻辑,业务逻辑和插入记录在同一个本地事务
唯一的坑在并发场景:两个并发消费者拿到同一条消息同时插入唯一索引,会有一个插入失败。处理方式是让插入失败的那个消费者直接返回成功,因为另一个正在处理,没必要自己再插一次。
时间窗口方面,去重表记录的保留周期建议和业务容忍的重复消费窗口对齐。资金类业务我建了90天,一般订单类30天足够。
5. 最容易翻车的六个细节:真实案例向
5.1 幂等键别用自然业务键,要单独设计
有团队用订单号做TCC的幂等键,结果一单多次改动就出问题。订单号在“修改订单”场景里会复用,一旦TCC事务重试,直接命中旧幂等记录返回成功,新改动被吞掉。正确做法是每次业务动作生成一个全局唯一动作ID(actionId),作为事务ID。订单号可以作为业务维度进上下文,但不能当幂等键使。
5.2 超时与重试会制造悬挂
悬挂是什么场景?Try阶段超时了,协调者判决失败并触发Cancel,Cancel执行完成。可就在这时,超时的Try请求绕过了网络延迟终于到达了库存服务——如果Try方法不做防悬挂检查,它就会在Cancel之后冻结库存,从此“挂了”:库存被锁住但没有任何事务去释放。
防悬挂的标准写法是:Try方法执行前必须检查代表Cancel执行过的记录标记。比如在冻结记录表里建一个canceled字段,Try前先查一下有没有该订单的Cancel记录,如果有就直接拒绝执行。我见过不少上线半年都没暴露,结果全链路压测一上来就出现冻结库存越积越多的问题,最后查了半天才发现是这种竞态。
5.3 补偿顺序不能乱
SAGA或多级TCC里,补偿必须按反向顺序执行。比如订单→库存→余额,如果余额失败,必须先取消订单、再释放库存。有人为了省事,先释放库存再取消订单,结果订单还在有效状态,库存已经补回去了,用户还能继续操作,但后台真实数据已经错乱。
反向补偿逻辑需要软件开发时就固化下来,不能依赖人工编排。如果用了编排引擎,每个子事务都要声明自己的补偿操作和优先级,跑起来之后引擎按栈序弹出执行。
5.4 定时对账:最后的保命手段
无论方案设计得多完善,分布式环境里总有一些犄角旮旯的边界情况逃过所有机制。所以我始终坚持:每条核心链路都必须有独立的定时对账任务。对账任务的作用不是纠正业务数据,而是发现“状态应该一致但实际不一致”的数据,然后交给补偿流程或者人工处理。
我做过的一个订单对账任务,每天凌晨扫描“订单已确认但库存冻结记录未释放”数据,通过SQL关联两库表后生成差异列表。上个月那起凌晨告警,就是这层对账发现的,虽然晚了几小时,但至少没拖到财务结算以后。对账任务本身要独立于业务链路运行,不用走TCC协调器,直接用轻量脚本扫描差异。
5.5 监控别只盯“成功率”
接入分布式事务后,很多人只盯着接口成功率,忽略了几个更重要的指标:
- 冻结/释放差:冻结记录数和释放记录数的差值,这个值理论上应长期为0,一旦上升说明有悬挂或空回滚风险
- 补偿执行次数:次数突然增多说明主链路正在大面积失败,需要立即关注根因
- 事务协调器积压数:协调器处理不过来时,全局事务会大面积超时
- 本地消息表PENDING积压数:这个值超阈值基本说明投递通道出问题了
我通常在消息表扫描任务和协调者日志里各加一组Prometheus指标,再配上告警阈值,这样在告警还没被用户感知之前就能提前介入。
5.6 数据补偿脚本必须灰度执行
最后一条经验是给所有人的:补偿脚本不要直接在生产库上全量跑。哪怕你觉得自己对业务已经完全拿捏,分布式环境的数据修复也不是改一条update那么简单。修一条订单链路的数据,可能同时影响订单表、冻结记录表、消息表、消费去重表,任何一张表的状态漏掉,就会产生新的不一致。
我习惯先把待修复数据导出到临时表,按订单维度聚合,手动审一遍数据上下文,然后分批执行修复脚本,每批100条,执行完立即查一遍关键表的差异,没问题再跑下一批。如果遇到中间态数据复杂的情况,直接走“人工介入+补偿工具”协同处理,不硬上自动化。
最后的一点个人体会
分布式事务做了几年,最大的感受是:方案本身没有绝对优劣,重要的是清楚知道自己每一笔数据在任意时刻处于什么状态。2PC适合低频强一致,TCC适合实时强约束链路,本地消息表和SAGA适合异步化和长流程场景。选型时不要盲目追框架,先问问业务能不能接受最终一致、能不能把链路改造成异步化,这些问题比技术选型本身更重要。
最后分享一个实用技巧:不管上了哪种方案,都要给核心数据链路画一张“状态迁移图”放在团队Wiki里,标明每个节点的正常状态、异常状态、补偿触发条件。踩过几次坑之后你会发现,分布式事务的排查难度九成不在技术,而在没人说得清数据当前到底应该在什么状态。这张图画清楚,再复杂的故障也能在30分钟内定位到具体环节。