从单机到分布式:一个接口的事务一致性演进

本文是体系化知识专题的第 1 篇 叙事框架:业务演进 → 技术挑战 → 方案对比 → 选型决策

从一条转账 SQL 说起

假设你刚接手一个转账接口,业务逻辑很简单:

① 扣减 A 账户余额
② 增加 B 账户余额
③ 写入转账流水

这三个步骤必须一起成功或一起失败——不能出现 A 扣了钱但 B 没收到的情况。单机时代,数据库的本地事务天然解决了这个问题:BEGIN → UPDATE → UPDATE → INSERT → COMMIT,要么全部生效,要么全部回滚。

但业务增长后,你拆了库、拆了服务、引了消息队列。同一个接口的三个步骤散落在不同的进程和数据库中。本地事务管不了跨库操作了

这篇文章就沿着这个场景,看事务一致性方案如何随着架构演进而演进。


一、单机时代:本地事务的 ACID

单库单应用的架构下,事务是最"无感"的能力。你只需要写 BEGIN / COMMIT,数据库底层用一套成熟的机制保证 ACID。

单机事务架构

原子性(Atomicity)—— undo log

原子性的核心问题是:如何保证部分执行的事务在崩溃时能回滚

MySQL InnoDB 用 undo log 解决。事务修改数据前,先将旧值写入 rollback segment:

UPDATE account SET balance = 100 WHERE id = 1;
                  ↓
undo log: balance 原来是 200

事务回滚时,InnoDB 从 undo log 读取旧值写回去。崩溃恢复时,所有未 commit 的事务通过 undo log 回滚到起点。

undo log 的另一个身份是 MVCC 的快照基础——长事务读取 undo log 构建历史版本。这就是为什么长事务会导致 undo log 膨胀,最终引发 undo log 占用过大 的问题。

持久性(Durability)—— redo log

持久性的核心问题是:事务提交后、数据刷盘前,数据库宕机了怎么办

InnoDB 用 WAL(Write-Ahead Logging)机制:每次事务提交时,只保证 redo log 刷入磁盘,不要求数据页也刷入。崩溃恢复时,扫描 redo log 重放尚未写入数据页的修改。

事务提交流程:
BEGIN → 修改数据(在 buffer pool)→ 写 redo log(刷盘)→ COMMIT
                                                          ↓
                                                    redo log 刷盘成功
                                                    = 事务已持久化

数据页在后台异步刷盘,不影响事务的提交延迟。所以 COMMIT 的耗时 ≈ redo log 刷盘耗时,和修改的数据量无关——只和 fsync 延迟有关。

隔离性(Isolation)—— 锁 + MVCC

多个事务同时操作同一行时,通过行锁解决写冲突。写-写互斥,读-写不阻塞:

事务 A:UPDATE account SET balance = balance - 100
事务 B:SELECT balance FROM account
        事务 A 未提交 → MVCC 提供快照读,返回旧值

MVCC 通过三个隐式字段实现:DB_TRX_ID(最后修改的事务 ID)、DB_ROLL_PTR(指向 undo log)、DB_ROW_ID。每个事务启动时拿到一个 view,查询只认 view 范围内已提交的版本。

这个阶段的局限

一个本地事务只能操作一个数据库。所有的表必须在同一个实例上。一旦你的业务拆分到多个数据库实例,本地事务就管不住了。


二、拆库之后:分布式事务的困境

业务增长,单库扛不住了。最常见的拆分路径:

  1. 读写分离——主库写、从库读,事务必须路由到主库
  2. 垂直拆分——将余额库和流水库拆分到不同实例
  3. 微服务拆分——账户服务和账务服务独立部署

问题来了:转账接口跨了多个数据库实例。

分布式事务挑战

跨库事务为什么难

场景 问题
账户服务本地事务提交 余额已扣减
账务服务本地事务提交 流水已写入
通知服务时间点失败 网络超时,无法回滚前两个服务的提交

