分布式事务扫盲

分布式事务扫盲

从银行转账的 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:
            continue

Cancel(解冻)

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:
            continue

Redis 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 <= lBalance
def 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:
            return
def 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:
            return

WHERE 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 必须被拒绝

核心原则:

  1. 先问自己是否需要分布式事务——重试 + 幂等、异步补偿通常就够了
  2. 能 TCC 就别 Saga——Cancel 是释放预留(轻),Compensate 是反向业务逻辑(重)
  3. Try 只预留不执行,Confirm 才真正执行,Cancel 释放预留
  4. 所有接口必须幂等,Cancel 必须支持空回滚

分布式事务不是银弹,而是权衡的艺术。 技术上的”一致性”往往是靠业务上的”柔性”换来的。真正的架构能力,不是看谁把 2PC 写得最溜,而是看谁能用”本地事务 + 幂等 + 告警”四两拨千斤。如果读完全文你只记住一句话,我希望是:Try 之前,先确认是否真的需要分布式事务。


分布式事务扫盲
https://blog.lyzen.cn/2026/07/03/DistributedTransactionTutorial/
Author
Lyzen
Posted on
July 3, 2026
Licensed under