分布式事务有哪些解决方案?

深入高频场景题对比约 8 分钟读完

一句话回答

强一致的方案是 2PC(XA):协调者先让所有参与者"准备",都成功了再统一"提交",缺点是同步阻塞、协调者单点、性能差。业务里更常用的是最终一致方案:TCC(业务层面的预留、确认、取消)、Saga(每步一个本地事务,失败了按反方向执行补偿)、本地消息表 / 事务消息(把"发消息"和本地事务绑在一起,下游消费并重试),以及最大努力通知。选型的第一原则是能不用就不用:先看能否通过调整服务边界,把需要原子完成的数据放进同一个库。

详细解析

2PC 和 3PC

文本
阶段一(准备):协调者 ──prepare──> 参与者 A、B
               参与者执行事务、写 redo/undo 日志、锁住资源,但不提交,回复 yes/no
阶段二(提交):全部 yes ──commit──> 所有参与者提交
               任一 no 或超时 ──rollback──> 所有参与者回滚

问题:

  • 同步阻塞:从准备到提交,参与者一直持有锁,并发能力差
  • 协调者单点:参与者回复 yes 后协调者宕机,参与者不知道该提交还是回滚,只能一直等
  • 数据不一致:阶段二只有部分参与者收到 commit 时网络断了,各节点状态不一致

3PC 在中间加了一个预提交阶段,并让参与者在超时后自行提交,减少了阻塞;但网络分区时仍可能不一致,实际很少使用。MySQL 支持 XA 事务,Seata 的 AT 模式在 2PC 思路上做了改进:一阶段就提交本地事务、释放本地锁,靠自动生成的 undo 日志在需要时回滚。但它并不是没有锁:本地提交前要先拿到相关行的全局锁,并一直持有到全局事务结束,防止别的全局事务写同一行;默认的全局隔离级别是读未提交,需要读已提交时用 SELECT ... FOR UPDATE。

TCC

把一个操作拆成三个业务接口,以扣库存为例:

阶段 做什么
Try 检查并预留资源:可用库存减 1,冻结库存加 1
Confirm 真正执行:冻结库存减 1。只用 Try 预留的资源,要求幂等
Cancel 释放预留:冻结库存减 1,可用库存加 1,要求幂等

TCC 不依赖数据库的锁,性能好,但每个服务都要写三套逻辑,侵入性强。有两个经典问题,都靠一张事务控制表(记录全局事务 ID 和各阶段状态)解决:

  • 空回滚:Try 请求因网络问题没到,协调者超时后调用了 Cancel。Cancel 发现没有 Try 的记录,就直接返回成功,并记下"已取消"
  • 悬挂:Cancel 先执行完,迟到的 Try 才到达,资源被预留后再也没人释放。Try 执行前先查控制表,已经取消过就拒绝执行

Saga

把长事务拆成一串本地事务 T1、T2、T3,每个都有对应的补偿操作 C1、C2、C3。T3 失败时,依次执行 C2、C1。

文本
正常:T1 创建订单 ──> T2 扣库存 ──> T3 扣余额
失败:T3 失败 ──> C2 恢复库存 ──> C1 取消订单

两种组织方式:编排(Orchestration)由一个中心协调者按顺序调用各服务,流程清晰、好监控;协同(Choreography)由各服务监听事件自己决定下一步,去中心化,但流程分散在各处,链路长了难以追踪。

Saga 没有 Try 阶段的资源预留,中间状态对外可见(比如库存已扣、余额还没扣),这叫缺乏隔离性,业务上要能接受,或用"待确认"之类的状态标记兜住。

本地消息表与事务消息

最常用的最终一致方案,核心是让"改业务数据"和"记下要发的消息"在同一个本地事务里完成:

文本
1. 本地事务:插入订单 + 插入消息表(状态:待发送)
2. 定时任务扫描待发送的消息,投递到 MQ,成功后标记已发送
3. 下游消费消息,处理成功后 ack;失败则重试(下游必须幂等)

事务消息是 MQ 内置的同类能力,例如 RocketMQ:先发一条下游不可见的"半消息",执行本地事务后再提交或回滚;如果 Broker 迟迟收不到结果,会回查生产者的本地事务状态来决定。MQ 怎么保证可靠投递和幂等消费,见 消息不丢失 和 重复消费与幂等。

最大努力通知用于跨公司的场景,比如支付平台回调商户:按递增间隔重试通知若干次,同时提供查询接口,让对方主动查询结果。

方案对比

方案 一致性 性能 侵入性 适用场景
2PC / XA 强一致 差 低 内部系统、低并发、必须强一致
TCC 最终一致 好 高 资金、库存等要求资源预留的核心链路
Saga 最终一致 好 中 长流程、调用外部系统
本地消息表 / 事务消息 最终一致 好 低 绝大多数异步场景
最大努力通知 最终一致 好 低 跨组织的结果通知

代码示例

本地消息表(Node.js + mysql2),mq 代表项目里的消息队列客户端:

JavaScript
async function createOrder(order) {
  const conn = await pool.getConnection()
  try {
    await conn.beginTransaction()
    await conn.query('INSERT INTO orders (id, user_id, amount) VALUES (?, ?, ?)', [order.id, order.userId, order.amount])
    // 和订单在同一个事务里写入消息,要么都成功,要么都回滚
    await conn.query("INSERT INTO outbox (id, topic, payload, status) VALUES (?, 'order-created', ?, 'PENDING')", [order.id, JSON.stringify(order)])
    await conn.commit()
  } catch (err) {
    await conn.rollback()
    throw err
  } finally {
    conn.release()
  }
}

// 定时任务:投递待发送的消息。投递成功但标记失败会导致重复发送,所以下游必须幂等
async function relayOutbox() {
  const [rows] = await pool.query("SELECT * FROM outbox WHERE status = 'PENDING' ORDER BY created_at LIMIT 100")
  for (const msg of rows) {
    await mq.publish(msg.topic, msg.payload, { messageId: msg.id })
    await pool.query("UPDATE outbox SET status = 'SENT' WHERE id = ?", [msg.id])
  }
}

面试官可能追问

TCC 和 2PC 有什么区别?

两者都是两阶段,但层次不同。2PC 是数据库层面的协议,准备阶段持有数据库锁直到提交;TCC 是业务层面的,Try 完成后本地事务就提交了,锁马上释放,资源的"预留"靠业务字段(冻结库存)表达。所以 TCC 性能更好,但要自己写三套逻辑,并处理幂等、空回滚和悬挂。

Saga 的补偿失败了怎么办?

补偿必须设计成可重试且幂等,失败后持续重试。重试多次仍失败,记录下来转人工处理,并告警。有些操作天然无法补偿(比如已经发出的短信),要把它们放在 Saga 的最后一步,前面的步骤都成功后才执行。

本地消息表和事务消息怎么选?

本地消息表不依赖特定 MQ,任何消息队列都能用,代价是多一张表和一个扫描任务,扫描还会给数据库带来压力。事务消息由 MQ 负责回查,业务代码更简洁,但要求 MQ 支持(例如 RocketMQ;Kafka 的事务解决的是"消费-处理-生产"的原子性,不是这个场景)。另一种做法是用 CDC 工具订阅消息表的 binlog 代替定时扫描。

易错点

  • 认为用了分布式事务就"强一致"了,TCC、Saga、消息表都只是最终一致
  • 本地消息表的投递是至少一次,下游不做幂等就会重复处理
  • TCC 的 Confirm、Cancel 都可能被重复调用,必须幂等
  • 先发 MQ 消息再提交本地事务,事务回滚时消息已经发出去了,这是最常见的错误写法

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。