三个本地事务各自成功不等于全局成功。提交后无法回滚,这就是分布式事务的核心难题。

来自 CAP 的理论限制

分布式系统无法同时满足一致性(C)、可用性(A)和分区容忍性(P)。分布式事务的核心矛盾在于:

  • 要实现强一致性,就必须在提交前让所有参与者达成共识——这需要锁和同步通信
  • 要保证可用性,就必须容忍部分节点暂时不一致——走向最终一致性

不同的分布式事务方案,本质是在 CAP 中的不同取舍。


三、XA / 2PC:二阶段提交

XA(eXtended Architecture)是 X/Open 组织定义的分布式事务规范。MySQL、Oracle 等主流数据库都实现了 XA 接口。

核心思想:引入一个事务协调器(TM)来协调多个资源管理器(RM)。所有参与者执行 prepare,全部通过后才 commit。

2PC 时序图

第一阶段:准备(Voting Phase)

事务协调器向所有参与者发送 prepare 请求。参与者执行但不提交:

-- 账户服务收到 prepare
XA PREPARE 'xid';
-- 数据库写入 undo/redo log,加行锁
-- 返回 OK

关键点:参与者回复 OK 后,就不能自行回滚了——它承诺等待 TM 的最终决策。

如果任何一个参与者返回 NO 或超时,TM 向所有已 prepare 的参与者发送 rollback。

第二阶段:提交(Commit Phase)

所有参与者都返回 OK 后,TM 做出全局提交决策并写入事务日志:

-- TM 写入 commit 日志(先持久化)
-- 然后广播 commit 到所有参与者
XA COMMIT 'xid';

这个决策是不可逆的——一旦 TM 决定提交,即使部分 commit 失败,也必须坚持重试或人工介入。

2PC 的核心问题

问题 说明
同步阻塞 prepare 阶段持有行锁,直到全局事务结束。prepare → commit 的窗口越长,锁竞争越激烈
单点故障 TM 宕机 → 所有参与者阻塞等待决策。需要额外的 HA 方案
不一致窗口 Phase 2 中网络分区 → 部分参与者已 commit,部分未收到
性能差 2 轮 RTT + 3 次日志刷盘,RT 远高于本地事务

生产中的实际表现

有一类线上故障就是 XA 事务引起的:一个跨库转账链中,DB-A prepare 很快但 DB-B 行锁等待,锁等待时间拖长了全局事务。DB-A 的行锁也一直被持有,逐渐引发连接池耗尽。

什么场景还值得用 XA

  • 短事务(prepare → commit 在 200ms 内)
  • 强一致要求(资金类核心链路)
  • 同构数据库(全是 MySQL)
  • 低并发(TPS < 1000)

在这些约束下,XA 虽然慢但正确性可靠——它保证了 ACID,没有补偿逻辑。


四、TCC:业务补偿的艺术

TCC(Try-Confirm-Cancel)是阿里提出的分布式事务方案,不依赖数据库锁,通过业务层的预留和确认保证一致性。

它将一个分布式事务拆成三个阶段,每个服务都要实现三组接口。

TCC 流程

Try 阶段——资源预留

不是直接扣款,而是"冻结"。以转账为例:

// 账户服务——Try:冻结余额
public boolean tryFreeze(String accountId, long amount, String txId) {
    Account account = accountMapper.selectForUpdate(accountId);
    if (account.getBalance() - account.getFrozen() >= amount) {
        account.setFrozen(account.getFrozen() + amount);
        accountMapper.update(account);
        // 记录事务日志,用于幂等和防悬挂
        txLogMapper.insert(txId, "FREEZED");
        return true;
    }
    return false;  // 余额不足
}

注意这里没有减少余额,而是在冻结字段中标记。冻结的金额不能使用,但仍在余额中。

Confirm 阶段——实际执行

