分布式系统中的双写问题

发布: (2025年12月29日 GMT+8 20:05)
5 min read
原文: Dev.to

Source: Dev.to

(请提供您想要翻译的具体文本内容,我将为您翻译成简体中文,并保持原有的格式、Markdown 语法以及技术术语不变。)

Overview

双写问题发生在单个逻辑操作必须更新两个(或更多)独立系统时——例如,将数据持久化到数据库 并且 将事件发布到像 Kafka 这样的消息中间件。由于这些系统不共享事务协调器,实现原子“全有或全无”行为极其困难。

分布式事务协议

  • 基于共识的协议(Paxos、Raft)为状态机提供强一致性,广泛用于分布式数据库和配置存储。
  • TrueTime + Paxos(Google Spanner)提供全局 ACID 保证。
  • 在典型的微服务架构中,这类重量级协议很少被采用,导致双写挑战。

示例场景

假设一个 用户服务 需要:

BEGIN;
INSERT INTO users (id, name) VALUES (...);
-- send a "UserCreated" event to Kafka
COMMIT;

如果数据库插入成功而 Kafka 发布失败(或反之),系统将处于不一致状态——一侧反映了更改,而另一侧没有。这说明了双写问题。

核心问题

  • 没有 global transaction manager 协调这两个操作。
  • 网络故障、进程崩溃或 retries 可能导致 partial updates
  • Retries 可能产生 duplicate eventsout‑of‑order processing

后果

失败模式结果
数据库写入成功,事件未发出下游服务永远不会得知新实体的存在。
事件已发出,数据库写入失败消费者会对不存在的数据进行操作。
部分重试产生重复事件或多次数据库插入。

Source:

常见解决方案 / 模式

事务性 Outbox 模式

  1. 在同一个事务中同时写入业务数据 以及 事件到同一数据库。
  2. 后台进程(或 CDC 工具)读取 “outbox” 表并将事件发布到 Kafka。

优点

  • 数据库与消息之间保持强一致性。
  • 当你同时控制存储和消息系统时实现简单。

缺点

  • 增加运维复杂度。
  • 消费者必须处理可能出现的重复事件。

更改数据捕获(CDC)

  • 使用 CDC 工具(例如 Debezium、Oracle GoldenGate)监控数据库变更并自动发出事件。

优点

  • 应用代码中无需双写逻辑。
  • 若 CDC 流程可靠,可实现强一致性。

缺点

  • 可能存在事件延迟。
  • 需要稳定的模式和可靠的 CDC 基础设施。

幂等 & 可重试安全设计

  • 将操作设计为幂等(可安全重复)。
  • 使用唯一请求 ID 并在消费者侧进行去重。

优点

  • 可在异构系统之间使用。

缺点

  • 仍需仔细设计;不能解决顺序问题。

Source:

事务性 Outbox 解决方案(详细示例)

模式

-- Business table
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    total NUMERIC NOT NULL,
    created_at TIMESTAMP DEFAULT now()
);

-- Outbox table
CREATE TABLE outbox (
    id UUID PRIMARY KEY,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP DEFAULT now(),
    published BOOLEAN DEFAULT FALSE
);

应用事务(原子写入)

import uuid, json
import psycopg2

conn = psycopg2.connect(...)
order_id = uuid.uuid4()
event = {
    "event_id": str(uuid.uuid4()),
    "type": "OrderCreated",
    "order_id": str(order_id)
}

try:
    with conn.cursor() as cur:
        # Insert business data
        cur.execute(
            "INSERT INTO orders (id, customer_id, total) VALUES (%s, %s, %s)",
            (order_id, "some-customer-id", 123.45)
        )
        # Insert outbox event
        cur.execute(
            """
            INSERT INTO outbox (id, event_type, payload)
            VALUES (%s, %s, %s)
            """,
            (event["event_id"], event["type"], json.dumps(event))
        )
    conn.commit()  # ✅ both rows are persisted atomically
except Exception as e:
    conn.rollback()
    raise

Outbox 处理器(异步发布者)

一个轻量级工作者(或 CDC 工具)会持续扫描 outbox,查找 published = FALSE 的行。对每一行:

  1. payload 发布到 Kafka。
  2. 将该行标记为 published = TRUE

如果工作者在发布过程中崩溃,事件仍保持未标记状态,将会被重新尝试,从而保证至少一次投递且不丢失一致性。

目标: 防止在向数据库写入数据并向消息中间件发布事件时出现不一致,解决双写问题。

Back to Blog

相关文章

阅读更多 »