
上一篇文章我们聊了分布式事务为什么难、CAP和BASE到底在说什么也简单提了强一致和最终一致这两条路线。后台留言里有不少朋友说概念是懂了但真到自己上手选型、写代码的时候还是不知道该用哪个方案尤其是订单和库存这个经典场景一扣库存就超卖一异步就对不上账问题到底出在哪。这篇就把这些事彻底聊透。先说结论分布式事务没有银弹你能做的就是根据业务容忍度选出最合适的那个方案然后用工程手段把它的副作用降到最低。这篇会从方案盘点开始聚焦订单与库存这个高频场景把TCC、事务消息、本地消息表这些主流方案放到真实业务里看一遍包括它们各自最恶心的坑、以及我实际踩过之后总结的处理办法。项目中正被数据不一致折磨的同学这篇能帮你理清思路刚接触分布式事务的同学这篇也可以当作一份偏实战的选型参考。1. 方案全家福哪些能用哪些看着美却不好用分布式事务发展到现在能叫上名字的方案就那么几种2PC两阶段提交、3PC、TCCTry-Confirm-Cancel、SAGA、本地消息表、事务消息。很多文章会把这些都列一遍但面向真实业务我的判断很直接2PC能不用就不用TCC是最通用的强一致方案但很考验代码功底事务消息和本地消息表则是最终一致性的主力。1.1 2PC为什么被多数互联网公司放弃2PC的原理很简单引入一个协调者第一阶段先让所有参与者做准备工作写Undo日志、加锁资源全部准备好了再进入第二阶段统一提交。这个模型在数据库层面有成熟的实现比如XA协议像Seata的AT模式底层用的也是类似思路。但2PC有几个硬伤在实际业务里会要命。第一同步阻塞。第一阶段所有参与者都拿着数据库连接和锁资源就等协调者发指令。如果某个参与者网络抖动整个事务的全局资源就干等着系统吞吐直接被打没。数据库连接池总共就那么几十个连接一旦被这种慢事务占满整个服务就雪崩了。第二协调者单点。协调者挂了所有参与者既不知道提交还是回滚锁只能死等。更麻烦的是协调者恢复之后如果状态丢了所有分支事务全都变成悬空状态。第三数据不一致没得救。第二阶段如果某个参与者提交失败其他已经提交的节点没法撤销。说白了2PC根本解决不了网络分区下的最终一致只是把问题推迟到了提交阶段。所以2PC这套东西更适合那些极短事务、参与方数量少且网络极其可靠的传统单体架构。到了微服务这种跨进程、跨网络的场景直接上2PC等于把整个系统的可用性押注在协调者和网络上这是赌命。1.2 TCC、SAGA、消息方案各适合什么场景TCC是我在订单、账户、支付类系统里用得最多的方案。它的核心思路是把一个分布式事务拆成三个阶段Try阶段做业务检查并预留资源Confirm阶段真正执行业务Cancel阶段回滚。因为业务方自己控制每个阶段的操作所以能做到比2PC更细粒度地释放资源不会一直握着数据库锁。代价就是侵入性强每个业务操作都要写三段逻辑而处理悬挂、空回滚、幂等这三个问题会消耗大量精力。SAGA则是把一个长事务拆成一系列正向事务和对应的补偿事务。它没有预留资源这一步所有事务先正常提交后续某个步骤失败了再逐个反向补偿。SAGA比TCC好写但问题在于隔离性差中间步骤的结果对其他事务是可见的业务上如果接受不了“先看到脏数据后面再修正”这种模型就别选SAGA。反例一个典型场景是订单已提交但库存还没扣成功在途订单状态被其他服务读到就可能提前触发发货之类的后续动作这就需要额外引入业务层面的状态机保护。消息方案走的是最终一致性路线。本地消息表和事务消息的核心逻辑是一致的把本地业务操作和消息发送放在同一个事务里要么一起成功要么一起失败之后通过消息异步通知下游执行操作保证两边最终状态一致。这套方案性能好、结构简单代价就是状态在短时间内可能不一致且下游消费必须做幂等。有人会问那分布式事务框架选哪个我的意见是框架只是工具方案才是核心。你现在用的注册中心是Nacos、Zookeeper还是Eureka这决定了Seata这类框架的整合成本你的业务对数据不一致的容忍度有多高这决定了你该选AT还是TCC模式。先搞清楚自己的业务需要什么再选框架顺序不能反。2. 订单与库存场景拆解强一致 vs 最终一致的选型逻辑订单和库存的分布式事务是网上讨论最多、实际踩坑最密集的场景没有之一。原因很简单库存一旦扣错要么超卖要么少卖都是真金白银的损失而且用户端会立刻炸锅。2.1 为什么这个场景让人如此头疼假设你在做一个电商系统用户下单会同时涉及订单服务、库存服务可能还有账户服务。订单服务创建订单库存服务扣减库存这两件事必须保证“要么都成功要么都失败”。但在微服务架构下这两个服务各自有独立的数据库没法用本地事务一把梭跨库操作就成了问题的根源。光是不一致还不够还有一个隐含约束——并发。双11零点的瞬间同一件商品可能同时有几千个用户在抢如果扣减库存的逻辑写得不严谨超卖几乎是必然的。超卖本身是数据一致性问题但它暴露出来的其实是并发控制不到位。还有一个容易被忽视的痛点——补偿逻辑的复杂性。订单创建成功了库存扣减失败了这件事按业务规则应该把订单取消但取消订单本身又要改订单状态、可能还要通知用户如果补偿操作本身也失败了呢分布式事务的复杂度终极来源就是这种“补偿的补偿”一环套一环。2.2 选型决策先回答业务容忍度问题拿这个场景来说我每次做方案选型都会逼问业务方一个问题库存超卖这件事你们业务上能不能兜得住如果答案是绝对不能接受比如库存量很小、价格很高、每件都对应真实采购成本那就要走TCCTry阶段锁库存Confirm阶段减库存Cancel阶段释放库存只要还没确认库存就一直被占着别人不能买。如果业务能接受短期超卖比如活动秒杀场景反正最后要退款或者砍单那就直接上事务消息或者本地消息表下单成功发了消息就返回库存服务异步扣减超卖数量靠后续对账兜底。这里面的权衡本质上是用户体检和系统复杂度的博弈。TCC在用户侧体验最好——拿到订单就代表库存锁定了——但实现和运维成本最高。消息方案实现简单但用户在秒杀场景下拿到订单后可能等几秒才发现订单被系统取消。我个人的项目经验是资金、库存这类核心数据能上TCC就上TCC周边链路、时效要求不高的数据一致性用事务消息就够了。一刀切地说“全公司统一用某某框架”的多半是没被坑过。2.3 更深一层库存扣减还要防超卖不管你选哪种事务方案库存扣减本身的并发控制都不能少。很多人以为有了分布式事务框架就能高枕无忧结果框架层面的数据一致了库存100件被卖了200单一看代码——用select然后update直接破功。防超卖的正确姿势是在数据库层面扣减而不是应用层。UPDATE inventory SET stock stock - #{quantity} WHERE sku_id #{skuId} AND stock #{quantity}返回受影响行数为0就说明库存不足然后在应用层抛异常。这条SQL本身就带条件用MySQL的行锁天然保证了并发安全。不要自己去写什么分布式锁来限制这属于给数据库扣减套了层不必要的外衣出错的概率更大。但在高并发场景下每次都打数据库也不是个事。更常见的做法是Redis预扣数据库兜底先在Redis里用Lua脚本原子扣减库存扣减成功后才去走事务流程数据库里的库存作为最终对账的兜底。如果事务回滚了再把Redis里的预扣值加回去。这么设计可以把热点库存的QPS承载能力提升一个数量级代价是要处理Redis和数据库之间短暂的不一致——不过这个不一致正好可以被你的最终一致性事务方案兜住。3. TCC落地实战处理悬挂、空回滚和幂等TCC的坑网上讨论得挺多但大多是零散的只言片语真正落地时能踩的细节比想象中多。这里结合我在一个订单与库存项目里的实操把关键环节逐个展开。3.1 设计主控服务的三个阶段TCC的第一件事就是定义主控服务的三大阶段方法。以订单创建库存扣减为例我会把主控逻辑放在订单服务里它负责串联整个事务流程。Try阶段负责预留一切需要的资源订单服务创建一条状态为“待确认”的订单记录库存服务执行UPDATE inventory SET frozen_stock frozen_stock #{quantity}, stock_available stock_available - #{quantity}。这里的关键是用一个单独的frozen_stock字段记录预占库存而不是直接改stock字段这样即使在Tr阶段没有完成真实库存也没被扣掉Cancel阶段只需要把frozen_stock减回去即可。这个设计能避免Cancel阶段和实际下单并发时的数据错乱也是网上很多TCC案例没讲清楚的核心细节。Confirm阶段执行真正的业务操作订单状态从“待确认”改为“已确认”库存服务把frozen_stock扣减掉stock_available已经减过不需要再动。这个阶段的SQL务必保证幂等通常用事务状态或者唯一业务号做判断。Cancel阶段执行回滚订单状态改为“已取消”库存服务执行UPDATE inventory SET frozen_stock frozen_stock - #{quantity}, stock_available stock_available #{quantity}。同样要幂等。这三个阶段要放在不同的服务里通过TCC框架比如Seata的TCC模式或者自研框架协调调用。每一阶段的服务都必须返回明确的成功或失败信号框架才能决定是进入下一阶段还是触发回滚。3.2 三个经典陷阱空回滚、悬挂、幂等TCC被诟病“难落地”根源就是这三个问题任何一个没处理都会导致业务数据错乱。空回滚指的是Try阶段还没执行成功Cancel阶段就被调用了。这种情况在网络超时后很常见——主控服务调用库存服务的Try接口超时了于是触发Cancel补偿但库存服务的Try可能根本没执行Cancel却先到了。处理办法是在Cancel方法里先检查有没有对应的Try记录没有就直接返回成功不做任何补偿操作。为了保证这个检查的可靠性库存服务里需要保存每次Try的上下文记录用Try请求的唯一事务ID作为索引。悬挂是空回滚的反向问题Cancel先执行成功了之后迟到的Try才到达。如果不做处理这个迟到的Try就会把资源预占掉而事务已经回滚了这个预占就永远不会有对应的Confirm或Cancel来释放资源就成了死资源。解决办法是在Try方法里检查事务状态如果发现事务已经处于回滚状态就直接拒绝执行Try。这个检查得靠事务状态表来实现不能依赖内存——因为服务可能有多个实例状态表的查询和更新必须同步进行。幂等问题贯穿TCC全流程。Try、Confirm、Cancel每个方法都可能被框架重试多次因为分布式环境下网络超时、机器宕机都可能导致框架重新调用。处理办法是在每个方法里用事务ID查状态表已经执行过就直接返回上次的结果不再重复执行。我见过最惨烈的线上事故就是因为幂等没处理好Cancel重复执行把库存加回了两次结果账面上库存多了1000件直接引发大面积超卖。这种问题在测试环境很难发现因为测试环境网络太好很少触发重试一到线上就原形毕露。3.3 事务状态表TCC的隐形支柱上面反复提到的状态表是整个TCC方案里最容易被忽略但又最关键的部分。我一般会在每个参与TCC的服务里建一张事务记录表大致结构如下字段说明tx_id全局事务ID由主控服务生成tx_type事务类型比如“下单扣库存”try_statusTry阶段状态0未执行、1已执行confirm_statusConfirm阶段状态0未执行、1已执行cancel_statusCancel阶段状态0未执行、1已执行payload业务上下文数据JSON格式create_time / update_time记录创建和更新时间每个TCC方法进来第一件事就是去查这个状态表根据tx_id查出记录判断当前阶段能否执行。这个表同时也承担着空回滚和悬挂判断的责任。实际落地时这张表的读写频率不高每个事务就几次不需要上什么高端的千兆存储普通MySQL实例就够了。但要注意状态表的数据会持续累积线上一定要定期清理过期数据否则一张几千万行的表会把所有查询拖垮。4. 最终一致性的正确姿势事务消息与本地消息表TCC解决了订单和库存强一致的需求但有些场景你其实不需要那么强的保证。比如用户下单后要发个通知、更新个积分、写个操作日志这些链路的即时一致性要求没那么高最终一致就足够了。这时候如果再上TCC纯属给自己找麻烦——每个下游服务都要写Try/Confirm/Cancel成本太高。4.1 事务消息方案RocketMQ的半消息机制RocketMQ的事务消息是我觉得最优雅的最终一致性实现。它的核心是“半消息”和“消息回查”。发送方先发送一条半消息这条消息对消费者不可见——这就是“半”的由来。消息发出去后发送方执行本地事务比如创建订单、写订单状态表本地事务成功就提交半消息让它变成对消费者可见本地事务失败就回滚半消息消息直接销毁。如果本地事务执行过程中进程挂了怎么办RocketMQ会启动回查机制定时反向询问发送方本地事务到底怎么样了发送方根据本地事务状态再告诉Broker提交还是回滚。这套机制把本地事务和消息发送绑定在一起保证“业务操作”和“消息发送”要么同时发生要么同时不发生。落到订单场景里流程是这样订单服务先发半消息→创建订单并置为“待支付”→提交消息→库存服务消费消息扣减库存。库存服务这边不需要把扣减和事务绑定因为消息投递本身就有重试机制消费失败会被不断重投直到成功。这里有一个容易搞错的地方很多同学以为发了事务消息就能保证下游一定成功。不是的事务消息只保证了消息本身不丢失、不重复在极端情况下仍可能重复见下不保证消费者执行成功。消费者这边必须做幂等否则处理重复消息时会有大问题。4.2 本地消息表没有MQ时的折中手段本地消息表的思路更朴素。在订单库里建一张消息表和订单表放在同一个数据库里。创建订单事务里同时插入一条“待发送”状态的消息记录——因为都在同一个库里这个操作天然就是原子的。之后一个异步任务扫描这张表把“待发送”的消息投递给MQ投递成功的就改成“已发送”。这套方案不需要MQ支持事务消息任何MQ都行甚至没有MQ直接调用下游HTTP接口也行。但代价是侵入性大每接一个下游就要建一张消息表消息的幂等处理也完全靠自己。虽然土但在一些老旧系统改造里这反而是最稳妥的过渡方案因为改动面最小且不依赖新基建。4.3 幂等消费最终一致方案的保命符事务消息也好本地消息表也好都解决不了同一个问题消息重复投递。RocketMQ是at-least-once语义也就是说一条消息可能被投递多次。如果消费者不做幂等扣减库存的消费逻辑被执行两次库存就减两次数据就不对了。所以最终一致性方案的铁律是消费端必须幂等。我在订单-库存场景里是这么设计的库存服务收到扣减消息后先根据消息里的订单号查一下本地操作记录表如果这个订单号已经处理过直接返回成功不再扣减。如果没处理过就执行扣减并记录操作记录。这里的关键是查记录和扣减必须在一个本地事务里否则还是会有并发重复处理的风险。很多文章只说“要幂等”但没说为什么查记录和扣减要在同一个事务里。真实原因是消息重投时如果有两条相同消息被两个不同的消费者线程同时处理两个线程都查到“没有处理记录”然后都去扣库存还是超卖。把查记录扣库存写记录放进同一个本地事务数据库的行锁会保证只有一个线程能成功另一个要么锁等待后看到已处理要么直接报错触发重试。另外幂等表里记得把业务唯一键比如订单号设置成唯一索引这是防并发重复的最后一道保险。5. 线上故障排查实录那些文档不会告诉你的坑方案讲完了来点实战排障的心得。做分布式事务这几年我整理了几个出现频率最高、也最具迷惑性的问题每个都是真实踩过坑后总结出来的。5.1 幂等表数据膨胀引发的架构级故障有一次线上突然出现大量订单状态卡在“已创建扣库存成功但下单失败”的死节点排查了半天发现是幂等表有一千多万条历史数据没清理导致每次查重都走全表扫描数据库CPU飙到90%。扣库存的SQL超时服务以为处理失败不断重试重试又不断查这张大表恶性循环。排查思路并不复杂。先用监控看数据库慢查询发现全是同一张表的查询。再看这张表数据量从三个月前的两百万涨到了一千多万索引也没办法优化因为查询条件已经走索引了瓶颈全在表体量。最后只能上定时任务把三个月前的过期数据批量清理掉再配合加索引问题才算解决。这件事告诉我们分布式事务方案里的每一张辅助表都要在一开始就设计好数据生命周期方案。因为很多辅助表特别是幂等表、状态记录表的写入频率跟着业务量走不像流水表那么有清晰的归档策略特别容易被人遗忘一旦积累到某个量级就成了隐形炸弹。5.2 隔离级别破坏导致TCC在特殊场景下失效TCC最常见的隔离性问题是Try阶段预留了资源但这个预留对其他事务不可见导致其他事务也认为资源可用做出错误的判断。举一个真实例子一个商品库存总量100件。业务设置了库存低于10件时系统自动下架。TCC的Try阶段预占库存40件但下架检查看到的仍是stock_available - frozen_stock还是60件即还没减预占系统觉得还有库存不下架。等用户把剩下60件买完发现实际库存可用量变负数了但下架逻辑一直没触发因为它的查询条件用的是未扣减前的数值。解决方法是所有需要读“实时可用量”的地方都必须记得把frozen_stock减掉或者直接查stock_available字段。说起来容易但业务代码里散落着各种直接查stock字段的老逻辑改起来非常耗时。这也是TCC侵入性的真实体现——不仅要写三段逻辑还要把业务上所有读库存的地方都拉通改造一遍。5.3 很隐蔽的“消息丢失后一直没意识到”事务消息方案最让人头疼的一个问题消息产生后因为消费失败被回退到重试队列如果重试队列积压严重或者消费者进程没起来消息就一直躺在重试队列里而订单服务早就返回用户“下单成功”了。用户钱付了但库存一直没扣直到人工对账或者用户投诉才发现。这种问题靠技术手段很难彻底杜绝时间长了总会遇到一两次最重要的是要有对账机制。我常用的做法是每天凌晨跑一个对账任务扫描订单表里“已支付但库存扣减记录不存在”的订单自动补发消息或者触发人工介入。另外一个贼好使的策略是监控MQ的消费堆积量和重试队列深度设置告警阈值一旦超过就第一时间拉群排查把隐患扼杀在早期。否则等用户投诉那会儿数据已经不知歪到哪里去了。做分布式事务这几年我最大的体会是很多问题的根源不是框架不够好而是方案和业务容忍度没对齐。强一致就多做些设计上的功夫最终一致就把兜底和监控做实别想着拿一套方案打天下。另外特别要提一点分布式事务的测试比实现更重要也更容易被忽视——测试环境一定要刻意模拟网络超时、进程kill、消息重投这些场景别让测试环境好得跟样板间似的不然线上一定会给你上一课。