先给结论:分布式事务有解,但解不是某一个中间件,也不是某一种“开箱即用”的模式,而是一整套工程取舍。 这也是我最近几年被问得最多的问题。每次有团队拿着“订单服务扣库存老不一致”来求助,我都会问一句:你确定自己需要的是“分布式事务”,还是你只是需要一个能被追回来、对得上的数据闭环?
先讲个真实案例。去年有个团队找我诊断线上问题,他们的订单服务和库存服务拆成了两个库,核心链路引了一套全局事务中间件。压测一上,TPS刚到几百,数据库会话就全被锁等待占满,接口超时率直线上升。问题的根源倒不是中间件不好,而是他们把“分布式事务”当成了一个可以无缝替换本地事务的增强版事务,没有意识到在分布式环境里,我们要对抗的从来不是某个框架的缺陷,而是网络延迟、机器宕机、消息丢失这些底层事实。
这篇文章我尽量把这件事讲透:为什么分布式事务这么难、主流方案各自解决什么、怎么在真实项目里做选择。尤其会以“订单与库存”这个最高频场景为例,给出两套可以直接落地的设计。内容会比较长,建议先收藏再慢慢看。
1. 先把问题掰开:分布式事务为什么不是“选个中间件”那么简单
1.1 你以为在选方案,其实在和网络分区定律博弈
很多人在刚接触这个问题时,脑子里想的是:单机数据库能靠事务保证ACID,那分布式环境里搞一个“全局事务器”,不也能保证一样的效果吗?
问题就出在这里。
单机事务之所以可行,是因为所有数据都在同一个数据库里,事务管理器拥有一份全局的、确定性的视图。你可以锁行、锁表,可以记录redo/undo日志,可以决定提交或者回滚,而且这一切都发生在同一台机器的可控环境内。跨服务之后,任何一个环节的网络都可能莫名其妙抖动一下。数据库A提交成功了,数据库B那边却因为网络超时根本没有收到“提交”指令——这时候你手上没有任何信息能告诉你B的状态到底是什么。这种“不确定”才是分布式事务真正的敌人。
CAP理论里的partition(分区)不是理论家的恐吓,它是日常现象:交换机震荡、GC停顿、容器迁移、线程池满了,都可能导致一个服务暂时“失联”。在大楼里两个人可以面对面签字画押,但在两栋楼之间签协议就必须依赖通信;只要通信会断,就永远不存在一个方案能让所有人同时完成签字且永远不需要协商。
所以,网上那些“分布式事务六大方案”的文章看起来眼花缭乱,本质都是在回答同一个问题:当网络不可靠时,你怎么设计让步策略?是牺牲可用性去等所有人都确认?还是先放行一部分请求,再靠补偿把账算平?
1.2 单机事务和分布式事务,差的不是事务而是“控制权”
有人可能会说:我们系统已经做得很细了,每个服务都用了数据库事务,为什么跨服务数据还是会乱?
因为每个服务只对自己数据库里那部分数据有“控制权”。
本地事务的ACID,靠的是单点数据库能够对事务内所有操作做裁决。到了分布式环境,订单库和库存库是两套独立的数据库,各自有各自的事务管理器,没有一个公共的上帝视角能同时锁住两边、协调它们的提交。你引入分布式事务中间件,本质上是在所有参与方之上重建一个“中央协调者”。可是这个协调者本身也会挂,也依赖网络。于是你只是把问题从业务层搬到了协调层,并没有消除不确定性。
这也是为什么2PC这类“先prepare、再commit”的方案会有那么多争议。2PC把不确定性尽量压缩到“第二阶段协调者宕机”这一个窗口里,但窗口并没有完全消失。只要这个窗口存在,参与方就必须考虑一个问题:我本地事务已经准备成功、资源锁也持有,可协调者迟迟没通知我到底提交还是回滚,我该怎么办?锁不能一直不放,放了又可能和数据不一致。这个困境不是实现不够好,而是分布式系统里“提交决定”和“执行提交”之间天然存在时间差。
1.3 强一致与最终一致:不存在谁比谁高级
还有一点必须澄清:很多开发者在潜意识里觉得“我要做分布式事务,就是要做强一致”,似乎不强一致就不专业、不安全。这是个很大的误区。
强一致和最终一致不是高低之分,而是不同业务约束下的两种选择。你在京东下单,页面显示“订单已提交,请付款”,这本身就是一次可用性优先的选择;此时后台库存还没扣减,只有当你付款动作真正发生的那一刻,系统才会去锁定库存。这一小段不一致窗口用户完全感知不到,就算感知到了,也只是看到“库存紧张”之类的提示。可如果你是处理支付扣款,用户余额不允许多扣,那你对一致性的要求就完全不同,可能要考虑TCC或者带锁的本地事务。
“最终一致”不等于“最终乱七八糟”。它说的是经过一段可预期的时间后,数据会收敛到一致状态。除非极端故障,否则这个收敛时间通常只有几百毫秒到几秒,用户可以接受,业务也承担得起。把精力花在准确判断“哪些场景可接受短暂不一致、哪些场景绝对不能”上,比盲目找一个万能框架要重要得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主流方案的真实角色与代价:从XA到Seata AT逐个排雷
2.1 XA/2PC:教科书里最“正经”的方案,现实里最不敢用
先聊最“正统”的解法,X/Open XA规范下的两阶段提交。这也是很多刚接触分布式事务的人最先学到的方案。
两阶段提交的思路很清晰:先让所有参与方做prepare,各自把本地事务执行到可提交状态并持有资源锁,然后全部返回成功,再由协调者发起commit;只要有一个人prepare失败,就全员rollback。这套逻辑在理论上非常漂亮,以至于很长一段时间里,“分布式事务”和“XA”几乎是同义词。
但你在生产环境里很少看到它跑在核心链路上,原因是它有两个硬伤。第一,第二阶段协调者如果宕机,参与方不知道最终裁决,事务会一直悬在那里,数据库连接和行锁长时间不释放,系统吞吐量直线下跌。第二,prepare之后资源的锁是真实存在的,两个事务如果交叉访问同一条数据,很容易互相锁死。对高并发业务来说,等锁的代价往往比数据不一致更严重。
实际见过不少团队做技术选型时,一开始把XA列入候选,演几轮下来就放弃了。不是它不正确,而是它对运行条件要求太苛刻。能接受这种短期阻塞和性能损耗的业务,比如一些低频但强一致的批处理,可能用数据库自身机制就能搞定,未必需要单独上分布式事务协调器。
2.2 TCC:业务友好但代价来自两件事
于是业界开始想:能不能不要让数据库锁那么久,而是把“资源锁定”这件事从数据库底层上移到业务层?这就有了TCC(Try-Confirm-Cancel)。
TCC把一个分布式事务拆成三个阶段。Try阶段做资源检查和预留,比如扣库存的Try就是先把“可用库存”里的数量搬到“预占库存”里,并不真正扣减;Confirm阶段在所有人都Try成功后才真正执行,比如把预占库存转成已扣库存;Cancel阶段则负责在有人失败时把预占库存释放回去。
TCC的优点是每个阶段都是短事务,数据库锁只在各自服务本地事务里短暂存在,跨服务之间的“锁”通过业务状态体现,而不是靠数据库锁,因此性能比XA好得多。但它有两个绕不开的代价。第一,业务侵入很大,每个参与方都要实现Try/Confirm/Cancel三个方法,而且Confirm和Cancel必须幂等。第二,你得自己处理空回滚和悬挂问题——比如Try请求超时了,框架直接调Cancel,但Cancel先到,Try后到,如果没有事务控制表记录状态,Cancel就空转了一次,迟到的Try还可能把已经释放的资源重新占住。
TCC本身不是银弹,它只是把“补偿规则”从数据库强制逻辑变成了业务显式逻辑。业务代码的复杂度上升了,但对底层资源的控制力也强了。金融场景里做资金冻结、账户余额扣减这类操作,TCC的这套“先预留再确认”思想,即使你不套用框架,也值得借鉴。
2.3 本地消息表:老土却可靠的兜底
如果业务能接受异步最终一致,方案就自由多了。在“最终一致”流派里,最古老也最可靠的套路之一,是本地消息表。
核心思路特别朴素:在执行业务操作的同一个本地事务里,顺便往一张消息表里插一条消息。业务提交,消息也提交;业务回滚,消息也跟着没了。然后由一个后台任务扫描消息表,把状态为“待发送”的消息投递给MQ或直接调下游接口。投递成功后把消息状态改成“已发送”。
为什么说它可靠?因为它利用的是数据库本地事务的原子性。你的订单表更新和消息表插入在同一个数据库事务里,绝对不会出现“订单提交成功了,消息却丢了”的情况。早年很多支付系统没上MQ,就用一个定时任务轮询消息表,照样稳定跑了好多年。
缺点也很明显:消息表和业务数据耦合在同一库里,订单库越来越大,消息表也跟着膨胀;如果业务库分片了,消息表还得跟着分片。所以它更适合作为“最终一致性底座”来理解,直接落到业务库看运维成本能不能接受。
2.4 事务消息:把消息表交还给中间件
既然本地消息表的核心问题是“消息表得自己维护”,那如果把这张表搬进消息中间件内部,让消息中间件帮我们做“半消息”和“回查”,业务侧就能清爽很多。
这就是RocketMQ事务消息的模型。流程大致是:生产者先发送一条半消息,消息到MQ后处于不可见状态;接着生产者执行本地事务;执行成功后向MQ发送commit指令,消息才真正对消费者可见;如果本地事务回滚,就发送rollback把半消息删掉。
这里最关键的是回查机制。如果生产者在执行本地事务期间进程崩了,commit或者rollback都没有发出去,MQ会定期反向调用生产者的回查接口,询问那条半消息对应的本地事务到底成功了没有。所以你的服务需要额外提供一个根据消息内容查本地事务状态的接口。有了回查,消息的“最终投递”就和业务的“最终状态”绑定在了一起。
用事务消息替代手写消息表,省掉的不仅是表结构,还有一大批状态流转和清理脚本。但注意一点,MQ的事务消息只解决“上游消息可靠发出”问题,下游收到的消息仍然遵循“至少一次”语义,消费者必须自己做幂等,否则重复投递会变成重复扣减。
2.5 Seata AT模式与Saga:低成本入口和长流程编排
国内团队最多接触的可能就是Seata。Seata的AT模式看起来非常诱人:你甚至不需要改太多业务代码,框架通过代理数据源解析SQL,自动生成undo_log,事务提交后自动回滚。很多文章把它描述成“分布式事务零改造接入”。
实际落地时要清醒:AT模式本质上是一种“事后补偿”的自动化实现,它虽然对业务代码侵入小,但对数据访问模式有隐性要求。比如在高并发下,AT模式为了控制隔离性需要持有全局锁,热点数据一旦并发写,锁等待依然存在。再比如如果你的更新语句本身依赖数据库当前值做复杂计算,AT模式生成的补偿SQL可能和实际语义有细微偏差。所以它更适合中低并发、跨服务更新不频繁的业务系统,比如后台管理、订单状态流转;而像秒杀减库存这种热点写场景,把它当银弹用,结果往往是把数据库拖垮。
Saga则是另一种视角,它把一个长事务拆成多个本地事务序列,每个本地事务成功后触发下一个,失败则逆序调用补偿操作。它和TCC的区别在于Saga更偏向业务编排和长流程,比如下单后扣库存、创建发货单、通知仓储,整个过程可能跨越几十秒甚至更久,根本不适合用数据库锁把资源钉住。Saga又分成事件编排和中央协调器两种实现,业界也有Seata Saga状态机这种可视化编排工具可以用。它的代价是隔离性比较弱,中间状态可能被其他事务看到,所以必须通过业务状态和补偿逻辑来规避风险。
2.6 把这些方案放在同一张表里看
没有哪个方案是完美的,选型的关键在于搞清楚你愿意在哪个维度做出牺牲。我习惯用下面这张表来帮助团队做初步判断:
| 方案 | 一致性级别 | 业务侵入 | 性能影响 | 典型适用场景 |
|---|---|---|---|---|
| XA/2PC | 强一致 | 低 | 锁持有时间长,吞吐低 | 低频、强一致、可容忍锁等待 |
| TCC | 近似强一致 | 高 | 阶段短事务,性能较好 | 资金冻结、库存预占等短事务 |
| 本地消息表 | 最终一致 | 中 | 开销低 | 无MQ时的可靠异步通知 |
| RocketMQ事务消息 | 最终一致 | 中 | 开销低,依赖MQ | 下单后扣库存、积分发放 |
| Seata AT | 自动补偿 | 低 | 全局锁可能形成竞争 | 中低并发的跨库更新 |
| Saga | 最终一致 | 高 | 无长锁,适合长流程 | 订单、支付、履约跨服务长链路 |
这张表不能直接替你拍板,但能帮你快速排除那些“看着能用、实际跑不动”的方案。比如压测发现性能扛不住,你先别急着优化SQL,看一眼表格里选的方案是不是根本不适合高并发场景。
3. 选型前三问:把“跨服务事务”消灭在架构阶段
3.1 第一问:这两个操作真的需要分属不同服务吗
很多分布式事务问题,本质上是微服务拆分过度造成的。
见过一个团队,把一个用户模块拆成了“用户基础信息服务”和“用户扩展信息服务”两个服务,两个库。有个操作用户注册时要同时更新基础信息和扩展信息,于是他们开始讨论分布式事务怎么实现。我当时的建议是先讨论能不能合并回同一个服务。同一个用户的信息在一块数据里被高频一起读写,它天然就是一个聚合,强行拆开只会凭空创造一致性难题。
所以做选型之前,一定要先问自己:这两个需要一起变更的数据,是不是本来就该待在同一个数据库里?如果业务上它们永远在同一事务内被读写,那就没有任何理由把它们拆到不同服务。所谓的“分布式事务专家”,很多时候干的第一件事其实是减少分布式场景,而不是在分布式场景里难为自己。
3.2 第二问:业务接受“过一会儿一致”,还是必须“此刻一致”
这个问题的答案,直接决定走同步协调路线还是异步收敛路线。
“过一会儿一致”并不是说最终一致性低级。主流电商的库存展示、订单状态、优惠券发放,绝大多数都是最终一致。你在购物车点击结算,库存扣减可能发生在请求链路里,也可能发生在后端异步队列里,用户根本分辨不出来。
真正需要“此刻一致”的,通常是被其他系统记录、不能被反悔的资产类操作。比如用户支付了一笔钱,账户余额必须立刻减少;或者跨行转账,不能出现A扣了款B没到账的“中间态”被用户感知。金融系统倾向用TCC、带本地事务的可靠消息机制,再搭配严格的对账,而不是简单发个MQ就结束。
判断标准可以再粗暴一点:这个操作如果发生短暂不一致,有没有人能感知到?感知到之后,你能否在业务上给出解释并在秒级内修正?能,就放心走异步最终一致;不能,才需要上强一致方案。
3.3 第三问:你的瓶颈到底在一致性,还是性能和可运维性
有些团队把“一致性问题”和“接口超时问题”混在一起处理。数据偶尔不一致,本质是缺少补偿机制;接口老是超时,本质是下游依赖太多、同步调用链太长。
我见过最典型的情况是:订单服务同步调库存服务扣减库存,又同步调支付服务创建支付单,再同步调积分服务发积分。整条链路任何一个环节抖动,订单接口就超时,好不容易四个服务都成功了,其中一个网络断开,系统不知道如何回滚,于是产生不一致。这种场景的根因不是“没有引入分布式事务框架”,而是把太多本可以异步化的操作放进了同步链路。
正确的做法往往是先把长链路拆短。下单请求只需要完成“生成订单+锁定库存”这两个最核心的步骤,支付、积分、短信通知全部通过消息异步化。链路短了,跨服务一致性问题自然就少了。如果连“生成订单+锁定库存”这种最短链路都还需要跨服务协调,再考虑TCC或事务消息,这时候问题才真正需要分布式事务技术来兜底。
3.4 不同场景直达答案
把三个问题问完,大部分场景其实已经能落到具体方案上了。我整理了几条高频路径,可以直接当选择题做:
- 业务操作必须强一致、不允许迟延且事务很短,比如扣余额、冻结资金:优先TCC,框架不强求,状态机加幂等才是重点。
- 业务可以异步收敛,比如用户下单后扣库存、发优惠券、更新积分:优先RocketMQ事务消息或本地消息表。
- 跨服务更新同一份数据模型的不同侧面,比如订单状态和订单明细状态:先考虑是不是要把两个服务合并,合并不了选用Seata AT这种低侵入方案即可。
- 一个操作要穿越多个独立子系统,每步耗时不确定,比如创建一个履约单然后走仓库调度:用Saga编排和补偿,不要把全局事务锁在一条链路上。
选型的终点不是“选哪个框架”,而是“选择哪种一致性代价”。很多人以为分布式事务是个技术问题,其实它是个成本问题,想通了这一点,方案自然浮出水面。
4. 订单-库存场景的两条可落地路径:TCC与事务消息对比
4.1 需求先拆清楚:扣减、预占、释放是三个独立状态
说了这么多,我拿最经典的“下单扣库存”场景走一遍完整设计,这次不聊抽象概念。
业务需求拆开看,其实是三件事:用户下单后要扣减可售库存;订单取消或超时未支付要释放库存;系统不能超卖。如果把这三个动作混在一个叫“扣库存”的服务里,后面所有设计都会乱。
更合理的建模是把库存拆成两个概念:可售库存和预占库存。用户下单那一刻,系统做的不是“直接从可售库存里扣掉”,而是“从可售库存里预占一份”。预占之后,库存对其他人不可见。等用户支付成功,预占库存转为实际扣减;用户取消或未支付,预占库存释放回可售库存。
这组概念本身就是TCC思想的雏形。你会发现,不管底下用不用分布式事务框架,业务状态设计都必须先具备Try(预占)、Confirm(确认扣减)、Cancel(释放回滚)这三类动作。没有这层设计而只靠“扣库存”三个字,任何方案都救不了你。
4.2 路径A:TCC强一致扣库存,写法和注意点
如果团队严格要求下单后立刻看到可用库存减少,并且不希望引入异步消息队列,那可以走TCC强一致路径。
一次下单动作,会跨订单服务和库存服务。订单服务作为主业务服务,负责发起整个全局事务。整体流程是这样:订单服务提交Try,在订单库里生成一条“待确认”状态的订单记录;库存服务执行Try,把库存表里的一部分“可售库存”改成“预占库存”,相当于先占住货;所有Try都成功后,订单服务执行Confirm,把订单状态更新为“已下单、待支付”;库存服务执行Confirm,把预占库存转成已扣减库存。如果任何一个Try失败,比如库存不足,就全员进入Cancel,订单记录置为失效,预占库存释放回可售库存。
库存表的设计可以简化成下面这样:
sql复制CREATE TABLE t_inventory (
sku_id BIGINT PRIMARY KEY,
available INT NOT NULL COMMENT '可售库存',
occupied INT NOT NULL DEFAULT 0 COMMENT '预占库存',
updated_at DATETIME NOT NULL
);
库存服务Try的SQL不能傻傻地先查再减,必须用一条原子条件更新把“可售充足才预占”写进数据库语义里,否则并发时怎么都挡不住超卖:
sql复制UPDATE t_inventory
SET occupied = occupied + 1
WHERE sku_id = #{skuId}
AND available - occupied >= 1;
影响行数为0,说明库存不够,Try直接抛异常触发Cancel。
这只是纸上流程,真正落地时最让你头痛的不是主流程,而是网络超时后的状态判断。比如Try请求已经打到库存服务,但返回响应丢了,框架该怎么处理?它不能直接判定Try失败,因为库存已经预占上了;也不能盲目Confirm,因为调用方可能已经决定回滚。
我的处理习惯是维护一张事务控制表,把每个全局事务的ID、各分支的状态记下来。分支服务收到Try、Confirm或者Cancel请求时,先查控制表判断当前处于什么状态。Try执行过但没回执,后续到达的Cancel要把预占释放回去;Confirm已经执行过,重复到达的Confirm要直接返回成功,防止重复扣减。这套“状态先行、操作幂等”的机制,是所有TCC落地都绕不开的主干,比选哪个具体框架重要得多。
4.3 路径B:事务消息+幂等扣减,把同步等待变成异步收敛
如果业务能够接受“下单后库存稍晚扣减”的最终一致模型,事务消息是比TCC轻量得多的一条路。
链路大概是:订单服务先创建一条状态为“待扣减”的订单,同时向RocketMQ发送一条半消息。订单本地事务提交成功,MQ才让这条“扣减库存”的消息对库存服务可见。库存服务消费消息,做原子扣减,扣减成功,把订单状态更新成“待支付”;扣减失败,就重试或者进入异常处理。整个过程订单服务和库存服务不再有同步调用,接口响应速度会明显变快,服务之间也彻底解耦。
这里有一个非常容易被忽视的点:事务消息同样依赖“订单状态”和“半消息状态”的对齐。如果订单服务本地事务提交了,但是回查接口返回失败,MQ就会丢弃这条消息,那库存就永远不会被扣减。所以订单服务的本地事务必须把“扣减库存消息所对应的业务凭证”落库,回查时才能确定地说这条消息到底该不该投递。
库存服务消费端必须自己做幂等。消息至少会投递一次,不做幂等的消费者早晚会在网络抖动时重复扣减。我习惯加一张扣减流水表:
sql复制CREATE TABLE t_stock_deduct_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tx_id VARCHAR(64) NOT NULL COMMENT '业务事务ID或消息ID',
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_tx_id (tx_id)
) COMMENT '库存扣减幂等流水';
消费逻辑在同一个本地事务里先插入流水表,再执行库存扣减,最后更新订单状态。如果重复消息到达,插入流水时唯一键冲突,直接判定为重复请求返回成功,不再扣减。你甚至可以顺便把这个流水表当对账依据,后面讲到对账会再一次用到它。
4.4 两条路径都绕不开的三件事
不管选A还是B,有三件事是绕不开的。
第一,幂等。TCC的Confirm和Cancel要幂等,事务消息的消费要幂等,订单回调库存的结果接口也要幂等。幂等的标准做法不是用内存标记,而是用唯一ID加数据库唯一索引。只要你把每个操作抽象成一条带唯一凭证的记录,幂等就变成了一道简单的约束题。
第二,超时处理。远程调用的超时绝不等于失败。网络包可能已经到达对方服务,对方也处理完了,只是回包丢了。此时你该做的不是马上重发一个相反操作,而是先查一下对方服务里的“执行记录”,确认这个操作到底发生了什么。这也是为什么上面反复强调事务控制表和流水表,没有这些记录,超时后你就是瞎猜。
第三,状态的可逆性。订单状态不能只有一条直线路径:待扣减、待支付、已支付、已完成。你得允许它被异常拉回到取消态,允许库存扣减失败后订单自动关闭,允许支付超时后释放预占。状态机里至少要有两个可逆分支,一个是订单服务主动取消,一个是超时未支付自动释放。这样即使出现方案之外的意外,你的对账脚本也有依据能把数据掰回正轨。
5. 我心中真正能落地的“解”:状态机+幂等+对账
5.1 状态机:让数据自己说明它走到了哪一步
说句得罪人的话,很多团队的数据不一致,不是缺事务框架,而是缺状态机。
订单状态没有设置“待扣减”“待确认”这种中间态,用户一提交直接置为“已付款”,库存扣减却在另一个服务里异步跑。一旦库存扣减失败,订单服务还傻乎乎地认为自己是已付款状态,两边永远对不上。反过来,如果订单有一组明确定义的中间态和流转规则,每个状态都对应明确的业务含义,那出现异常时我们至少知道这份数据卡在哪个环节、应该用什么动作去驱动它。
我习惯在项目里用显式状态枚举管理整个生命周期,而不是散落一堆status字段魔法值。比如订单有若干状态:待扣库存、待付款、已付款、已发货、已取消;库存有:可用、预占、已扣、已释放。消息驱动让状态按既定路径流转,对账任务不断扫描那些“不该停留太久”的状态。状态机设计好了,很多一致性代码其实是水到渠成写出来的,因为你压根就不需要“把两个系统改一致”这种模糊操作,你只需要让每个系统都沿着状态机往前走。
5.2 幂等:无脑重试的唯一底气
在分布式环境里,重试是解决瞬时故障最朴素、最有效的手段。但无脑重试会放大事故,所以重试必须配幂等。
消息消费要幂等,接口调用要幂等,定时任务扫单触发的补偿动作也要幂等。幂等键的选择要非常讲究,通常选业务唯一键,而不是单纯的流水号。扣库存用tx_id,发券用order_id加活动ID,退款用原支付流水号。一张唯一索引表就能解决90%的幂等需求。
还有一种容易被忽略的幂等是“补偿任务的幂等”。定时任务每五分钟扫一次超时未支付订单,触发库存释放,如果上一轮扫到订单时释放请求发出去了,但库存服务慢了半拍,这一轮又扫到同一笔订单,就可能重复释放。解决办法还是在释放动作里带业务唯一键,通过流水表判断这次释放是否已经发生过。
5.3 对账:分布式系统的最后一道防线
不管你前面做了多完美的状态机,做了多严格的幂等,还是会遇到极端case:消息彻底丢失、回查接口有bug、人工运维操作失误、中间件版本升级导致消息积压。这时候能救你的只有对账。
对账的本质是找一个“不变量”,用定期任务去验证这个不变量是否被打破。订单库存场景里有一个很简单的不变量:每个成功下单且能付款的订单,最终必须对应一条库存扣减流水;每个被取消的订单,占用的库存必须已经释放。对账任务可以把订单表和库存扣减流水表做一次关联扫描,揪出那些“订单已付款但库存流水缺失”的记录。
我见过不少系统上线第一年都没有对账脚本,出了问题靠业务投诉,运维手工改数据,改完也不敢保证干净。后来花了一个下午写了一个对账任务,每天凌晨跑一次,把中间状态的订单量、异常流转的订单明细全部拉出来,问题从“用户发现”变成了“系统发现”,整体风险下降了一个量级。
对账脚本本身不用写得花哨,简单SQL加日志告警就够用。真正要投入精力的是你的数据模型里有没有一个稳定的不变量,能不能对得上。对不上时,是优先重放消息、补偿操作,还是需要人工干预,这些规则要提前设计好,而不是等出事了再拍脑袋。
聊到这里可以给最开始的问题一个答案了。分布式事务到底有没有解?我的体会是:真正的解,不是让两个服务在同一个瞬间提交,而是把业务拆成一组可追踪的本地事务,让状态机决定流向,让消息驱动流转,让幂等拦截重复,让对账兜住意外。 什么时候该用TCC、什么时候该用事务消息、什么时候引入Seata,是在这个大框架下的局部决策。
最后分享一个我这些年踩坑踩出来的习惯:每当你觉得自己需要一套分布式事务框架时,先别急着接框架,坐下来把业务流程里的状态列全、把中间状态补齐、把补偿步骤画出来。很多时候工作做到这一步,你会发现原来根本不需要引入那么重的东西,几条可靠的消息加对账脚本就已经把问题解决得很完美了。