// 账户服务——Confirm:确认扣减
public boolean confirmDeduct(String accountId, long amount, String txId) {
    // 幂等检查:已 confirm 则跳过
    TxLog log = txLogMapper.select(txId);
    if (log.getStatus() == "CONFIRMED") return true;

    Account account = accountMapper.selectForUpdate(accountId);
    account.setBalance(account.getBalance() - amount);
    account.setFrozen(account.getFrozen() - amount);
    accountMapper.update(account);
    txLogMapper.updateStatus(txId, "CONFIRMED");
    return true;
}

Confirm 方案设计上保证不会失败——因为 Try 阶段已经校验余额了。

Cancel 阶段——回滚释放

// 账户服务——Cancel:解冻
public boolean cancelFreeze(String accountId, long amount, String txId) {
    // 空回滚处理:Try 未执行但 Cancel 来了
    TxLog log = txLogMapper.select(txId);
    if (log == null) {
        txLogMapper.insert(txId, "CANCELED");  // 记录已取消,避免后续 Try 产生防悬挂
        return true;
    }

    Account account = accountMapper.selectForUpdate(accountId);
    account.setFrozen(account.getFrozen() - amount);
    accountMapper.update(account);
    txLogMapper.updateStatus(txId, "CANCELED");
    return true;
}

三个必须处理的反向逻辑

空回滚:Try 未执行但 Cancel 到了。原因是异步网络下,TM 判定超时发送 Cancel 时,Try 请求还在路上。Cancel 中看到无事务记录→插入已取消标记→后续 Try 到达时检查到已取消→响应成功但不执行。

防悬挂:Cancel 先于 Try 到达。原因同上,但时序相反。Cancel 执行完毕、Try 才到。如果 Try 不检查事务状态,就会把冻结记录写入,但永远不会有 Confirm 或 Cancel 来清理它。解决:Try 前检查是否有 Cancel 记录,有则直接返回。

幂等控制:Confirm 和 Cancel 可能被重复调用(TM 超时重试)。用事务 ID 去重——每个接口都支持传入 txId,先检查状态再执行。

TCC 框架选型

框架 特点
Seata 阿里开源,支持 TCC + AT + Saga,社区活跃
TCC-Transaction 轻量级,适合快速集成
Hmily 高性能,支持异步 TCC

Seata 是目前最流行的选择,提供了完善的空回滚、防悬挂和幂等功能。


五、方案选型:什么时候用什么

你不需要在项目初期就上 TCC。选型要看一致性要求、性能要求、资源类型和时间成本。

选型决策树

1. 本地事务 + 定时补偿

适用场景:低并发、能接受秒级延迟、业务可接受短暂不一致。

本地事务 + 定时补偿

这是最简单的分布式事务方案,不需要引入任何框架。

核心设计

业务操作(本地事务):
  UPDATE 余额 SET 余额 = 余额 - 100
  INSERT 流水表(状态 = PENDING)

异步通知 → 如果失败,流水表状态仍是 PENDING

定时补偿任务(每 1 分钟扫描):
  SELECT * FROM 流水表 WHERE 状态 = 'PENDING'
  → 重试通知服务
  → 超过 N 次失败 → 人工告警

关键点在于业务操作和状态记录在同一个本地事务中。只要本地事务提交了,流水表的 PENDING 记录就一定存在。补偿任务只需要扫描 PENDING 状态的记录进行重试即可。

实现细节

  • 状态字段至少要包含:status(PENDING/SUCCESS/FAILED)、retry_countlast_retry_at
  • 补偿任务需要幂等:重试前检查目标状态是否已变更
  • 补偿任务需要退避策略:指数退避(1min → 2min → 4min → 8min → 告警)
  • 补偿任务要保证只执行一次语义:用 UPDATE ... WHERE status = 'PENDING' AND retry_count = N 做乐观锁

优缺点

优点 缺点
零额外依赖,不需中间件 补偿逻辑散落在业务代码中
实现简单,开发成本低 数据一致窗口 = 扫描间隔(通常秒级)
易于排查和对账 业务侵入性强(每张表都要加状态字段)

