一致性事务:从 2PC 到 Outbox pattern
前言:分布式系统里最让人头疼的事
如果你做过一点微服务开发,大概率都遇到过这种场景:
用户下单 → 扣库存 → 扣余额 → 更新积分。
这听上去很简单,按顺序写几行逻辑就好,但问题是——这些操作分散在不同的服务、不同的数据库里。
于是问题来了:
如果中间有一步失败了,整个流程该怎么办?
订单创建了,但库存没扣?
库存扣了,但支付失败?
消息没发出去,用户看不到状态?
这就是经典的分布式事务一致性问题。
今天我们就从最早的 2PC(两阶段提交)开始,一路讲到现在大家更常用的 Outbox 模式,看看这一路我们是怎么在”理想”和”现实”之间反复拉扯的。
一、理想中的完美方案:2PC(两阶段提交)
2PC,全称 Two Phase Commit。
听起来就很有那味儿:让多个数据库像一个事务一样提交或回滚。
它是怎么工作的?
顾名思义,它分两步走:
Prepare 阶段(投票阶段)
协调者(Coordinator)告诉所有参与者(数据库、服务):”兄弟们,准备一下,看你们能不能提交。”
每个参与者检查本地事务是否能成功,如果没问题,就写好日志、锁住资源,回复一句”我准备好了(YES)”。如果不行,就回复”NO”。
Commit/Abort 阶段(执行阶段)
如果所有人都回复”YES”,协调者就广播”Commit!”,大家正式提交。
如果任何一个回复”NO”或超时,协调者就广播”Abort!”,大家全部回滚。
听上去很完美,对吧?所有操作都能保持一致,要么都成功,要么都失败。
举个例子
假设我们有订单服务、库存服务、支付服务。
在一次下单操作里:
协调者发送”Prepare”命令。
三个服务各自检查:订单能建、库存够、余额足。
都返回”YES”。
协调者发送”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 化
假设业务流程:
创建订单(本地事务)
扣库存(本地事务)
扣余额(本地事务)
增加积分(本地事务)
如果第 3 步(扣余额)失败了,那就按相反顺序触发补偿逻辑:
恢复库存(补偿操作)
取消订单(补偿操作)
需要注意的关键问题
1. 补偿操作的设计难度
每个正向操作都要有对应的补偿操作
补偿操作必须幂等(可能被重复调用)
补偿操作可能不完美,需要业务妥协
2. 某些操作难以或无法补偿
❌ 已发送的短信通知(无法撤回)
❌ 已发放且被使用的优惠券
❌ 已调用的第三方支付(需要走退款流程,不是真正的”撤销”)
❌ 已发出的实物货品
3. 中间状态可见性
Saga 过程中,其他事务可能看到不一致的中间状态:
订单已创建,但支付还在处理中
库存已扣,但订单被取消(正在补偿)
这要求业务设计时考虑这些中间态。
优缺点
✅ 优点:
性能好:不需要全局锁,各服务独立执行
可用性高:单个服务故障不会阻塞整个流程
适合长流程:支持跨天、跨周的业务流程
松耦合:各服务可以独立演进
❌ 缺点:
补偿逻辑复杂:每个操作都要设计补偿,代码量翻倍
调试困难:分布式环境下追踪问题链路长
无法保证隔离性:中间状态对外可见
某些场景不适用:存在不可补偿的操作
一句话总结:
Saga 是”可控的混乱”:牺牲了强一致性和隔离性,换来了性能和可用性。适合大部分业务场景,但需要精心设计。
三、现实中更优雅的方案:Outbox Pattern(事件表)
再往后,很多系统变成了事件驱动架构。
这时一个新问题出现了:
我更新了数据库后,要发一条消息到 MQ,但如果发 MQ 时系统挂了怎么办?
比如:
创建订单(DB 成功)
发送 “OrderCreated” 消息到 Kafka(失败或未执行)
数据库里已经有订单,但消息没发出去,下游服务不知道有新订单,系统就不一致了。
你可能会想:那我先发消息,再写数据库?
1. 发送消息到 Kafka(成功)
2. 创建订单(失败)
这样更糟:消息发出去了,但数据库里没有订单,下游收到的是”幽灵订单”。
核心问题是:这是两个不同的系统(数据库 + 消息队列),无法在一个事务中原子性地完成。
这时,Outbox Pattern 登场。
核心思路
既然我们不想搞分布式事务,那就干脆:
把”要发的消息”也当作业务数据写进数据库,和业务操作一起提交!
具体做法:
业务操作 + 消息落库(在同一个本地事务中)
插入/更新业务数据(如订单表)
同时插入一条待发送的消息到 outbox_messages 表
后台异步发送
定时任务/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 是一种”落地级方案”:用一个本地事务表巧妙地解决了”数据库 + 消息队列”的一致性问题,既保证可靠性,又能保持高性能。是现代事件驱动架构的标配。
⚖️ 四、三种方案对比总结
|模式|一致性保证|性能|复杂度|隔离性|典型场景|
|—|—|—|—|—|—|
|2PC|强一致|中低
(同步阻塞)|中
(协议简单但容错复杂)|有
(持锁)|数据库内部事务
小规模强一致场景|
|Saga|最终一致|高
(无全局锁)|高
(补偿逻辑、状态机、幂等)|无
(中间态可见)|长事务业务流程
订单、支付、审批|
|Outbox|最终一致|高
(异步解耦)|中
(消息表管理)|不适用|事件驱动系统
可靠消息投递
数据库-MQ 一致性|
如何选择?
📌 单体应用内的操作
→ 用本地数据库事务(ACID)
📌 跨服务的业务流程(如下单、支付、发货)
→ 用 Saga(编排式或编舞式)
📌 数据库操作 + 发送消息到 MQ
→ 用 Outbox Pattern
📌 关键金融交易(转账、结算)
→ 考虑 TCC (Try-Confirm-Cancel) 或事件溯源
❌ 避免在应用层使用 2PC
→ 除非是数据库底层实现,或极小规模的强一致场景
五、写在最后:一致性没有完美解
现实世界里,没有完美的”分布式一致性”方案。
不同业务场景,不同需求下,我们都在平衡:
一致性 vs 性能
简单 vs 灵活
强一致 vs 最终一致
理论完美 vs 工程可行
真正的目标不是”完美一致”,
而是”在可接受的时间窗口内,达到业务所需的一致程度”。
工程上的务实原则
能用单体事务就别分布式
如果功能可以在一个服务内完成,就用本地事务,不要为了”微服务”而微服务。
异步优于同步
大部分业务场景都能接受”最终一致”,几秒的延迟换来的是更高的性能和可用性。
补偿优于回滚
现实业务中很多操作本身就无法”撤销”,补偿是更符合业务语义的方式。
监控和可观测性是关键
分布式系统出问题是常态,重要的是能快速发现、定位和恢复。
测试、测试、还是测试
补偿逻辑、幂等性、重试机制都要充分测试,包括网络分区、节点宕机等异常场景。
如果你在设计”订单 + 库存 + 支付”这样的系统:
✅ 用 Saga 编排业务流程(订单创建 → 扣库存 → 扣款 → 发货)
✅ 用 Outbox 保证每个服务的数据变更能可靠地发布事件
✅ 让消费者实现幂等,处理可能的重复消息
✅ 设计好监控和告警,及时发现补偿失败或消息积压
这,就是工程上的”务实一致性”。
参考资料:
Martin Fowler - The Transactional Outbox Pattern
Chris Richardson - Saga Pattern
Designing Data-Intensive Applications by Martin Kleppmann





