一致性事务:从 2PC 到 Outbox pattern

技术 2025-11-28 23:08 5885 字 · 15 分钟 1823

前言:分布式系统里最让人头疼的事

如果你做过一点微服务开发,大概率都遇到过这种场景:

用户下单 → 扣库存 → 扣余额 → 更新积分。

这听上去很简单,按顺序写几行逻辑就好,但问题是——这些操作分散在不同的服务、不同的数据库里。
于是问题来了:
如果中间有一步失败了,整个流程该怎么办?

  • 订单创建了,但库存没扣?
  • 库存扣了,但支付失败?
  • 消息没发出去,用户看不到状态?

这就是经典的分布式事务一致性问题。
今天我们就从最早的 2PC(两阶段提交)开始,一路讲到现在大家更常用的 Outbox 模式,看看这一路我们是怎么在”理想”和”现实”之间反复拉扯的。

一、理想中的完美方案:2PC(两阶段提交)

2PC,全称 Two Phase Commit。
听起来就很有那味儿:让多个数据库像一个事务一样提交或回滚。

它是怎么工作的?

顾名思义,它分两步走:

  1. Prepare 阶段(投票阶段)
    协调者(Coordinator)告诉所有参与者(数据库、服务):”兄弟们,准备一下,看你们能不能提交。”
    每个参与者检查本地事务是否能成功,如果没问题,就写好日志、锁住资源,回复一句”我准备好了(YES)”。如果不行,就回复”NO”。

  2. Commit/Abort 阶段(执行阶段)

    • 如果所有人都回复”YES”,协调者就广播”Commit!”,大家正式提交。
    • 如果任何一个回复”NO”或超时,协调者就广播”Abort!”,大家全部回滚。

听上去很完美,对吧?所有操作都能保持一致,要么都成功,要么都失败。

举个例子

假设我们有订单服务、库存服务、支付服务。
在一次下单操作里:

  1. 协调者发送”Prepare”命令。
  2. 三个服务各自检查:订单能建、库存够、余额足。
  3. 都返回”YES”。
  4. 协调者发送”Commit”,大家一起完成事务。

理想中,这就是”强一致性”的完美世界。

可惜,现实很骨感

2PC 在实际应用中面临严重的工程问题:

1. 阻塞问题

参与者在 Prepare 阶段后会持有锁并等待协调者的最终指令。如果协调者响应慢或网络延迟,所有参与者都会阻塞,严重影响系统吞吐量。

2. 单点故障

  • 如果协调者在发送最终决定前崩溃,参与者会因超时而回滚(相对安全)
  • 但如果协调者在发送 Commit/Abort 的过程中崩溃(部分参与者收到消息,部分没收到),就会导致数据不一致:
    • 收到 Commit 的节点已经提交
    • 没收到消息的节点还在等待或已超时回滚

3. 网络问题

在网络状况的影响下,部分参与者可能收不到协调者的消息,导致长时间持锁或数据不一致。

4. 性能问题

同步阻塞的特性导致:

  • 资源锁定时间长
  • 并发能力差
  • 响应时间受最慢节点影响

在高并发的互联网系统里,这种全局锁和同步等待非常致命。
所以 2PC 在实际分布式场景中,主要用于数据库内部实现,很少在应用层使用。

一句话总结:

2PC 理论完美,工程地狱。它保证了强一致性,但牺牲了可用性和性能。

二、退一步:Saga 模式(补偿事务)

既然”强一致”做不到,那我们退一步,追求”最终一致“。
这就是 Saga 模式的思路。

核心思想

不再要求”原子性地回滚”,而是让每个服务各自完成自己的本地事务。
如果中间某一步失败,就执行 “补偿操作”(Compensation),把前面的动作”撤销”掉。

换句话说:

不锁资源,出错就补。用业务逻辑补偿代替技术回滚。

Saga 的两种实现方式

1. 编排式(Orchestration)

由一个中央协调器负责调度各个步骤:

订单服务(协调器):
1. 调用库存服务扣库存
2. 调用支付服务扣款
3. 调用积分服务加积分
4. 如果任一步失败,依次调用补偿接口

2. 编舞式(Choreography)

各服务通过事件驱动自行协作:

订单服务 → 发布"订单已创建"事件
库存服务 → 监听事件,扣库存,发布"库存已扣"事件
支付服务 → 监听事件,扣款,发布"支付成功"事件
如果支付失败 → 发布"支付失败"事件
库存服务 → 监听到失败事件,执行库存补偿

