一致性事务:从 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 https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html