分布式事务扫盲
分布式事务扫盲
从银行转账的 ACID 开始,到 2PC / Saga / TCC 三种方案的本质差异,再到 TCC 协调者的空回滚、不等长超时等工程细节,最后落地到 MySQL 里一行 SQL 实现”冻结”——分布式事务的完整图景。
一、从银行转账说起
你去 ATM 给朋友转账 100 元,银行系统做了两件事:
你的账户 -100
朋友的账户 +100这两件事必须要么全做,要么全不做。不能出现”钱扣了但朋友没收到”的中间状态。这就是事务。
数据库用 ACID 描述事务的四个特性:
| 特性 | 含义 | 转账例子 |
|---|---|---|
| Atomicity | 原子性-不可分割 | 扣款和入账必须一起完成或一起取消 |
| Consistency | 一致性-数据满足规则 | 转账前后总金额不变 |
| Isolation | 隔离性-并发互不干扰 | 别人同时转账不影响你 |
| Durability | 持久性-完成后不丢失 | 成功后重启机器钱也不会丢 |
二、从本地事务到分布式事务
如果”你的账户”和”朋友的账户”在同一个 MySQL 库里,很简单:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE uid = 123;
UPDATE account SET balance = balance + 100 WHERE uid = 456;
COMMIT;
-- 出错了就 ROLLBACK数据库自己保证原子性,这叫本地事务。
但现实是,你的账户在用户服务,朋友的账户在商户服务,各有各的数据库:
用户服务 (MySQL-A) 商户服务 (MySQL-B)
你的账户 -100 朋友账户 +100没有一个数据库能同时管两个库。 于是问题来了:用户服务扣款成功了,调商户服务时网络超时了。钱扣了但没到账——这就是分布式事务要解决的问题。
分布式事务的难点集中在这四个地方:
| 难题 | 说明 |
|---|---|
| 网络不可靠 | 消息可能丢失、延迟、重复 |
| 服务会宕机 | 任何参与者随时可能挂掉 |
| 全局锁代价极高 | 不能像单机那样低成本锁所有资源;2PC 的锁跨网络、跨服务,代价高且易死锁 |
| 部分失败 | 有些分支成功有些失败,状态不一致 |
三、三种方案,三种思路
业界演进出了三类方案,分别代表三种不同的解题思路。
2PC(两阶段提交)——数据库帮你管
XA 协议,数据库层面原生支持:
阶段1(准备): 协调者问所有参与者"准备好了吗?"
参与者锁定资源,回复"好了"或"不行"
阶段2(提交): 全好了 → "提交!" / 有人不行 → "回滚!"优点是强一致、无需改业务代码。缺点也很致命:准备阶段锁资源,协调者单点故障会导致资源长时间锁定,高并发场景基本不可用。
Saga —— 做错了就改
把一个大事务拆成一串小事务,每个小事务都配一个补偿操作:
步骤1: 创建订单 ──→ 补偿1: 取消订单
步骤2: 扣减库存 ──→ 补偿2: 恢复库存
步骤3: 扣减余额 ──→ 补偿3: 退还余额思路是”先干了再说,出错了退回去”。适合长流程(比如订单→支付→发货→签收),但补偿逻辑写起来重——取消订单不是”改个状态”那么简单,可能涉及优惠券回退、积分扣减、库存恢复时的并发竞争。
TCC(Try-Confirm-Cancel)——先占坑,再说
业务层实现的两阶段。核心是把”直接操作”变成”预留 + 确认”两步:
Try: 冻结余额(钱还在,但不能用)
Confirm: 真正扣款
Cancel: 解冻余额(恢复可用)思路是”先占坑,确认了再真干”。适合资金、库存这类”冻结几乎零成本”的场景。
三者的差异本质上是”对资源的控制方式不同”:2PC 是数据库锁,Saga 是事后补偿,TCC 是提前预留。各有各的代价。
四、先别急——你真的需要分布式事务吗?
分布式事务是一个重武器。它引入了一个中心化协调者、额外的网络调用、复杂的状态机和超时补偿后台。在决定用它之前,先过三个问题。
问题一:操作 B 失败真的不可接受?
注册送优惠券:注册成功,送券失败 → 无所谓,下次登录补发就行
支付发短信: 扣款成功,短信没发 → 不重要,丢了就丢了如果操作 B 失败并不致命,不需要分布式事务。一个 try-catch + 日志告警就够了。
问题二:上游重试 + 下游幂等行不行?
只有一个下游的时候,通常不需要分布式事务:
订单服务 → 调用 → 库存服务.deduct(orderId, 5)
库存服务以 orderId 做幂等键:同一个订单号,重复扣只生效一次。
订单服务超时了就重试——幂等保证不超扣。只有一个下游、且下游能做幂等,不需要分布式事务。
如果不想用同步 RPC + 重试,也可以用消息队列做同样的事——上游发消息,下游消费。MQ 负责可靠投递,下游同样需要幂等。本质是同一个逻辑的两种实现方式:同步重试 vs 异步解耦。选哪个取决于你对延迟的敏感度和对系统耦合度的容忍度。
幂等的关键细节:下游不仅要”同一订单号不重复处理”,更要返回首次成功的结果。如果第一次扣款成功,第二次重试时因为余额变动又查了一次余额发现不够了返回失败——上游就会把一次成功的交易误判为失败。正确的做法是用
INSERT+ 唯一索引挡重复请求,插入失败说明已有记录,直接返回首次结果,不再执行业务逻辑或重新查余额。
问题三:本地事务 + 异步补偿行不行?
1. 订单服务在自己的数据库事务里"创建订单 + 写一条待同步 ERP 的记录"
2. 后台 Job 定时扫描待同步记录,推送给 ERP
3. 推送失败就重试
ERP 同步是异步的——只有一个数据库事务(订单服务自己的),推送是后台慢慢追的。如果操作 B 可以延迟执行、重试一定能最终成功,不需要分布式事务。
“重试一定能成功”是个危险的假设。 现实中 ERP 服务可能挂了 3 小时,或者表结构变了导致 SQL 永远失败。异步补偿表必须记录重试次数和最终错误码。超过阈值(如 10 次)停止重试,改为告警 + 人工介入。技术上”最终一致”需要业务上”人工对账”兜底——如果在这点上欺骗自己,上线后半夜会被电话打醒。
什么时候才真的需要?
只有同时满足这四个条件:
| 条件 | 例子 |
|---|---|
| 多个资源必须原子性 | 两个账户余额必须同时动 |
| 无法单靠重试 | 操作不可逆(钱转出去了退不回来) |
| 中间状态不可接受 | 用户余额扣了但对方没收到的投诉 |
| 补偿操作有副作用或不可逆 | 退款触发风控、库存释放后被其他订单抢走导致补偿失败 |
决策流程:
多个服务/数据库?
├── 否 → 本地事务就够了
└── 是 → 操作B失败致命?
├── 否 → 异步补偿
└── 是 → 只有一个下游?
├── 是 → 幂等 + 重试
└── 否 → 需要分布式事务 ← 继续往下读五、Saga vs TCC,选哪个?
上一节的结论是”你需要分布式事务”。现在的问题是:Saga 还是 TCC?要回答这个问题,得先理解它们本质上的轻重之分。
重在哪里
对比一下同一个”转账”场景,两种方案 Cancel 阶段做的事:
TCC Cancel:
解冻余额 → 一行 UPDATE,零业务逻辑
Saga Compensate:
退款 → 发起一笔反向转账
→ 可能涉及手续费计算
→ 可能涉及优惠券回退
→ 可能触发风控规则
→ 必须幂等TCC 的 Cancel 是”释放预留”,Saga 的 Compensate 是”反向执行业务逻辑”。前者是一行代码,后者是一个功能模块。 这是 TCC 比 Saga 轻的根本原因。
再看超时处理。Saga 的步骤是真操作(钱转了),如果步骤超时,你不知道操作成了没——补偿还是不补偿?所以 Saga 强制要求每个下游提供幂等查询接口,超时后先查再决定。
TCC 的 Try 只是冻结,超时了重试 Try 即可,没有副作用——不需要查询接口。
| 维度 | TCC | Saga |
|---|---|---|
| Cancel/Compensate 复杂度 | ★(解冻) | ★★★(真实业务逻辑) |
| 额外接口要求 | Try, Confirm, Cancel | Execute, Compensate, Query |
| 超时处理 | 重试 Try,无副作用 | 必须先查询才能决策 |
| 接入心智负担 | 低 | 高 |
所以:能 TCC 就别 Saga
既然 Saga 更重,自然的思路是:如果能把一个”Saga 场景”改造成”TCC 场景”,那就改。下面看三种常见改造手法。
手法一:串行改并发
❌ Saga:
step 1: 调支付扣用户余额(真实转账)
step 2: 加商户余额(真实入账)
✅ TCC:
Try: 并发 → 冻结用户余额 + 预占商户额度
Confirm: 并发 → 真扣 + 真入账
Cancel: 并发 → 解冻 + 释放关键前提:操作能拆成”预留 + 确认”。 大多数 CRUD + 余额操作都能拆——创建订单可以”预创建”(status=frozen),扣库存可以”预占”。
手法二:TM 提前解决数据依赖
❌ Saga:
step 1: 创建订单 → 返回 orderId
step 2: 用 orderId 调支付 → 返回 paymentId
step 3: 用 paymentId 调发货
✅ TCC:
TM 在 Try 之前自己生成 orderId、paymentId
Try 1: 订单服务用预生成的 orderId 预创建
Try 2: 支付服务用预生成的 paymentId 冻结额度
→ 两个 Try 可以并发!数据依赖不代表执行依赖。TM 提前组装好所有 ID,串行变并发。
手法三:外部不可逆接口包一层预留表
❌ Saga:
调第三方物流"下单取件"——调了就是调了,不可逆
✅ TCC:
自己库建 delivery_reservation 表
Try: INSERT status=frozen(预约取件时段)
Confirm: 调物流接口 + UPDATE status=confirmed
Cancel: UPDATE status=cancelled(不动物流接口)成本是加一张表,但换来 Cancel 零副作用。
但这种”包一层”有一个陷阱:如果 Confirm 阶段调物流接口超时(物流公司那边其实已经接单了),协调者执行 Cancel,你把自己库里的状态改成了 cancelled——但物流公司照样会派人取件。对外部不可逆接口做 TCC 封装,Cancel 必须是调外部取消接口成功后才能改本地状态,而不是直接改。 如果外部接口本身不支持取消,这个场景就不适合 TCC,老老实实走 Saga + 超时后查单。
什么时候确实不能 TCC
三种手法覆盖了大多数场景,但有些情况确实不行:
- 有外部不可逆的副作用:短信、邮件、Push 通知。发了就收不回来。解法是把副作用移到事务成功后异步做。
- 外部系统只提供一个接口:银行只给了
transfer(),没有”冻结”。只能 Saga。 - 人在回路:审批流程不能”冻结”一个人的决策。但这种场景通常用工作流引擎更合适,Saga 也有点杀鸡用牛刀。
六、实战:用 MySQL 实现 TCC 的”冻结”
前面一直在讲”怎么选”。这一节落地——选 TCC 之后,具体怎么”冻”。
“冻结”在存储层的实现有三种流派:
| 流派 | 做法 | Try 做的事 | 适用前提 |
|---|---|---|---|
| A(预扣库存) | Try 真扣,Cancel 补回去 | 真实扣减 | 下游有幂等退款/撤销接口 |
| B-1(两字段 + 版本 CAS) | 读后写 + 版本号守卫 | 冻结字段 | 存储至少支持等号 WHERE |
| B-2(三字段 + 相对更新) | 一次原子操作 | 一行 SQL | 存储支持 SET += 和 WHERE >= |
MySQL 天然支持 B-2,往下看。
数据模型
CREATE TABLE t_balance (
lUid BIGINT PRIMARY KEY,
lAvailable BIGINT NOT NULL DEFAULT 0, -- 可用余额
lFrozen BIGINT NOT NULL DEFAULT 0, -- 冻结额
lBalance BIGINT NOT NULL DEFAULT 0, -- 总额
-- 不变量: lAvailable + lFrozen = lBalance
);Try(冻结)——一行 SQL
UPDATE t_balance
SET lAvailable = lAvailable - ?,
lFrozen = lFrozen + ?
WHERE lUid = ?
AND lAvailable >= ?; -- 原子超卖防护affected_rows = 0 → 余额不足。MySQL 的 UPDATE SET x = x - ? WHERE x >= ? 原子执行——没有读后写窗口,不需要版本号。
Confirm(确认扣减)
UPDATE t_balance
SET lBalance = lBalance - ?,
lFrozen = lFrozen - ?
WHERE lUid = ?
AND lFrozen >= ?;Cancel(解冻)
UPDATE t_balance
SET lAvailable = lAvailable + ?,
lFrozen = lFrozen - ?
WHERE lUid = ?
AND lFrozen >= ?;Recharge(充值)
UPDATE t_balance
SET lBalance = lBalance + ?,
lAvailable = lAvailable + ?;
WHERE lUid = ?
-- 不需要其他 WHERE 条件,充值总是合法的四句 SQL,一个 TCC 余额系统。 关键在 SET x = x - ?——它是相对更新(告诉 MySQL “在当前值上减”,而不是”设成某个我算好的值”),配合 WHERE >= 的原子条件检查,消除了读后写窗口。
为什么这一行 SQL 比”读-判-写”强一万倍
新手很容易写出这样的乐观锁代码:
# ❌ 常见错误:应用层读-判-写
def try_freeze_bad(uid, amount):
row = db.query("SELECT lAvailable FROM t_balance WHERE lUid = ?", uid)
if row.lAvailable < amount:
raise InsufficientBalance()
db.execute("UPDATE t_balance SET lAvailable = ? WHERE lUid = ?",
row.lAvailable - amount, uid)这段代码看起来没问题,实际上有两个坑。
第一个坑:读后写窗口(Lost Update)
两个并发请求同时读到 lAvailable = 500,都算出 SET lAvailable = 400,先后执行。第二个 UPDATE 把第一个的结果覆盖了——实际扣了 200,但余额只减了 100。这就是 lost-update。
加版本号也治标不治本:
# ⚠️ 加了版本号,但还有第二个坑
def try_freeze_with_version(uid, amount):
while True:
row = db.query("SELECT lAvailable, iVersion FROM t_balance WHERE lUid = ?", uid)
if row.lAvailable < amount:
raise InsufficientBalance()
affected = db.execute(
"UPDATE t_balance SET lAvailable = ?, iVersion = iVersion + 1 "
"WHERE lUid = ? AND iVersion = ?",
row.lAvailable - amount, uid, row.iVersion)
if affected > 0:
return
# 版本号变了,重试版本号解决了 lost-update(第二个 UPDATE 会因为 WHERE iVersion 不匹配而失败重试),但乐观锁还有两个问题。
第二个坑:ABA 问题
有些存储(如某些 KV 引擎)的版本号字段只有 8 位(0-255)。当一行数据被高频更新时,版本号会绕圈:
T1 读到 lAvailable=500, iVersion=5
期间发生了恰好 256 次更新,iVersion 5→6→...→255→0→1→...→5
T1 的 UPDATE WHERE iVersion=5 → 成功!
但 lAvailable 可能已经被改过很多次,最终值未必是 500
T1 却用"500 - amount"覆盖了当前值这就是 ABA:版本号绕了一圈回到原值,CAS 误判为”没被改过”。8 位版本号下,单行 256 次更新就能绕回——秒杀场景单个 SKU 的 QPS 轻松破 10 万,绕回窗口可能只有几毫秒。即使你的数据库版本号是 32 位,ABA 在理论上永远存在,只是概率大小的区别。
第三个坑:重试风暴
即使版本号不会绕圈(可以把版本号定为64位整型,绕回窗口比宇宙寿命还长),乐观锁仍然需要 while 循环。每次冲突 → 一次多余的数据库往返。热点行(如秒杀 SKU)上大量事务竞争同一行,大量 CAS 冲突 → 大量重试 → 数据库 CPU 飙升。相对更新把整个”读-比-写”压缩进存储引擎内部的一次锁持有——零重试,零额外往返。
MySQL 的相对更新为什么没有这些问题:
UPDATE t_balance
SET lAvailable = lAvailable - ?, -- ← 告诉 MySQL "减掉这么多",不是"设成 400"
lFrozen = lFrozen + ?
WHERE lUid = ?
AND lAvailable >= ?;- 没有读后写窗口:读、比较、写都在存储引擎内部的一次锁持有内完成,中间插不进任何操作。
- 没有 ABA:根本不需要版本号——
SET lAvailable = lAvailable - 100使用的是当前值,不是某个之前读到的值。 - 没有重试循环:一行 SQL,
affected_rows判断结果,不需要 while 循环。
一句话:相对更新把”读取当前值”和”写入新值”合并成一次存储引擎内部操作,消除了读-判-写之间的所有并发问题。如果你的存储支持
SET x = x - ? WHERE x >= ?,永远用它而不是应用层 CAS。
如果你的存储不支持 WHERE >=
MySQL 的 WHERE lAvailable >= ? 让超卖检查在存储层原子完成。但如果你的存储只支持等号条件(比如 WHERE field == oldValue、或者 Redis 基础命令根本没有 WHERE),就得走 B-1——把”读-判-写”的间隙交给版本号守卫。
以 Redis 为例(假设不用 Lua 脚本,只用基础命令)。Redis 的 Hash 结构可以存余额字段,HINCRBY 能做相对更新,但没有跨字段的原子条件检查——你没法在一行命令里说”如果 frozen + amount <= balance 就 HINCRBY frozen”。
所以需要 WATCH/MULTI/EXEC 做乐观锁:
数据模型 (Redis Hash):
balance:{uid}
lBalance: "700" ← 总额
lFrozen: "200" ← 冻结额
-- 不变量: lFrozen <= lBalance
-- 可用余额 = lBalance - lFrozen(推算,不存)Try(冻结)——读后写 + WATCH 守门:
def try_freeze(uid, amount):
key = f"balance:{uid}"
while True:
pipe = redis.pipeline()
pipe.watch(key)
bal = int(pipe.hget(key, "lBalance") or 0)
frozen = int(pipe.hget(key, "lFrozen") or 0)
# 应用层校验:Redis 做不了跨字段比较
if frozen + amount > bal:
pipe.unwatch()
raise InsufficientBalance()
pipe.multi()
pipe.hincrby(key, "lFrozen", amount)
try:
pipe.execute()
return # WATCH 没检测到变化,成功
except WatchError:
continue # 有人改过,重读重算Confirm(确认扣减):
def confirm_deduct(uid, amount):
key = f"balance:{uid}"
while True:
pipe = redis.pipeline()
pipe.watch(key)
bal = int(pipe.hget(key, "lBalance") or 0)
frozen = int(pipe.hget(key, "lFrozen") or 0)
pipe.multi()
pipe.hincrby(key, "lBalance", -amount)
pipe.hincrby(key, "lFrozen", -amount)
try:
pipe.execute()
return
except WatchError:
continueCancel(解冻):
def cancel_freeze(uid, amount):
key = f"balance:{uid}"
while True:
pipe = redis.pipeline()
pipe.watch(key)
frozen = int(pipe.hget(key, "lFrozen") or 0)
pipe.multi()
pipe.hincrby(key, "lFrozen", -amount)
try:
pipe.execute()
return
except WatchError:
continueRedis WATCH 把冲突检测委托给了存储层——key 有变化 EXEC 自动失败。为简化示例,上述代码省略了状态表的幂等守卫;实际工程中,Confirm 必须先检查冻结记录是否存在、是否已被 Confirm 过,确认状态合法后再执行 HINCRBY。但如果你的存储连 WATCH 都没有(比如某个只支持 SET field = value WHERE field == oldValue 的 KV),就得自己维护版本号:
-- 数据模型:两字段 + 显式版本号
CREATE TABLE t_balance (
lUid BIGINT PRIMARY KEY,
lBalance BIGINT NOT NULL, -- 总额
lFrozen BIGINT NOT NULL, -- 冻结额
iVersion INT NOT NULL -- 版本号,每次更新 +1
);
-- 不变量: lFrozen <= lBalancedef try_freeze(uid, amount):
while True:
row = db.query(
"SELECT lBalance, lFrozen, iVersion "
"FROM t_balance WHERE lUid = ?", uid)
# 应用层校验:等号 WHERE 做不了 lFrozen + amount <= lBalance
if row.lFrozen + amount > row.lBalance:
raise InsufficientBalance()
affected = db.execute(
"UPDATE t_balance "
"SET lFrozen = lFrozen + ?, iVersion = iVersion + 1 "
"WHERE lUid = ? AND iVersion = ?", # ← 版本号守门
amount, uid, row.iVersion)
if affected > 0:
return # CAS 成功
# affected == 0 → 版本号变了,重读重算def confirm_deduct(uid, amount):
while True:
row = db.query(
"SELECT lBalance, lFrozen, iVersion "
"FROM t_balance WHERE lUid = ?", uid)
affected = db.execute(
"UPDATE t_balance "
"SET lBalance = lBalance - ?, lFrozen = lFrozen - ?, "
" iVersion = iVersion + 1 "
"WHERE lUid = ? AND iVersion = ? "
" AND lFrozen = ?", # ← 双重保险
amount, amount, uid, row.iVersion, row.lFrozen)
if affected > 0:
returndef cancel_freeze(uid, amount):
while True:
row = db.query(
"SELECT lFrozen, iVersion "
"FROM t_balance WHERE lUid = ?", uid)
affected = db.execute(
"UPDATE t_balance "
"SET lFrozen = lFrozen - ?, iVersion = iVersion + 1 "
"WHERE lUid = ? AND iVersion = ?",
amount, uid, row.iVersion)
if affected > 0:
returnWHERE iVersion == oldVersion 是守门人。任何并发操作修改了这行数据,版本号就会变,后续 CAS 全部失败重试。WHERE lFrozen == oldFrozen(Confirm 中)是额外的冗余保护——版本号绕圈(如果版本号只有8位,在极高 QPS 下几乎必然发生)时多一层防线。
三种方式的本质差异:
| B-2(MySQL 相对更新) | B-1(Redis WATCH) | B-1(显式版本号) | |
|---|---|---|---|
| 冲突检测 | WHERE >= 存储层完成 |
WATCH 检测 key 变化 | WHERE version == old 应用层版本 |
| 是否需要循环 | 否 | 是 | 是 |
| 额外字段 | lAvailable | 无(WATCH 是隐式版本) | iVersion |
| 适用存储 | MySQL, PostgreSQL, MongoDB, DynamoDB | Redis | etcd, ZK, Consul, Cassandra LWT |
三者的正确性等价——都是乐观锁,只是在”谁来检测冲突”和”冲突信号是什么”上有区别。MySQL B-2 最简洁(存储包办),Redis WATCH 次之(隐式版本),显式版本号最通用(什么存储都能用)。
七、不同存储能走哪个流派
对号入座:
| 存储 | 推荐流派 | 理由 |
|---|---|---|
| MySQL / PostgreSQL | B-2 | UPDATE SET x = x - ? WHERE x >= ? 原子完成 |
| MongoDB / DynamoDB | B-2 | $inc + filter,文档级原子操作 |
| etcd / ZK / Consul | B-1 | 仅支持 version CAS,走两字段 + while 循环 |
| Cassandra (LWT) | B-1 | IF field = value,同 etcd |
| Redis(基础命令) | B-1 | WATCH/MULTI 等价于版本 CAS |
| Redis + Lua | B-2 | Lua 脚本内能写 if >=,能力等价 |
流派 A(预扣库存)不挑存储——只要下游有幂等退款接口就能走。B-1 和 B-2 的详细实现见第六章。
八、TCC 协调者的工程要点
教科书上的 TCC 很干净,但实际工程实现中,总有奇奇怪怪的边界情况(网络不可靠、服务器宕机等),TCC协调者是绕不开的。
空回滚:Cancel 比 Try 先到
看一个真实的故障时序:
10:00:00 TM 发起全局事务,并发调两个分支的 Try
10:00:00 分支1 Try 成功(冻结 100 金币)
10:00:01 分支2 Try ——— 网络超时,TM 不知道成功了没
10:00:02 TM 决定回滚,协调者向两个分支发 Cancel
10:00:02 分支1 Cancel 正常执行(解冻 100 金币)✓
10:00:02 分支2 Cancel 到达 —— 但分支2 的 Try 从来没到过分支2 什么都没做。如果 Cancel 的逻辑是”还原 Try 的操作”——把冻结额减回去——那分支2 的 lFrozen 就会变成负数,或者用户的可用余额被凭空增加。更糟的是:如果 Cancel 自己又去扣了一笔余额(以为在”补偿”一个不存在的 Try),用户就莫名丢钱。
这就是空回滚。Cancel 被调用了,但对应的 Try 根本没执行。
正确的处理:
Cancel 到达,查 Try 记录:
→ 不存在 → 这就是空回滚
→ 不要动余额!不要调任何补偿逻辑!
→ 创建一条 status=Cancelled 的流水记录(防悬挂用,以全局事务 ID + 分支 ID 为联合主键,确保 Cancel 和迟到的 Try 能关联起来)
→ 返回成功Cancel 必须支持空回滚。不存在 Try 记录时不报错,返回成功,且不操作余额。
防悬挂:迟到的 Try 或 Confirm 比 Cancel 晚到
空回滚只解决了 Cancel 先到的问题。但还有一个更阴险的场景:
10:00:00 分支2 Try ——— 网络超时,但请求在网络上漂着,没有丢
10:00:02 分支2 Cancel 先到了 → 空回滚处理 → 创建 Cancelled 记录 → 返回成功
10:00:05 分支2 Try 的请求终于到达了!如果 Try 不检查状态,直接执行业务逻辑——冻结余额——那么:
- Cancel 已经在第 2 秒告诉 TM “这个分支回滚成功了”
- Try 在第 5 秒真的冻结了余额
- 用户的钱被永久冻结了,没有后续的 Confirm 或 Cancel 会来释放它
这就是防悬挂。一个已经被 Cancel 的事务,迟到的 Try 不能再执行。
正确的处理:
Try 到达,查流水记录:
→ 存在 status=Cancelled → 这是迟到的 Try,事务已经回滚了
→ 直接拒绝,不冻结余额Confirm 同理:如果 Confirm 到达时查不到 Try 记录(极端网络乱序下 Confirm 比 Try 先到),或者看到 Cancelled 记录,也必须拒绝——不能对一笔不存在的冻结执行确认。
空回滚和防悬挂是同一个硬币的两面——都是网络乱序导致请求到达顺序与发送顺序不一致。空回滚让 Cancel 在”无 Try”时安全返回,防悬挂让迟到的 Try/Confirm 在”已 Cancel”时拒绝执行。两者缺一不可。
幂等性
三个接口都必须支持重复调用。网络超时后调用方会重试——第二次 Try 发现已经是 Prepared,直接返回成功。
状态先行
协调者二阶段的关键设计选择:先改状态,再调 RPC。
如果反过来(先调 RPC 再改状态),”RPC 成功但改状态失败”就是死局——不知道发生了什么。先把状态改成 Committing,RPC 就算失败,补偿器看到 Committing 状态会持续重试。状态是补偿器的方向盘,必须先摆正。
不等长超时——以及超时后怎么办
Confirm 和 Cancel 的超时可以不一样:
- Confirm 设长(比如 2 秒):下游要真转账,多等一秒可能换来事务成功
- Cancel 设短(但需大于实际业务补偿耗时 + 网络 RTT):回滚通常比正向操作快,但不能设得太激进导致网络波动时误判超时
但更关键的问题是:超时之后怎么办。
Confirm 超时 ≠ Confirm 失败。下游可能已经扣款成功了,只是响应丢了。所以 Confirm 超时绝对不能自动转 Cancel——一旦转了,就会出现”扣款成功 + 又解冻了”的双重赔付。正确的做法是挂起等待,由补偿器重试 Confirm,或通过查单接口确认最终状态。
Cancel 超时则不同——补偿器可以持续重试,因为 Cancel 是幂等的,重复调不会多退钱。
超时不是按”操作需要多久”设,是按”等不及了怎么办”设。Confirm 超时只能重试不能转 Cancel,这是分布式事务的常识底线。
补偿器的保护期
补偿器定期扫描未完成事务。但它不碰刚创建的事务——防止和 TM 的正常流程竞态。给一个”免打扰”窗口(比如 10 秒),过了窗口还没推进才由补偿器接管。
这个窗口必须可配置。 如果业务高峰期 Try 耗时超过窗口(数据库连接池打满等),补偿器会误判超时并发起重试,造成重复请求风暴。
分布式锁不是正确性的前提
补偿器多实例时用分布式锁避免重复扫描。但锁服务坏了?跳过锁直接扫。commit/rollback 是幂等的,重复扫最多浪费 CPU。补偿器停摆的代价是事务永久卡住。哪个更严重很清楚。
九、总结
| 概念 | 一句话 |
|---|---|
| 事务 | 要么全做,要么全不做 |
| 分布式事务 | 跨服务/数据库,需要协调者统一调度 |
| TCC | Try(预留)→ Confirm(执行)或 Cancel(释放) |
| Saga | 正向操作 + 补偿操作,错了再退 |
| 幂等 | 同样请求调多次,结果一样 |
| 空回滚 | Cancel 在 Try 没执行的情况下也要成功 |
| 防悬挂 | 已 Cancel 的事务,迟到的 Try/Confirm 必须被拒绝 |
核心原则:
- 先问自己是否需要分布式事务——重试 + 幂等、异步补偿通常就够了
- 能 TCC 就别 Saga——Cancel 是释放预留(轻),Compensate 是反向业务逻辑(重)
- Try 只预留不执行,Confirm 才真正执行,Cancel 释放预留
- 所有接口必须幂等,Cancel 必须支持空回滚
分布式事务不是银弹,而是权衡的艺术。 技术上的”一致性”往往是靠业务上的”柔性”换来的。真正的架构能力,不是看谁把 2PC 写得最溜,而是看谁能用”本地事务 + 幂等 + 告警”四两拨千斤。如果读完全文你只记住一句话,我希望是:Try 之前,先确认是否真的需要分布式事务。