RocketMQ 事务消息解决了补偿逻辑散落的问题,提供了更可靠的消息投递保证。

2. 消息事务(RocketMQ 事务消息)

适用场景:高吞吐、需要可靠异步通知、希望框架化管理补偿逻辑。

RocketMQ 事务消息

RocketMQ 事务消息的核心是半消息机制,解决"发消息"和"本地事务"的原子性问题。

流程详解

 ① 生产者发送半消息(Half Message)→ 消息到达 Broker,标记为不可见
 ② 生产者执行本地事务(扣余额、写流水)
 ③ 根据本地事务结果:
    - COMMIT → Broker 将消息标记为可见,消费者可以消费
    - ROLLBACK → Broker 删除半消息,消费者永远不会看到
 ④ 如果步骤 ② 超时(生产者宕机),Broker 回调生产者的回查接口:
    - 回查本地事务状态 → 返回 COMMIT / ROLLBACK / UNKNOWN

回查接口实现

// RocketMQ 事务回查监听器
public class OrderTxListener implements TransactionListener {
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        // 步骤 ②:执行本地事务
        String orderId = msg.getKeys();
        return orderService.createOrder(orderId);
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // 步骤 ④:Broker 回查事务状态
        String orderId = msg.getKeys();
        Order order = orderMapper.selectById(orderId);
        if (order == null) return LocalTransactionState.UNKNOW;
        return order.getStatus() == 1
            ? LocalTransactionState.COMMIT_MESSAGE
            : LocalTransactionState.ROLLBACK_MESSAGE;
    }
}

关键点

  • 回查接口必须幂等——可能被多次调用
  • 回查接口要快——Broker 有超时阈值,超时视为 UNKNOWN
  • 半消息机制只保证"发送"原子性,不保证"消费"原子性——消费失败需要消费者自行重试

与纯定时补偿的对比

维度 定时补偿 消息事务
一致性窗口 秒级(扫描间隔) 毫秒级(消息即到)
开发量 维护状态表和定时任务 实现回查接口
运维成本 扫描任务监控 RocketMQ 集群运维
适用场景 内部系统,允许短暂延迟 核心链路,要求低延迟

3. XA / 2PC

适用场景:短事务、强一致、同构资源、低并发(TPS < 1000)。

XA 二阶段提交架构

XA 是标准化的分布式事务协议,MySQL、Oracle、SQL Server 都原生支持。开发者只需要按 XA 规范调用接口,不需要写补偿逻辑。

XA 接口调用示例

-- MySQL XA 事务完整流程
XA START 'xid1';                    -- 第一阶段开始
UPDATE account SET balance = balance - 100 WHERE id = 1;
XA END 'xid1';
XA PREPARE 'xid1';                  -- 准备:写 undo/redo log,持有行锁

XA START 'xid2';
INSERT INTO transfer_log VALUES (..., 'PENDING');
XA END 'xid2';
XA PREPARE 'xid2';

-- 如果两个都 prepare 成功
XA COMMIT 'xid1';                   -- 第二阶段:提交
XA COMMIT 'xid2';

-- 如果任一 prepare 失败
XA ROLLBACK 'xid1';                 -- 第二阶段:回滚
XA ROLLBACK 'xid2';

TM 的持久化日志是核心

TM 在做出全局决策(commit/rollback)前,必须先写日志。如果 TM 在发送 commit 的过程中宕机,重启后扫描日志恢复未完成的全局事务:

事务日志条目:
[GLOBAL BEGIN]   xid=tx001, rm=[rm1, rm2], time=T1
[PREPARE OK]     xid=tx001, rm=rm1, time=T2
[PREPARE OK]     xid=tx001, rm=rm2, time=T3
[COMMIT DECIDE]  xid=tx001, time=T4     ← 先写日志再发送
[COMMIT OK]      xid=tx001, rm=rm1, time=T5
[COMMIT OK]      xid=tx001, rm=rm2, time=T6