举个例子:下单流程 Saga 化

假设业务流程:

  1. 创建订单(本地事务)
  2. 扣库存(本地事务)
  3. 扣余额(本地事务)
  4. 增加积分(本地事务)

如果第 3 步(扣余额)失败了,那就按相反顺序触发补偿逻辑:

  • 恢复库存(补偿操作)
  • 取消订单(补偿操作)

需要注意的关键问题

1. 补偿操作的设计难度

  • 每个正向操作都要有对应的补偿操作
  • 补偿操作必须幂等(可能被重复调用)
  • 补偿操作可能不完美,需要业务妥协

2. 某些操作难以或无法补偿

  • ❌ 已发送的短信通知(无法撤回)
  • ❌ 已发放且被使用的优惠券
  • ❌ 已调用的第三方支付(需要走退款流程,不是真正的”撤销”)
  • ❌ 已发出的实物货品

3. 中间状态可见性

Saga 过程中,其他事务可能看到不一致的中间状态:

  • 订单已创建,但支付还在处理中
  • 库存已扣,但订单被取消(正在补偿)

这要求业务设计时考虑这些中间态。

优缺点

✅ 优点:

  • 性能好:不需要全局锁,各服务独立执行
  • 可用性高:单个服务故障不会阻塞整个流程
  • 适合长流程:支持跨天、跨周的业务流程
  • 松耦合:各服务可以独立演进

❌ 缺点:

  • 补偿逻辑复杂:每个操作都要设计补偿,代码量翻倍
  • 调试困难:分布式环境下追踪问题链路长
  • 无法保证隔离性:中间状态对外可见
  • 某些场景不适用:存在不可补偿的操作

一句话总结:

Saga 是”可控的混乱”:牺牲了强一致性和隔离性,换来了性能和可用性。适合大部分业务场景,但需要精心设计。

三、现实中更优雅的方案:Outbox Pattern(事件表)

再往后,很多系统变成了事件驱动架构。
这时一个新问题出现了:

我更新了数据库后,要发一条消息到 MQ,但如果发 MQ 时系统挂了怎么办?

比如:

  1. 创建订单(DB 成功)
  2. 发送 “OrderCreated” 消息到 Kafka(失败或未执行)

数据库里已经有订单,但消息没发出去,下游服务不知道有新订单,系统就不一致了。

你可能会想:那我先发消息,再写数据库?

1. 发送消息到 Kafka(成功)
2. 创建订单(失败)

这样更糟:消息发出去了,但数据库里没有订单,下游收到的是”幽灵订单”。

核心问题是:这是两个不同的系统(数据库 + 消息队列),无法在一个事务中原子性地完成。

这时,Outbox Pattern 登场。

核心思路

既然我们不想搞分布式事务,那就干脆:

把”要发的消息”也当作业务数据写进数据库,和业务操作一起提交!

具体做法:

  1. 业务操作 + 消息落库(在同一个本地事务中)

    • 插入/更新业务数据(如订单表)
    • 同时插入一条待发送的消息到 outbox_messages 表
  2. 后台异步发送

    • 定时任务/CDC/消息中继扫描 outbox_messages 表
    • 找到未发送的消息
    • 发送到 MQ
    • 标记为”已发送”

这样,数据库保证了业务数据和消息记录的原子性,即使系统崩溃,重启后也能继续发送未完成的消息。
这是图片

举个实际例子

业务代码:

BEGIN TRANSACTION;

-- 1. 插入订单
INSERT INTO orders (id, user_id, amount, status) 
VALUES (123, 42, 100, 'created');

-- 2. 插入待发送的消息(同一个事务)
INSERT INTO outbox_messages (
    id, 
    aggregate_type, 
    aggregate_id, 
    event_type, 
    payload, 
    status,
    created_at
) VALUES (
    'msg-uuid-001',
    'Order',
    123,
    'OrderCreated',
    '{"order_id":123,"user_id":42,"amount":100}',
    'pending',
    NOW()
);

COMMIT;

异步消息发送器:

// 定时任务或 CDC 流程
func publishOutboxMessages() {
    for {
        // 查询待发送的消息(带锁,避免并发冲突)
        messages := db.Query(`
            SELECT id, event_type, payload 
            FROM outbox_messages 
            WHERE status = 'pending'
            ORDER BY created_at ASC
            LIMIT 100
            FOR UPDATE SKIP LOCKED
        `)
        
        for msg := range messages {
            // 先标记为处理中
            db.Exec(`
                UPDATE outbox_messages 
                SET status = 'processing' 
                WHERE id = ?
            `, msg.id)
            
            // 发送到消息队列
            err := kafka.Send(msg.eventType, msg.payload)
            
            if err != nil {
                // 失败,重置状态,稍后重试
                db.Exec(`
                    UPDATE outbox_messages 
                    SET status = 'pending', 
                        retry_count = retry_count + 1 
                    WHERE id = ?
                `, msg.id)
            } else {
                // 成功,标记为已发送
                db.Exec(`
                    UPDATE outbox_messages 
                    SET status = 'sent', 
                        sent_at = NOW() 
                    WHERE id = ?
                `, msg.id)
            }
        }
        
        time.Sleep(1 * time.Second)
    }
}

关键技术点

1. 消息幂等性

消费者必须能处理重复消息(Outbox 可能重发):

  • 每条消息带唯一 ID (类似幂等 Key)
  • 消费者用消息 ID 去重
  • 或设计其他幂等的业务操作

2. 消息顺序

如果业务需要保证顺序:

  • 按 created_at 顺序发送
  • 或使用分区键保证同一实体的事件有序

3. Outbox 表清理

已发送的消息要定期清理或归档:

DELETE FROM outbox_messages 
WHERE status = 'sent' 
  AND sent_at < NOW() - INTERVAL 7 DAY;

4. 使用 CDC (Change Data Capture) 优化

可以用 Debezium 等 CDC 工具监听数据库变更日志,自动捕获 Outbox 表的插入事件并发送到 Kafka,无需轮询。

优缺点

✅ 优点:

  • 不需要分布式事务:利用本地事务保证一致性
  • 高性能:异步解耦,不阻塞主流程
  • 可靠性高:消息不会丢失,故障恢复能力强
  • 实现相对简单:比 Saga 的补偿逻辑简单

❌ 缺点:

  • 有一定延迟:消息发送是异步的(通常毫秒到秒级)
  • 需要额外维护 Outbox 表:定期清理,避免膨胀
  • 消费者需要幂等:可能收到重复消息
  • 增加数据库负载:每次业务操作都要写额外的消息记录

一句话总结:

Outbox Pattern 是一种”落地级方案”:用一个本地事务表巧妙地解决了”数据库 + 消息队列”的一致性问题,既保证可靠性,又能保持高性能。是现代事件驱动架构的标配。

⚖️ 四、三种方案对比总结

如何选择?

📌 单体应用内的操作
   → 用本地数据库事务(ACID)

📌 跨服务的业务流程(如下单、支付、发货)
   → 用 Saga(编排式或编舞式)

📌 数据库操作 + 发送消息到 MQ
   → 用 Outbox Pattern

📌 关键金融交易(转账、结算)
   → 考虑 TCC (Try-Confirm-Cancel) 或事件溯源

❌ 避免在应用层使用 2PC
   → 除非是数据库底层实现,或极小规模的强一致场景

五、写在最后:一致性没有完美解

现实世界里,没有完美的”分布式一致性”方案。
不同业务场景,不同需求下,我们都在平衡:

  • 一致性 vs 性能
  • 简单 vs 灵活
  • 强一致 vs 最终一致
  • 理论完美 vs 工程可行

真正的目标不是”完美一致”,
而是”在可接受的时间窗口内,达到业务所需的一致程度”。

工程上的务实原则

  1. 能用单体事务就别分布式
    如果功能可以在一个服务内完成,就用本地事务,不要为了”微服务”而微服务。

  2. 异步优于同步
    大部分业务场景都能接受”最终一致”,几秒的延迟换来的是更高的性能和可用性。

  3. 补偿优于回滚
    现实业务中很多操作本身就无法”撤销”,补偿是更符合业务语义的方式。

  4. 监控和可观测性是关键
    分布式系统出问题是常态,重要的是能快速发现、定位和恢复。

  5. 测试、测试、还是测试
    补偿逻辑、幂等性、重试机制都要充分测试,包括网络分区、节点宕机等异常场景。

如果你在设计”订单 + 库存 + 支付”这样的系统:

✅ 用 Saga 编排业务流程(订单创建 → 扣库存 → 扣款 → 发货)
✅ 用 Outbox 保证每个服务的数据变更能可靠地发布事件
✅ 让消费者实现幂等,处理可能的重复消息
✅ 设计好监控和告警,及时发现补偿失败或消息积压

这,就是工程上的”务实一致性”。

参考资料: