在分布式系统架构中,双写问题(Dual Write Problem) 是一个经典且高频出现的数据一致性挑战。它指的是:当一次业务操作需要同时修改两个或多个独立系统的数据时,如何保证这些修改要么全部成功,要么全部失败,从而维持跨系统的数据一致性。
一个最典型的场景是:用户完成一笔订单支付后,系统既要更新数据库中的订单状态,又要向消息队列发送一条"支付成功"事件,供下游服务(如库存系统、通知系统、积分系统)消费。如果数据库更新成功但消息发送失败,下游服务将永远收不到通知;反之,如果消息发送成功但数据库更新失败,则会造成"虚假通知"。这两种情况都会导致严重的数据不一致。
本文将系统性地剖析双写问题的本质、难点,并深入探讨业界主流的解决方案,从应用层模式到基础设施层协议,帮助读者在实际项目中做出合理的技术选型。
双写问题并非仅指"写两次数据库",而是泛指任何需要在多个独立数据存储或服务之间进行协调写操作的场景。这些存储或服务可能包括:
以"数据库 + 消息队列"这一最经典的组合为例,用户操作涉及数据库变更和对外发送事件,如下图所示:
核心诉求:这两个操作必须保证原子性——要么全做,要么全不做。
许多开发者初次面对双写问题时,会直觉性地想到两种顺序策略:
策略A:先发消息,再写数据库
1. 发送 MQ 消息
2. 更新数据库
策略B:先写数据库,再发消息
1. 更新数据库
2. 发送 MQ 消息
Hugo 的踩坑记录:早期项目中曾采用"先写库后发消息"的方案,并简单地用 try-catch 包裹消息发送逻辑,在 catch 块中记录错误日志。结果生产环境多次出现 MQ 瞬时闪断,虽然数据库更新成功,但消息丢失,下游库存系统未扣减,导致超卖。事后复盘发现,仅靠日志记录无法保证可靠投递,必须引入持久化机制。
在数据库和消息系统都正常运行时,顺序执行两个操作确实可以都成功。问题的关键在于:如何在网络中断、MQ宕机、系统断电等故障场景下,依然保证数据库与消息的一致性。
配置 MQ 的消息持久化(Message Persistence)只能保证消息在 MQ 内部不丢失,但无法保证"消息一定被发送"这个行为本身。问题的本质是:现有的事务实现(如数据库的 ACID 事务、MQ 的事务消息)通常只能作用于单个系统内部,跨系统的事务一致性必须由应用层自行处理。
这正是双写问题的核心难点所在。
业界对双写问题的讨论非常广泛,从开源社区到商业化服务提供商都在探索解决方案:
综合各方方案,处理双写问题的思路大体可分为以下几类:
| 方案类别 | 代表模式/技术 | 侵入性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 应用层模式 | Outbox 模式 | 中 | 低 | 微服务架构,数据库+MQ |
| 基础设施层 | CDC(Change Data Capture) | 低 | 中 | 已有数据库,需反向同步 |
| 混合方案 | Outbox + CDC | 低 | 中 | 追求低侵入与领域语义 |
| 协议层 | XA 分布式事务 | 低 | 高 | 强一致性要求,性能可接受 |
| 架构风格 | 事件溯源(Event Sourcing) | 高 | 高 | 全新系统设计,事件为核心 |
| 底层协议 | 强化 TCP 持久化 | 极低 | 极高 | 自研基础设施 |
关键洞察:双写问题的解决方案——Outbox 模式,本质上就是协同式 Saga(Choreography-based Saga) 在两两服务协调中的基本模式。当涉及多个服务时,可以扩展为完整的 Saga 模式。更多 Saga 细节可参考 Sagas 模式。
Outbox 模式的核心思想是:利用数据库自身的事务能力,将"业务数据变更"和"待发送消息"绑定在同一个本地事务中。具体做法是:
outbox 表,用于记录待发送的消息。如下图所示:
关键在于步骤1和步骤2在同一个本地事务中:
BEGIN TRANSACTION;
UPDATE orders SET status = 'PAID' WHERE id = 123;
INSERT INTO outbox (topic, payload, created_at)
VALUES ('order.paid', '{"orderId":123,"amount":99.99}', NOW());
COMMIT;
1. 消息投递的幂等性
由于 Poller 可能重复投递同一条消息(例如投递后标记"已发送"前崩溃),消费者必须实现幂等性。常见做法:
messageId),消费者根据 messageId 去重。2. Poller 的设计
# 伪代码示例
while True:
pending = db.query("SELECT * FROM outbox WHERE sent = false LIMIT 100")
for msg in pending:
try:
mq.publish(msg.topic, msg.payload)
db.execute("UPDATE outbox SET sent = true WHERE id = ?", msg.id)
except Exception:
# 发送失败,不标记 sent,下次重试
log.error(f"Failed to send message {msg.id}")
sleep(1) # 控制轮询频率
3. 消息顺序保证
如果业务要求消息严格有序:
created_at 或自增 ID 排序扫描 outbox。4. 性能优化
(sent, created_at) 复合索引,加速待发送消息的查询。优点:
缺点:
Hugo 的项目经验:在支付中台项目中,我们要求所有涉及"状态变更+事件通知"的服务统一接入 Outbox 组件。通过封装一个 Spring Boot Starter,将 outbox 表操作和 Poller 逻辑下沉到公共库中,业务代码只需调用
eventPublisher.publish(event),框架自动完成事务绑定和后续投递。这样将侵入性降到了最低,全团队 20+ 个服务在 2 周内全部完成改造。
更多关于 Outbox 模式的细节,可以参考 Outbox 模式。
CDC(变更数据捕获)本质上是对 Outbox 模式的泛化实现。它不侵入业务逻辑,而是通过监听数据库的变更日志(如 MySQL 的 binlog、PostgreSQL 的 WAL、MongoDB 的 oplog),自动捕获数据变更并转化为事件发送到消息系统。
如下图所示:
以 PostgreSQL 的 Logical Replication 为例:
数据库写操作 → WAL/binlog → CDC 连接器 → Kafka → 下游消费者
尽管 CDC 方案听起来很理想,但在实际应用中存在几个关键问题:
1. 泄露实体结构
CDC 采集的是存储层的数据变更,直接暴露内部数据表结构。虽然可以在发布消息前进行格式转换,但这会增加整体复杂度。
Hugo 的内部约定:我们在使用 CDC 时,规定 CDC 发布的事件必须经过一层"领域事件转换器",将原始表变更映射为业务语义化的领域事件。例如,将
users表的UPDATE status='ACTIVE'转换为UserActivatedEvent,包含用户ID、激活时间等业务字段,而非直接暴露表结构。
2. 并非所有事件都有数据变更
CDC 只能捕获数据存储层的变更,但:
本质上,领域事件是业务对象,而 CDC 采集的是存储层数据。想要让 CDC 发布的事件真正符合领域模型,本质上是要做一次 ORM 的逆运算。
3. 过度解耦的风险
CDC 可以屏蔽下游对上游数据库的依赖,但并非所有依赖都应该被屏蔽。例如,在订单服务(Order)和支付服务(Payment)之间,这是一个业务强依赖:支付服务必须知道订单的存在和状态。用 CDC 这种极度松耦合的模式,可能会把本来应该显式声明的依赖强行消解掉,导致系统架构意图模糊。
4. 数据库能力限制
CDC 严重依赖于数据库本身的能力。以 PostgreSQL Logical Replication 为例[6]:
| 限制项 | 说明 |
|---|---|
| 对象类型 | 只支持普通表,不支持序列、视图、物化视图、外部表、分区表和大对象 |
| 操作类型 | 只支持 DML(INSERT、UPDATE、DELETE),不支持 TRUNCATE、DDL |
| 表配置 | 需要同步的表必须设置 REPLICA IDENTITY,不能为 NOTHING(默认是 DEFAULT),且表中必须包含主键 |
| 版本兼容 | 发布端和订阅端的 PostgreSQL 主版本必须一致 |
Outbox 模式的缺点和 CDC 的优点正好互补:
| Outbox 缺点 | CDC 优点 |
|---|---|
| 业务侵入性(需维护 outbox 表) | 不侵入业务逻辑 |
| 需编写 Poller 逻辑 | 自动捕获变更 |
| 领域语义清晰(业务定义事件) | 领域语义模糊(存储层事件) |
因此,不难得出一个集合二者优点的方案:
用 Outbox 存放对外的领域事件,然后利用 CDC 将 Outbox 中的数据发送到消息系统中。
这样设计的优势在于:
其基本设计如下图所示:
1. 业务事务:UPDATE 业务表 + INSERT outbox 表(领域事件)
2. 数据库 WAL/binlog 记录 outbox 插入
3. CDC 连接器捕获 outbox 变更
4. 连接器将事件发送到 Kafka
5. 下游消费者消费领域事件
关于这个实现方案的更多细节,可以参考这篇文章。
如果消息中间件把自己模拟成数据库,并支持数据库的 XA 分布式事务协议,便可以让消息发送与数据库变更在同一个分布式事务中完成。XA 协议由 X/Open 组织提出,定义了分布式事务处理的规范,通过两阶段提交(2PC)保证跨多个资源管理器的事务原子性。
1. 开启全局事务(Transaction Manager)
2. 注册数据库资源(XA Resource 1)
3. 注册 MQ 资源(XA Resource 2)
4. 执行业务 SQL(数据库分支事务)
5. 发送 MQ 消息(MQ 分支事务)
6. 两阶段提交:
- Phase 1(Prepare):询问所有资源是否可提交
- Phase 2(Commit):所有资源确认后统一提交
| 中间件 | XA 支持情况 | 备注 |
|---|---|---|
| RocketMQ | ✅ 支持事务消息 | 通过半消息 + 回查机制实现[7] |
| endurox | ✅ 支持 XA | 开源事务处理框架 |
| RabbitMQ | ❌ 不支持 | 官方明确不推荐在 RabbitMQ 中使用 XA[8] |
| ActiveMQ | ❌ 不推荐 | 性能影响严重 |
| Kafka | ❌ 不支持 | Kafka 设计哲学与 XA 不兼容 |
更常见的消息中间件(RabbitMQ、ActiveMQ、Kafka)均不支持或不推荐 XA 事务。原因也很简单:影响性能[8:1]。
XA 两阶段提交的网络往返和协调开销,在高并发场景下会成为严重瓶颈。此外,2PC 还存在协调者单点故障、阻塞等问题。因此,XA 分布式事务通常只在强一致性要求且并发量可控的场景中使用。
Hugo 的技术选型建议:在支付核心链路中,如果确实需要强一致性,优先考虑 RocketMQ 的事务消息(基于半消息机制,性能优于纯 XA)。对于一般业务场景,Outbox 模式的性能和复杂度平衡更好。
Martin Fowler 在《Event Sourcing》一文中这样阐述[9]:
The official system of record can either be the event logs or the current application state. If the current application state is held in a database, then the event logs may only be there for audit and special processing. Alternatively the event logs can be the official record and databases can be built from them whenever needed.
事件溯源的核心是将事件本身作为系统的中心,而非数据库中的当前状态。所有状态变更都通过追加事件来实现,系统的当前状态可以通过重放所有事件来重建。
如果不以数据库为系统的中心,而将事件本身作为数据的中心,架构如下:
工作流程:
如果对于"事件自产自消"的行为有顾虑(即同一个服务既生产事件又消费事件来更新自己的数据库),也可以把消费事件持久化到数据库的过程放在独立的节点来执行:
Debezium 官方博客有一篇很好的对比文章[10]:
| 维度 | Event Sourcing | CDC |
|---|---|---|
| 事件来源 | 应用层主动生成领域事件 | 数据库层被动捕获数据变更 |
| 事件语义 | 业务语义,表达"发生了什么" | 技术语义,表达"数据怎么变了" |
| 事件存储 | 事件是唯一的真相来源 | 数据库是真相来源,事件是派生 |
| 应用侵入性 | 高(需重构为事件驱动) | 低 |
| 适用场景 | 全新系统设计 | 已有系统改造 |
事件溯源作为一种系统架构风格,是关系整个系统设计的重大决策,一般并不会为"解决双写问题"而引入事件溯源。 这里只是为了解决方案的完整性将其列出。在实际项目中,需要有更好的理由来引入事件溯源,例如:
- 需要完整的审计追踪(Audit Trail)
- 需要支持时间旅行查询(Temporal Query)
- 需要多维度读模型(CQRS 模式)
- 系统天然适合事件驱动(如交易撮合、游戏状态同步)
以上方案都是应用层方案。应用层方案只能基于现有技术栈的能力来解决问题。但双写问题之所以产生,有两种主要原因:
如果能通过自定义网卡驱动对 TCP 协议本身进行强化,让连接本身做持久化,断掉之后能自动重连并且自动续传,然后所有应用层再基于这个加强版 TCP 协议去做应用层协议,也可以避免双写问题产生。
但是:这种做法的成本极高,需要从底层协议栈到所有中间件应用全部自研才能实现,没有现成的开源实现可以参考。目前仅在极少数超大规模基础设施团队中有类似探索(如 Google 的 TCP BBR、自定义 RDMA 协议等)。
随着云原生技术的发展,一些新的思路正在涌现:
1. Dapr 的 Pub/Sub 组件
Dapr(Distributed Application Runtime)提供了统一的消息发布抽象,其 Pub/Sub 组件可以与多种 MQ 后端集成,并通过 Sidecar 模式简化应用代码。
2. 云数据库的内置 CDC
AWS Aurora、Azure Cosmos DB、阿里云 PolarDB 等云数据库开始内置 CDC 能力,用户无需部署额外的 Debezium 等组件,直接在控制台配置即可将变更流式输出到消息服务。
3. Serverless 事件架构
AWS EventBridge、Azure Event Grid 等 Serverless 事件总线服务,提供了托管的事件路由和转换能力,可以与数据库触发器、API 网关等集成,降低自建事件基础设施的成本。
面对双写问题,如何选择合适的方案?以下决策树可供参考:
是否需要强一致性(金融级)?
├── 是 → 并发量是否可控?
│ ├── 是 → RocketMQ 事务消息 / XA 分布式事务
│ └── 否 → Outbox 模式 + 幂等消费者
│
└── 否 → 是否已有成熟数据库基础设施?
├── 是 → 是否希望低侵入?
│ ├── 是 → CDC(Debezium)
│ └── 否 → Outbox 模式
│
└── 否 → 是否全新系统设计?
├── 是 → 事件溯源(Event Sourcing)
└── 否 → Outbox 模式(最通用)
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 一般微服务(数据库+MQ) | Outbox 模式 | 简单、可靠、通用性强 |
| 已有数据库,需反向同步 | CDC | 不侵入业务,自动捕获 |
| 追求低侵入+领域语义 | Outbox + CDC | 二者优点结合 |
| 支付/金融核心链路 | RocketMQ 事务消息 | 强一致性,性能可接受 |
| 全新系统设计,事件为核心 | 事件溯源 | 架构层面统一,但成本高 |
| 超大规模,自研基础设施 | 底层协议强化 | 终极方案,但投入巨大 |
在我们团队的工程规范中,对双写问题的处理有以下约定:
坑1:Outbox 表无限膨胀
早期实现中未清理已发送的 outbox 记录,导致表在 3 个月内增长到数千万行,查询性能急剧下降。
解决方案:
(sent, created_at) 复合索引。坑2:CDC 事件顺序错乱
使用 Debezium 捕获 PostgreSQL 变更时,由于多个表并发变更,Kafka 中的事件顺序与业务发生顺序不一致,导致下游状态机处理异常。
解决方案:
causationId 和 correlationId,下游可通过因果链重建顺序。坑3:消息格式演进
Outbox 中存储的消息格式在业务迭代中发生变更,但旧格式消息仍在 Poller 的待发送队列中,导致序列化失败。
解决方案:
schemaVersion)。双写问题是分布式系统中数据一致性的经典挑战。本文系统性地梳理了从应用层到基础设施层、从协议层到架构风格的多种解决方案:
在实际项目中,Outbox 模式通常是最佳起点——它简单、可靠、不依赖特定中间件,且已有成熟的开源实现。随着系统演进,可以逐步引入 CDC 或考虑事件溯源等更高级的架构风格。
关键在于:没有银弹。选择方案时需要综合考虑一致性要求、性能需求、团队能力、现有基础设施和改造成本。
Message Queuing and the Database: Solving the Dual Write Problem — Cockroach Labs ↩︎
Avoiding dual writes in event-driven applications — RedHat Developers ↩︎
Saga Orchestration for Microservices Using the Outbox Pattern — InfoQ ↩︎
Achieving reliable dual writes in distributed systems — RazorPay Engineering ↩︎
PostgreSQL Logical Replication — 阿里云开发者社区 ↩︎
RocketMQ 事务消息 — Apache RocketMQ 官方文档 ↩︎
Should I use XA — Apache ActiveMQ 官方文档 ↩︎ ↩︎
Event Sourcing — Martin Fowler ↩︎
Event Sourcing vs CDC — Debezium 官方博客 ↩︎