上周有个同事跑来问我:订单服务和库存服务拆开之后,用户下单成功,订单状态显示已支付,库存却扣了两次,数据库事务到底还能不能保证一致性?这个问题背后牵扯出来的东西,恰恰就是分布式事务的核心。我在订单、支付、库存这条链路里摸爬滚打了几年,主流方案基本都实践过一遍,今天就想用大白话把"分布式事务"这件事讲透——它到底解决什么问题、有多少种解法、每种解法适合什么场景、落地时有哪些坑。
这篇文章不求覆盖所有理论细节,重点放在"能落地"三个字上。适合正在做微服务拆分、或者刚接手订单类系统的同学,哪怕你对分布式事务只有模糊概念,读完也能建立一张完整的认知地图。
1. 不是"事务不行了",是事务的边界变了
1.1 本地事务为什么能保证一致性
在单体应用时代,用户下单、扣库存、生成订单详情,这些操作都在同一个数据库里完成。一个事务就能把"插入订单 + 扣减库存 + 更新用户余额"三个操作包起来,要么全部成功,要么全部回滚。
这个机制依赖数据库的 ACID 特性:原子性保证操作不可分割,一致性保证数据约束不被破坏,隔离性保证并发事务互不干扰,持久性保证提交后数据不丢。数据库底层靠的是行级锁、undo log、redo log 和事务提交协议,这套东西已经非常成熟,你基本不需要自己操心。
但这里有个隐含前提,所有参与事务的数据必须落在同一个数据库实例上。一旦把订单表和库存表拆到两个库,甚至拆成两个独立服务,本地事务的边界就断了——第一个库里的事务提交成功,第二个库里的事务提交失败,没有任何一个数据库实例能感知全貌,回滚动作自然也无从谈起。
1.2 服务拆分后,"原子性"为什么失效了
拆服务之后,一次下单操作被拆成三个远程调用:
- 订单服务创建订单
- 库存服务扣减库存
- 如果涉及优惠券,还要调用券服务标记已使用
每一个远程调用都有网络延迟和失败概率。最常见的问题是:订单创建成功,但调用库存服务时网络超时,库存服务实际扣了库存,可订单服务这边收到的是异常,直接选择回滚。结果就是订单没了,库存少了,两边数据对不上。
这个场景,就是分布式事务要解决的"跨服务、跨数据库的一致性"问题。它是微服务拆分带来的副产品——你享受了独立部署、独立扩容的好处,就得接受分布式事务这个成本。
我见过不少团队在拆分之初没认真对待这个成本,等到线上出现"超卖"或者"扣款不到账"才回头补课。补课的代价,比一开始设计要高出好几倍。所以说,分布式事务不是一个"听着高级"的技术,而是一个你早晚要面对的工程问题。
2. 一致性不是"绝对一致",而是"你能接受多大偏差"
2.1 CAP的取舍,比想象中更严格
聊分布式事务,绕不开 CAP 理论。它说的是:在分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)三者最多同时满足两个。
很多人把 CAP 理解成"三选二",其实不够准确。分区(网络分裂)是分布式系统中的必然事件,不是可选项,所以 P 是必须有的。真正能选的是:当网络发生分区时,你要保 C 还是保 A。
- 保 C:牺牲可用性,比如两阶段提交,分布式事务执行期间相关资源全部锁定,宁可系统短暂不可用,也要保证数据绝对一致。
- 保 A:牺牲一致性,比如各服务自己提交,先返回用户成功,之后通过补偿机制慢慢对齐,这就是最终一致性的思想。
这里有个关键认知:强一致性不等于"正确",它只是"立即正确"。最终一致性也不等于"错误",它只是"最终正确",但中间会有一个时间窗口,数据处于中间态。
很多业务场景其实接受不了强一致性的代价。拿下单来说,扣库存这种事如果非得等所有服务同步提交,用户点击下单可能要卡几百毫秒甚至更久,这在高并发场景下根本扛不住。所以现实世界的做法往往是"核心链路尽量强一致,非核心链路接受最终一致"。
2.2 BASE理论:最终一致性不等于放弃一致性
BASE 是 Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致)的缩写。它是对 CAP 中 AP 路线的实践总结。
基本可用意思是系统出现故障时允许降级,比如下单高峰时段把"查看历史订单"功能切成只读缓存,但核心支付流程不能挂。软状态意思是允许数据在某个时间窗口不一致,比如订单已生成、库存还没扣成功,这个中间状态是合理且必须容忍的。最终一致意思是只要系统持续运行,经过重试、补偿、对账,所有副本最终会收敛到一致。
理解 BASE 的关键,是明白它没有放弃一致性——它只是放宽了一致性的时间约束,把"提交那一刻必须一致"改成"在一段时间内自动达到一致"。
而这个"一段时间",就是分布式事务方案设计时的核心参数。你允许系统在多少秒内达到一致,直接决定了你会选哪套方案。
3. 四大主流方案:从强一致到最终一致,分别适合什么场景
3.1 两阶段提交(2PC):协调者视角的全局锁
2PC 是最经典的强一致性方案,Google 的 Spanner 和传统的 XA 协议都是这个思路。核心角色是一个协调者(Coordinator)和若干参与者(Participant),流程分两阶段。
第一阶段叫准备阶段(Prepare):协调者向所有参与者发送准备请求,参与者执行事务操作,写入 undo/redo 日志,但不提交,然后回复"我可以提交"或"我要回滚"。
第二阶段叫提交阶段(Commit):协调者收集所有参与者的回复,如果全部同意,就广播"提交"指令;任何一个参与者拒绝,就广播"回滚"指令。
这套机制的问题是"同步阻塞"和"协调者单点"。
- 同步阻塞:参与者持有资源锁期间,其他事务无法访问这些数据。如果某个参与者执行得很慢,整个分布式事务就卡住,系统吞吐量被这个最慢的节点拖死。
- 协调者单点:如果协调者在广播提交指令前宕机,所有参与者既不知道提交还是回滚,又不敢释放锁,数据库连接池很快被占满,形成雪崩。
我在实际项目中很少看到团队自研 2PC,更多是依赖 XA 协议的数据库中间件。但它有一个致命短板:事务执行时间长,锁竞争激烈,高并发订单场景根本用不起。所以 2PC 更适合银行转账、金额结算这类并发量低、但对一致性要求极高的场景。
3.2 三阶段提交(3PC)与它的改进逻辑
3PC 是 2PC 的改良版,核心改进是把 2PC 的"准备"阶段拆成了 CanCommit(询问是否可以提交)和 PreCommit(预提交)两个阶段,同时引入超时机制,参与者不再无限期等待协调者指令。
优点很明显:降低了阻塞范围,参与者可以在超时后主动释放资源,协调者挂掉的影响被缩小。但代价是引入了新的不一致窗口——如果网络分区发生,可能有的参与者收到了提交指令,有的没收到,两边执行结果不一样。
说实话,3PC 在理论界讨论得多,在工程界用得少。它治好了 2PC 的"阻塞病",但引入了新的"脑裂病",并没有本质上的优势。所以我在选型的时候基本不会考虑它,提到它只是为了让你知道,业界不是没尝试过改良 2PC,只是这条路没有彻底走通。
3.3 TCC:把"锁"换成"业务补偿"
TCC 是 Try、Confirm、Cancel 三个词的缩写。它把一笔分布式事务拆成三个阶段,由业务方自己定义每一阶段的行为,框架只负责协调调用。
- Try 阶段:冻结资源,但不真正扣减。比如下单时,先冻结库存 1 件,冻结优惠券 1 张。此时用户还没真的下单成功,但资源已经被预留。
- Confirm 阶段:冻结成功后执行真正的业务逻辑,比如把冻结的库存扣减掉,把订单状态改成已确认。
- Cancel 阶段:如果某个环节失败,把 Try 阶段冻结的资源全部释放。
TCC 的最大优势是把资源锁粒度从"数据库行锁"降到了"业务状态"。Try 阶段不持有数据库锁,只是标记"这 1 件库存被预占了",其他事务还能读取库存总量,并发能力比 2PC 高得多。
但 TCC 对业务侵入极强,每个参与方都得实现三套逻辑:Try、Confirm、Cancel。而且 Cancel 逻辑本身要正确,否则资源解冻失败,库存永久性减少。我见过不少团队 TCC 落地的失败案例,问题往往出在 Cancel 逻辑和业务状态机冲突上——比如订单已经取消,但 Cancel 又被重复调用,导致库存被释放两次。
Seata 是 Java 生态里最常用的 TCC 框架之一。它支持 AT 模式(自动补偿,框架通过解析 SQL 生成反向 SQL 实现回滚)和 TCC 模式(业务方手动实现)。AT 模式上手快,但性能开销比 TCC 更大,因为框架要额外记录数据前后镜像。我的经验是:对性能敏感的核心链路用 TCC 模式,对开发速度要求高的内部系统用 AT 模式。
3.4 SAGA:长流程切短,每一步都有后悔药
SAGA 是另一种补偿思路,最早来自 1987 年的一篇数据库论文。它的核心是把一个长事务拆成一系列有序的本地子事务,每个子事务都有一个对应的补偿事务。如果某个子事务失败,就逐个执行前面所有子事务的补偿动作,回滚到初始状态。
一个典型的 SAGA 调用链长这样:
- 订单服务创建订单
- 支付服务完成扣款
- 库存服务扣减库存
- 物流服务创建配送单
如果第 4 步失败,就依次执行第 3 步的"库存回补"、第 2 步的"退款"、第 1 步的"取消订单"。
SAGA 有两种编排方式:
- 编排式(Orchestration):一个中心协调者负责按顺序调用各参与者,清晰可控,但协调者本身成为瓶颈。
- 协同式(Choreography):各服务通过事件消息互相驱动,比如订单服务发"订单已创建"事件,支付服务监听后扣款,再发"已支付"事件。这种方式去中心化,但调用链像蜘蛛网一样难以追踪。
我在订单系统里更推荐编排式。业务链路越长,一个中心化的流程管理器越有价值——你在支线故障时可以暂停整个流程,可以重跑某个失败的子事务,这些操作在协同式里都繁琐得多。
SAGA 和 TCC 最大的区别是:TCC 在 Try 阶段预留资源,Cancel 阶段释放资源;SAGA 没有预留概念,直接执行真实业务操作,失败后靠补偿事务反悔。这也导致 SAGA 的补偿动作必须足够健壮,因为它面对的是已经真实发生的数据变更。
3.5 本地消息表 + 事务消息:用时间差换一致性
这是一类非常务实的方案,核心思想是把"跨服务的分布式事务"降级为"本地事务 + 消息可靠投递"。
本地消息表的做法是:在业务数据库中建一张消息表,业务操作和消息写入放在同一个本地事务里。订单服务创建订单的同时,往消息表插入一条"扣减库存"的消息。事务提交后,一个异步任务轮询消息表,把消息发送到 MQ,库存服务消费后扣库存,再回写消息状态。
这套方案的好处是:依赖最少,只要一个数据库和一台 MQ 就能实现。缺点是:轮询有延迟,消息表会膨胀,需要定期清理;而且业务方要自己处理"消息发送失败""消费失败重试"等问题。
事务消息是 RocketMQ 提供的原生能力,把上面这套逻辑做成了中间件。流程是:
- 发送 half 消息(半消息)到 RocketMQ
- 本地事务执行
- 事务执行成功后,向 MQ 发送 commit;失败则发送 rollback
- MQ 如果长时间没收到 commit/rollback,会反查事务状态
这套方案最大的价值是解耦。主服务不需要关心消费方是否成功,只需要保证"消息一定能到 MQ"。消费方自己保证幂等,失败就重试。
4. 方案选型:别再"一招鲜吃遍天"
4.1 先判断你需不需要分布式事务
这是我最想强调的一点。很多团队一上来就纠结"用 TCC 还是 SAGA",其实第一步应该问:这个场景真的需要分布式事务吗?
有几种情况其实可以绕开:
- 数据不需要实时一致:比如用户修改昵称,个人中心和订单中心各存了一份,允许 10 秒内不一致,这种直接用 MQ 异步同步即可。
- 操作可以串行化:最终一致性靠"下游必成功"和"幂等重试"兜底,省掉复杂的回滚逻辑。
- 把写操作收敛到同一个服务:比如所有库存变更都走库存服务暴露的接口,订单服务不直接操作库存表,这样至少数据库层面没有跨库问题。
分布式事务是成本最高的一致性方案,能不用就不用。它的复杂度不会消失,只会转移:要么在编码时以 TCC 的三段式逻辑呈现,要么在运维时以各种补偿脚本呈现。
拿我负责的订单系统举例,我们最终只在"用户下单 + 扣库存 + 扣优惠券"这条核心链路上用了分布式事务,像"订单同步到搜索服务""发送通知短信"这类操作,全部走异步消息 + 重试,没有任何事务语义。
4.2 各方案横向对比
为了让你一目了然,我整理了一张对比表:
| 方案 | 一致性强度 | 性能影响 | 业务侵入 | 复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 2PC | 强一致 | 大(资源锁) | 低(数据库级别) | 中 | 跨行转账、低并发资金操作 |
| TCC | 强一致 | 中(无锁) | 高(三段式逻辑) | 高 | 订单冻结库存、支付预扣款 |
| SAGA | 最终一致 | 小 | 中(补偿逻辑) | 中 | 长流程业务、多系统调用链 |
| 本地消息表 | 最终一致 | 小 | 低 | 低 | 内部系统数据同步 |
| 事务消息 | 最终一致 | 小 | 低 | 低 | 订单状态变更通知 |
注意,TCC 和 SAGA 虽然都算"强一致"或"最终一致",但它们的差异更多体现在业务语义上。TCC 适合"资源操作型"业务(扣库存、扣余额),SAGA 适合"流程推进型"业务(创建订单、发货、签收)。前者关注预留与释放,后者关注推进与回退。
4.3 混合架构才是正常状态
严格来说,一个复杂系统里往往不是只有一种方案。我现在的订单系统就是"三套方案并存":
- 下单主链路的扣库存、扣券用 TCC
- 支付回调后的订单状态流转用 SAGA
- 订单完成后的数据同步到数仓和搜索,用事务消息
每套方案解决自己对应场景的问题,而不是尝试用一方案打天下。这种混合架构,才是生产级的常态。
5. 订单-库存-优惠券链路的完整设计实例
5.1 场景与业务约束
假设你现在要设计一个下单接口,涉及三个服务:订单服务、库存服务、优惠券服务。业务逻辑是:
- 用户从购物车提交订单,订单服务创建待支付订单
- 库存服务冻结对应商品库存
- 优惠券服务标记用户优惠券为"已使用"
约束条件有三个:不能超卖(库存冻结数不能超过真实库存)、优惠券不能重复使用、用户取消订单后所有占用资源要释放。
5.2 方案设计:TCC + 事务消息的组合拳
核心链路我们选择 TCC 方案。三个服务按如下方式实现各阶段逻辑:
Try 阶段:
- 订单服务:插入一条状态为 "TRYING" 的订单记录
- 库存服务:执行 UPDATE inventory SET frozen_qty = frozen_qty + 1 WHERE id = ? AND qty - frozen_qty >= 1,如果影响行数为 0,说明库存不足,抛出失败异常
- 优惠券服务:执行 UPDATE coupon SET status = 'LOCKED' WHERE id = ? AND status = 'UNUSED',同样通过条件更新保证不重复使用
Confirm 阶段:
- 订单服务:把订单状态从 "TRYING" 更新为 "CONFIRMED"
- 库存服务:执行 UPDATE inventory SET qty = qty - 1, frozen_qty = frozen_qty - 1,把冻结转成真实扣减
- 优惠券服务:把优惠券状态从 "LOCKED" 更新为 "USED"
Cancel 阶段:
- 订单服务:把订单状态标记为 "CANCELLED"
- 库存服务:执行 UPDATE inventory SET frozen_qty = frozen_qty - 1,回补冻结
- 优惠券服务:把优惠券状态从 "LOCKED" 更新为 "UNUSED"
这三个阶段都由 TCC 框架统一调度,任何一个 Confirm 失败,框架会自动触发所有参与方的 Cancel。
但这里有个容易被忽略的问题:Confirm 阶段也可能因为网络异常而被重复调用。所以每个参与方的 Confirm 逻辑必须天然幂等。比如库存服务 Confirm 的 SQL 改成:
UPDATE inventory SET qty = qty - 1, frozen_qty = frozen_qty - 1 WHERE id = ? AND frozen_qty >= 1这样即使 Confirm 被调用两次,第二次因为 frozen_qty 已经不满足条件,影响行数为 0,不会再次扣减。
链路执行完之后,把"订单已创建"事件发送到 MQ,通知下游服务(比如搜索服务、推荐服务)做异步数据同步,这部分就用事务消息。
5.3 幂等、重试与对账:方案落地三件套
做完上面的设计,只完成了 60% 的工作。剩下的 40% 在于三个细节:幂等、重试、对账。
幂等的核心是每个参与者都要有全局唯一的事务 ID。TCC 框架会把这个事务 ID 透传给所有参与方,参与方在 Try/Confirm/Cancel 至少执行一次的前提下保证执行结果一致。最简单的方式是建一张 t_transaction_record 表,以事务 ID 为主键,每次执行前先插入,插入成功才执行对应动作:
INSERT INTO t_transaction_record (tx_id, service_name, phase, status) VALUES (?, ?, 'TRY', 'SUCCESS') ON DUPLICATE KEY UPDATE status = status;重试就靠这个记录表:确认某个服务 Confirm 失败后,由 TCC 框架按指数退避策略重试,比如第 1 次 1 秒后重试、第 2 次 2 秒后、第 3 次 4 秒后,最多重试 5 次。重试期间要保证请求幂等,前面的事务记录表就是干这个用的。
对账则是最后的兜底。即使 TCC 框架和重试逻辑都正常,仍然可能出现脏数据——比如某次 Cancel 逻辑本身抛了异常,导致库存冻结数一直挂着。所以我们每天凌晨跑一个对账任务,对比订单状态、库存冻结数、优惠券状态,找出不一致的记录,自动生成补偿工单。对账这个步骤不能省,它是所有一致性方案的最后防线。
6. 生产环境里的真实教训:那些文档上不写的事
6.1 空回滚和悬挂,比超时更隐蔽
TCC 落地时最容易踩的两个坑是"空回滚"和"悬挂"。
空回滚指的是:Try 阶段没执行成功(比如网络超时),但 Cancel 阶段被调用。如果 Cancel 里直接执行回滚逻辑,可能把根本不存在的冻结记录硬生生扣掉,造成数据错乱。解决办法是:Cancel 执行前先查本地事务记录表,确认 Try 已经成功过才执行回滚;如果 Try 还没执行,记一条"跳过"标记,等 Try 乱序到达时直接拒绝。
悬挂指的是:由于网络乱序,Cancel 先到了,Try 后到。此时订单已经取消,但 Try 却成功冻结了资源,这个资源永远没人释放。解决办法是:Try 执行前检查事务记录,如果发现 Cancel 已经执行过,Try 直接返回成功但不做任何操作。
这两个坑的本质,是分布式调用不能保证有序性。写 TCC 代码时,一定把"Try 先到还是 Cancel 先到"当成一个必须处理的正常分支,而不是异常分支。
6.2 消息重复不是异常,是常态
用事务消息方案时,消费端必须假设同一条消息会被投递多次。原因很多:生产者重发、MQ 消费端网络超时导致 MQ 重新投递、消费者处理成功后还没来得及返回 ACK 就宕机。
处理方式只有一个:消费逻辑必须幂等。而且幂等的判断不能依赖"我判断一下状态再决定是否处理",必须用"先占坑再处理"的模式。比如消费"订单已创建"消息更新搜索索引,处理前先往 t_mq_consume_record 表插入消息 ID,插入成功才执行更新,插入失败说明已经处理过,直接跳过。
实际操作中我见过不少团队把幂等写成"先查再插",两个请求并发时全查不到,然后都执行了处理逻辑。正确的做法是利用数据库唯一索引做插入,用插入是否成功来判断是否重复。
6.3 对账任务才是"硅基防线"
最后想说的是:再完美的运行时方案,也会有漏网之鱼。线下对账不是可选项,而是必选项。
我们团队的对账策略是这样:每天凌晨跑一次全量对账,比对订单表、库存流水表、优惠券使用记录,找出"订单已确认但库存冻结记录还在""优惠券状态已使用但订单状态为取消"等异常数据。对账脚本本身用简单的定时任务实现,不依赖任何分布式框架,它要的就是一个"慢而全"。
对账发现异常后,不自动修复,只生成告警工单,由值班人员人工判断处理方式。自动修复听着高效,但一旦修复逻辑写错,可能把本来就乱的数据改得更乱。人工兜底虽然慢,但稳。
我踩过最惨的一次坑,就是上线初期对账任务没覆盖"优惠券冻结未释放"的场景,结果大促结束后一查,几千张优惠券被永久锁定,用户投诉电话直接打爆。自那以后,我给自己定了个规矩:任何分布式事务方案上线之前,必须先写好对账脚本——不为别的,就为晚上能睡得踏实。
分布式事务没有银弹。你选的不是"最好的方案",而是"代价最可控的方案"。想通这一点,很多纠结自然就解开了。