崩溃恢复时,如果日志停留在 [COMMIT DECIDE] 但未收到所有 [COMMIT OK],TM 必须重试 commit 直到全部确认。这就是 XA 的"至少一次"语义——commit 可能重复执行,参与者需要幂等。

Spring 集成示例

// 使用 Atomikos 作为 TM
@Bean(initMethod = "init", destroyMethod = "close")
public UserTransactionManager atomikosTransactionManager() {
    UserTransactionManager tm = new UserTransactionManager();
    tm.setForceShutdown(true);
    return tm;
}

// 分布式事务调用
@Transactional
public void transfer(TransferReq req) {
    accountDao.deduct(req.getFrom(), req.getAmount());  // DB1
    accountDao.add(req.getTo(), req.getAmount());       // DB2
    txLogDao.insert(req);                               // DB3
    // 三个数据源在一个 @Transactional 下,底层用 XA 协调
}

XA 不适合什么场景

  • 长事务:prepare 持有锁的时间 = 整个事务耗时,长事务导致大面积锁等待
  • 高并发:每轮 2 RTT + 3 次 fsync,TPS 受限于日志刷盘性能
  • 异构资源:XA 规范要求资源管理器实现 XA 接口,Redis/MQ 不支持原生 XA

4. TCC

适用场景:异构资源、高并发、业务可接受三阶段设计、短事务。

TCC 架构

TCC 是阿里提出的业务级分布式事务方案。每个服务实现三组接口,由 TCC 框架协调调用。与 XA 最大的区别:不依赖数据库锁,而是通过业务逻辑预留资源

Seata TCC 实现示例

// 定义 TCC 接口
@LocalTCC
public interface AccountTccService {

    @TwoPhaseBusinessAction(
        name = "deduct",
        commitMethod = "confirm",
        rollbackMethod = "cancel"
    )
    boolean tryDeduct(@BusinessActionContextParameter(paramName = "accountId") String accountId,
                      @BusinessActionContextParameter(paramName = "amount") long amount);

    boolean confirm(BusinessActionContext ctx);
    boolean cancel(BusinessActionContext ctx);
}

// Try:预留资源
@Override
public boolean tryDeduct(String accountId, long amount) {
    // 冻结金额,不实际扣减
    int rows = accountDAO.freezeBalance(accountId, amount);
    return rows > 0;
}

// Confirm:实际执行
@Override
public boolean confirm(BusinessActionContext ctx) {
    String accountId = (String) ctx.getActionContext("accountId");
    long amount = (long) ctx.getActionContext("amount");
    // 从冻结转为实际扣减
    int rows = accountDAO.confirmDeduct(accountId, amount);
    return rows > 0;
}

// Cancel:释放预留
@Override
public boolean cancel(BusinessActionContext ctx) {
    String accountId = (String) ctx.getActionContext("accountId");
    long amount = (long) ctx.getActionContext("amount");
    // 解冻金额
    int rows = accountDAO.cancelFreeze(accountId, amount);
    return rows > 0;
}

三种异常场景的处理

① 空回滚(Hanging Rollback)

Try 请求超时,TM 认为失败并发起 Cancel。但 Try 请求最终到达了服务端。此时 Cancel 中发现无 Try 记录——这就是空回滚。

// Cancel 中处理空回滚
public boolean cancel(BusinessActionContext ctx) {
    TxLog log = txLogMapper.select(ctx.getXid());
    if (log == null) {
        // 空回滚:记录 Cancel 标记,防止后续 Try 执行
        txLogMapper.insert(ctx.getXid(), "CANCELED");
        return true;
    }
    // 正常回滚逻辑
}

② 防悬挂(Suspension)

空回滚的镜像问题。Cancel 先执行完(记录 CANCELED),Try 后到达。如果 Try 不检查状态直接执行,就会产生一条"悬挂"的预留记录——永远不会有 Confirm 或 Cancel 来清理它。

// Try 中处理防悬挂
public boolean tryFreeze(String accountId, long amount, String txId) {
    TxLog log = txLogMapper.select(txId);
    if (log != null && "CANCELED".equals(log.getStatus())) {
        return true;  // 已取消,不做任何事情
    }
    // 正常 try 逻辑
}

③ 幂等控制

Confirm 和 Cancel 可能被重复调用(TM 超时重试、网络重传):

public boolean confirm(BusinessActionContext ctx) {
    TxLog log = txLogMapper.select(ctx.getXid());
    if (log == null || "CONFIRMED".equals(log.getStatus())) {
        return true;  // 已执行过,幂等返回成功
    }
    // 正常 confirm 逻辑,然后更新状态
    txLogMapper.updateStatus(ctx.getXid(), "CONFIRMED");
}

TCC 框架对比

特性 Seata TCC-Transaction Hmily
空回滚/防悬挂 ✅ 内置 ⚠️ 需自行实现 ✅ 内置
幂等控制 ✅ 通过事务日志
性能 中等 高(无代理)
社区活跃度 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐

5. Saga

适用场景:长事务(分钟级)、异步补偿可接受、业务流程明确。

Saga 编排模式

Saga 将一个长事务拆成多个本地事务,每个本地事务对应一个补偿操作。有两种实现模式:

编排型(Orchestration)

由一个 Saga 编排器负责流程控制,参与者只提供正向操作和补偿操作。编排器顺序调用正向操作,任一步失败时逆序调用补偿操作。

转账 Saga 编排流程:

编排器 → 账户服务:扣余额(正向 T1)
编排器 ← 账户服务:OK
编排器 → 账务服务:写流水(正向 T2)
编排器 ← 账务服务:OK
编排器 → 通知服务:发消息(正向 T3)
编排器 ← 通知服务:❌ 失败

编排器 → 账务服务:标记流水作废(补偿 C2)
编排器 → 账户服务:归还余额(补偿 C1)

协同型(Choreography)

没有中央编排器,每个服务执行完本地事务后通过 MQ 触发下一个服务。失败时需要反向传递消息触发补偿。

协同型 Saga 流程:

账户服务(扣余额成功)→ 发消息 → 账务服务
                                         ↓
                                 账务服务(写流水成功)
                                 发消息 → 通知服务
                                              ↓
                                       通知服务(失败)
                                       发补偿消息 → 账务服务
                                                     ↓
                                             账务服务补偿 → 账户服务补偿

两种模式对比

维度 编排型 协同型
职责 编排器负责流程控制 每个服务感知上下游
复杂度 编排器复杂度随步骤增长 步骤多时消息流转复杂
耦合度 低(参与者不知晓全貌) 高(服务间通过消息耦合)
可观测性 强(编排器有完整状态) 弱(状态分散在 MQ 中)
适用场景 流程固定、复杂度可控 流程灵活、需要解耦

Saga 的实现难点

  • 隔离性缺失:Saga 不提供事务隔离。步骤 T2 在步骤 T1 提交后就能看到 T1 的结果,即使后续 C1 可能回滚 T1。这称为"脏读"。解决方式是用语义锁:在预留字段中标记操作进行中,读取时过滤这些标记
  • 补偿幂等:补偿操作可能重复执行,需要幂等设计(同 TCC)
  • 补偿正确性:补偿操作必须能将系统恢复到业务一致的状态,而不是数据一致

Saga vs TCC

维度 TCC Saga
资源占用 Try 阶段持有资源 无预留,直接执行
锁持有时间 Try → Confirm 全程 仅本地事务期间
适用事务时长 秒级 分钟级甚至更长
补偿设计 有预留,补偿简单 无预留,补偿要精确
隔离性 可提供一定隔离性 不保证隔离

一个转账接口的选型实战

回到开头的转账案例,假设当前系统的数据:

  • QPS:5000(日均 4 亿笔)
  • 跨 3 个服务:账户、账务、通知
  • 数据源:MySQL(账户库 + 流水库)+ RocketMQ(通知)
  • 一致性要求:转账链路不允许资金损失,但通知消息允许延迟

这个场景不适合 XA(跨异构资源、高并发),也不适合纯 TCC 全覆盖(开发成本高)。实际最优方案是混搭

账户扣减 → 本地事务(余额库)
    ↓
写流水 → TCC Confirm(流水库)
    ↓
发通知 → RocketMQ 事务消息

账户和流水之间用 TCC 保证一致性(这两个不允许不一致),流水和通知之间用消息事务接受延迟。关键链路强一致,非关键链路最终一致——这是生产中最务实的做法。

演进路线总结

从一个接口的事务方案选择,可以看出一套系统的架构成熟度:

单库本地事务
  → 读写分离(本地事务不变,注意路由)
    → 分库分表(XA / 2PC)
      → 微服务拆分(TCC / Saga)
        → 最终一致 + 对账(成熟的分布式系统)

大部分系统走到"XA"或"最终一致"就够了。TCC 和 Saga 只有在跨多个异构资源、且对吞吐有明确要求时才需要引入。

从这套演进逻辑中可以看到,事务方案没有银弹。每个阶段的选择都是在一致性、性能、开发成本之间的权衡。理解权衡背后的原理,比记住某个方案的具体实现更重要。当你面对新的业务场景时,能自己判断该走到哪一步、该用什么方案,而不是盲目套用某一种分布式事务框架。


避坑建议

  1. 不要为了分布式事务而分布式事务——90% 的场景通过优化本地事务或异步补偿解决。引入分布式事务前先问:这个操作真的需要强一致吗?
  2. XA 的锁持有时间必须监控——prepare → commit 的时间超过 1 秒就要报警。锁持有时间 = 全局阻塞时间,直接决定系统吞吐上限
  3. TCC 的空回滚和防悬挂必须实现——不做这两个,极端情况(网络超时 + 重试)下会出现资金异常。这是线上故障最常见的原因
  4. 事务 ID 必须在整条链路传递——日志、数据库、MQ 都需要同一个 ID 用于回查和排障。缺少链路 ID 的分布式事务等于没有运维手段
  5. 先测试单机事务的正确性——分布式事务的 bug 往往根在底层事务配置(隔离级别、自动提交、代理绕过)。基础不牢,上层再好的方案也会出问题
  6. 最终一致性方案必须设计对账——没有对账的最终一致性等于没有一致性。对账是分布式系统的最后一道防线
  7. 不要混合使用不同分布式事务方案——一个链路里既有 2PC 又有 TCC,隔离性和一致性难以保证。同一链路选一种方案走到底
  8. XA 事务的隔离级别要设为 READ COMMITTED——不要用 REPEATABLE READ。XA + RR 的间隙锁会大幅增加死锁概率
  9. TCC 的 Try 接口要有防并发设计——并发下同一账户可能被多条 Try 同时冻结,需要用行锁或乐观锁保护

附:完整命令清单

-- 查看 MySQL 事务状态
SHOW ENGINE INNODB STATUS\G

-- 查看当前运行中的事务
SELECT * FROM information_schema.INNODB_TRX;

-- 查看锁等待
SELECT * FROM sys.innodb_lock_waits;

-- 本地事务测试
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- undo log 和 redo log
SHOW VARIABLES LIKE 'innodb_undo_%';
SHOW VARIABLES LIKE 'innodb_log_%';

-- XA 事务测试
XA START 'xid1';
UPDATE account SET balance = balance - 100 WHERE id = 1;
XA END 'xid1';
XA PREPARE 'xid1';
XA COMMIT 'xid1';

-- 查看 redo log 刷盘配置
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
-- 值为 1: 每次 commit 都刷盘(最安全)
-- 值为 2: 每秒刷盘(性能好但可能丢 1 秒数据)