Day 7 Hard Distributed Systems Transactions Saga / Outbox Microservices

分布式事务 — 别做,做了也尽量做小2PC / 3PC · Saga (Orchestration vs Choreography) · Transactional Outbox · Idempotency · Why Microservices Avoid Distributed Transactions

问题场景与约束

设计一个跨境电商「下单结账」流程,单次结账要同时完成 5 件事:

  1. 订单服务 写订单(自己 Postgres)
  2. 库存服务 扣库存(自己 Postgres)
  3. 支付服务 调 Stripe 扣款(外部 API,不可回滚
  4. 积分服务 扣减积分抵扣(自己 DynamoDB)
  5. 通知服务 发邮件 + Push(外部 SES / FCM)

规模:峰值 5k TPS 下单,单笔结账 P99 ≤ 800ms0 笔超卖、0 笔重复扣款。5 个服务由 5 个团队拥有,没有共享数据库。任何中间步骤都可能失败:服务挂掉、网络分区、Stripe 504、消息丢失。

问题:这 5 步如何保证『要么都成功,要么都不发生(或可观测地补偿)』?这就是分布式事务。今天讲:教科书的 2PC/3PC 为什么在生产里几乎没人用、Saga 怎么落地、Outbox 怎么解 dual-write、为什么『最佳的分布式事务就是不做』

高层架构

graph TD
    Client["客户端"]
    Orch["Saga Orchestrator
Temporal / Cadence
持久化每一步状态
"] subgraph S1["订单域"] OrdSvc["Order Service"] OrdDB[("Postgres
orders + outbox")] end subgraph S2["库存域"] InvSvc["Inventory Service"] InvDB[("Postgres
reservations + outbox")] end subgraph S3["支付域"] PaySvc["Payment Service"] Stripe["Stripe API
外部 不可回滚"] end subgraph S4["积分域"] PtsSvc["Points Service"] PtsDB[("DynamoDB
+ idempotency table")] end subgraph S5["通知域"] Notify["Notify Service"] end CDC["Debezium CDC
读 outbox 表"] Kafka["Kafka
order.events"] Client --> Orch Orch -->|1 createOrder| OrdSvc Orch -->|2 reserveStock| InvSvc Orch -->|3 charge| PaySvc Orch -->|4 deductPoints| PtsSvc Orch -.->|5 async notify| Kafka OrdSvc --> OrdDB InvSvc --> InvDB PaySvc --> Stripe PtsSvc --> PtsDB OrdDB -.-> CDC InvDB -.-> CDC CDC --> Kafka Kafka --> Notify classDef orch fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 classDef ext fill:#3a2010,stroke:#ffb450,color:#e8eef5 class Orch orch class Stripe ext

核心思路:用 Saga(Temporal/Cadence 编排)串起 5 步,每步是本地事务 + 失败时反向补偿;事件流走 Outbox + CDC(保证『DB 写成功 ⇒ Kafka 必发』);通知这类弱事务用最终一致。整个系统没有一个 2PC

关键技术点

1. 2PC / 3PC:教科书答案,生产负债

原理:两阶段提交(Two-Phase Commit)由一个 coordinator 协调多个 participant

正确性保证 ACID 的 atomicity 跨节点。问题在于:

2PC 的三大致命伤 谁还在用 2PC:传统企业 XA 事务(Oracle + MQ + 另一 Oracle,同一 DBA 团队管控)、分布式数据库内部(Spanner 跨 shard 用 2PC + Paxos,但参与者本身是 Paxos group,不阻塞)。跨微服务、跨外部 API → 永远不要 2PC
# 2PC 灾难场景 — 为什么 Pat Helland 称其为"Maginot Line"
# T = 0   coord -> all: PREPARE
# T = 10ms all -> coord: YES (各自锁住资源, 写 prepare log)
# T = 11ms coord 崩溃 ⚠
# T = ∞   participants 持锁等待 coord 重启
#         其他事务全部阻塞, 业务停摆
#
# 即使 coord 重启, 它必须先恢复 transaction log,
# 期间所有 participant 仍持锁. 这是分布式系统中
# 最糟糕的故障模式: "一个进程的死亡导致整个系统停摆".
现实案例:

2. Saga:把『一个大事务』拆成『N 个本地事务 + N-1 个补偿』

原理:1987 年 Garcia-Molina & Salem 在 SIGMOD 提出 Saga,原本是为『单库内长事务』设计(避免长时间锁)。微服务时代被借用解决跨服务事务:把跨服务事务拆成一串本地事务 T1…Tn;任一 Ti 失败,则按反向顺序执行已完成步骤的补偿事务 C(i-1)…C1

不再保证 ACID 的隔离性,但保证最终一致 + 业务语义可撤销。两种落地模式:

模式编排者优点缺点适用
Orchestration中央 orchestrator(Temporal / Cadence / Step Functions)显式调用各步骤逻辑集中可读、补偿路径清晰、易调试 / 可视化orchestrator 是新组件需维护、可能成单点步骤多(≥4)、补偿复杂、需要审计
Choreography无中心,各服务订阅事件、自主响应解耦彻底、无单点业务流分散在多服务、改一步要改 N 处、难追踪步骤少(≤3)、领域内自然事件驱动
sequenceDiagram
    participant C as Client
    participant O as Orchestrator (Temporal)
    participant Ord as Order Svc
    participant Inv as Inventory Svc
    participant Pay as Payment (Stripe)
    participant Pts as Points Svc

    C->>O: placeOrder()
    O->>Ord: 1 createOrder (PENDING)
    Ord-->>O: orderId=42
    O->>Inv: 2 reserveStock(sku, qty)
    Inv-->>O: reserved
    O->>Pay: 3 charge($100, idempKey=42)
    Pay-->>O: paid (charge_xyz)
    O->>Pts: 4 deductPoints(user, 50, idempKey=42)
    Pts--xO: 失败! 积分不足
    Note over O: Saga 反向补偿开始
    O->>Pay: C3 refund(charge_xyz, idempKey=42-refund)
    O->>Inv: C2 releaseStock(sku, qty, idempKey=42-rel)
    O->>Ord: C1 markFailed(42, reason="points")
    O-->>C: 失败: 积分不足, 已全部回滚
# Temporal Workflow 风格伪代码 (Python SDK)
@workflow.defn
class PlaceOrderSaga:
    @workflow.run
    async def run(self, req: OrderReq) -> Result:
        compensations = []          # 已成功步骤的补偿动作栈
        try:
            order = await activity.execute(create_order, req)
            compensations.append(lambda: mark_failed(order.id))

            await activity.execute(reserve_stock, req.sku, req.qty)
            compensations.append(lambda: release_stock(req.sku, req.qty))

            charge = await activity.execute(
                stripe_charge,
                amount=req.amount,
                idempotency_key=f"order-{order.id}"   # 关键: 幂等
            )
            compensations.append(
                lambda: stripe_refund(charge.id, key=f"refund-{order.id}")
            )

            await activity.execute(deduct_points, req.user, req.points,
                                   key=f"pts-{order.id}")
            return Result.ok(order.id)

        except ActivityError as e:
            # 反向执行补偿. Temporal 保证每个 activity at-least-once + retry.
            for comp in reversed(compensations):
                await activity.execute(comp, retry_policy=infinite_backoff)
            return Result.fail(reason=str(e))

# 关键性质:
#  - Temporal 把 workflow 状态持久化到 event log, 进程崩了也能 replay 接续.
#  - 每个 activity 必须 idempotent (因为可能重试).
#  - 补偿动作必须 idempotent 且不能再失败 (失败要人工介入).
补偿不是 rollback。 rollback 是『假装没发生』(DB undo log);补偿是『反向操作以达到等效效果』。付钱可以退款,但邮件发了就发了——补偿是『再发一封"抱歉刚才订单失败"』。这要求业务接受『可见的中间状态』,是 saga 与 ACID 最大的语义差异。
现实案例:

3. Outbox Pattern:消灭『dual write』,把分布式事务降级为本地事务

问题:服务要『写 DB』+『发 Kafka 事件』。两个动作不在一个事务里,必有不一致窗口

这就是 dual write problem,是微服务最常见的事务陷阱。

Outbox 解法

  1. 业务表和 outbox 表在同一个本地 DB 事务里一起写。本地事务 = ACID 保证,要么都成功要么都失败。
  2. 独立的 relay 进程(Debezium 类 CDC,读 DB 的 WAL / binlog)异步把 outbox 表的新行发到 Kafka。
  3. relay 自带 offset 追踪,at-least-once 投递;消费者用 outbox 行 ID 去重。
graph LR
    App["Order Service"]
    DB[("Postgres
orders + outbox
同一本地事务")] Debez["Debezium
读 WAL"] K["Kafka
order.events"] Cons1["Inventory Consumer"] Cons2["Analytics Consumer"] Cons3["Notification Consumer"] App -- "BEGIN
INSERT orders
INSERT outbox
COMMIT" --> DB DB -. WAL .-> Debez Debez --> K K --> Cons1 K --> Cons2 K --> Cons3
# Outbox 落地 — Postgres + Debezium
CREATE TABLE orders (id BIGSERIAL PK, user_id ..., amount ..., status ...);
CREATE TABLE outbox (
    id BIGSERIAL PK,
    aggregate_id BIGINT,        -- order id (做 partition key)
    event_type TEXT,            -- "order.created" / "order.cancelled"
    payload JSONB,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- 业务代码: 写 orders 的同时往 outbox 写事件
BEGIN;
  INSERT INTO orders (...) VALUES (...) RETURNING id;
  INSERT INTO outbox (aggregate_id, event_type, payload)
       VALUES (42, 'order.created', '{"user":7,"amount":100}');
COMMIT;        -- 原子: 两表要么都成功要么都失败

-- Debezium 连接 Postgres replication slot,
-- 读 WAL → 监听 outbox 表的 INSERT → 转换 → 推 Kafka.
-- 关键: 业务代码不需要知道 Kafka 存在, 完全解耦.

# 为什么不直接读业务表的 CDC?
#  - 业务表 schema 变更频繁, 下游强耦合
#  - 一行业务变更可能对应 0 个或多个领域事件 (insert order 可能触发
#    "order.created" + "loyalty.points.eligible"). outbox 让产事件
#    粒度由业务代码控制.

# 为什么不直接 Kafka producer?
#  - Producer.send() 跟 DB COMMIT 不在一个事务里, dual write 问题
#  - 即使有 Kafka transactions, 也不能跨 Postgres + Kafka 两个系统.
Outbox 的 trade-off
现实案例:

4. Idempotency:分布式事务的『最后防线』

原理:分布式系统里所有 RPC、消息、回调都是 at-least-once——网络超时无法区分『请求丢了』还是『响应丢了』,重试是必然。幂等性保证『同一操作执行 N 次的效果等同于执行 1 次』,是 saga 重试、outbox 重投、补偿动作的安全网。

实现方式原理适用
天然幂等的 opSET x=5, DELETE id=42 — 多次执行结果一样能写成 set 的尽量 set
Idempotency Key + 去重表客户端生成 UUID,服务端记 (key → result),重试返回缓存结果支付、订单、外部回调(Stripe 标杆)
乐观锁 / 版本号UPDATE … WHERE version=X,冲突丢弃同一资源的并发更新
消费端去重消费 Kafka 时按 event_id 写 dedup 表,重复跳过outbox / event sourcing 消费者
# Stripe 风格 idempotency key 服务端实现 (Postgres)
# 参考: https://stripe.com/blog/idempotency

CREATE TABLE idempotency (
    key TEXT PRIMARY KEY,
    request_hash TEXT NOT NULL,     -- 防 key 重用不同请求体
    response_code INT,
    response_body JSONB,
    status TEXT,                    -- 'in_progress' | 'completed'
    locked_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT now()
);

def handle(key, req):
    h = hash(req.body)
    row = db.fetch_for_update("SELECT * FROM idempotency WHERE key=%s", key)

    if row and row.request_hash != h:
        raise Conflict("idempotency key reused with different body")

    if row and row.status == 'completed':
        return row.response_body                # 直接返回缓存

    if row and row.status == 'in_progress':
        if now() - row.locked_at > LEASE:
            pass                                # lease 过期, 可重试
        else:
            raise Conflict("request in flight")

    # 占位
    db.upsert("idempotency", key=key, request_hash=h,
              status='in_progress', locked_at=now())

    try:
        resp = do_business(req)
        db.update("idempotency", key=key,
                  status='completed', response_code=200,
                  response_body=resp)
        return resp
    except RetryableError:
        # 不更新, 让客户端重试
        raise
    except FatalError as e:
        db.update("idempotency", key=key,
                  status='completed', response_code=500,
                  response_body={"error": str(e)})
        raise

# 过期策略: Stripe 保留 24h, 之后清理. 客户端不应假设 key 永久存在.
现实案例:

扩展与优化

常见陷阱与面试问题

1. 把 Saga 当 ACID:忘了隔离性消失。 Saga 期间订单可能处于『已下单未支付』中间态,被读取后业务可能基于错误状态决策。要么用 semantic lock 标记 pending,要么业务接受『可见的中间状态』并设计 UX 兜底。
2. 补偿动作不幂等 / 自身可失败。 补偿失败比正向失败更尴尬——补偿本就是为兜底而生。补偿必须 idempotent,且失败要走人工或 DLQ,绝不能进入无限重试死循环
3. 直接『发 Kafka + 写 DB』当成原子。 这是初级工程师最常踩的坑。问到这题面试官期待你立刻说出『dual write problem』和 Outbox。
4. Idempotency key 用 timestamp / 自增 ID 当 key。 客户端重试要用同一个 key。如果客户端每次重试生成新 timestamp,去重就失效——key 必须在请求生成时固定,重试时复用。
5. 用 2PC 跨外部 API(Stripe / 邮件 / 微信支付)。 外部 API 不支持 XA。这种场景必须 saga + 补偿 + idempotency 三件套。

面试可能追问:

  1. 详述 2PC 阻塞场景:coordinator 在 phase 1 ACK 后挂掉,participants 怎么办?3PC 怎么改进、为什么仍不流行?
  2. Saga 与 ACID 事务在隔离性上的区别?给一个 Saga 看到中间态导致的业务 bug 例子。
  3. Outbox vs 直接 Kafka transactions 区别?为什么生产更多选 Outbox?
  4. 设计一个『跨服务转账』:服务 A 扣 100、服务 B 加 100。给 saga 补偿流程 + 失败模式分析。
  5. Idempotency key 的过期策略?过期后客户端再用同一 key 重试会发生什么?怎么避免重复扣款?
  6. 『微服务要不要避免分布式事务』——你如何回答?给一个『把分布式事务转化为领域重新划分』的例子。

深入资源

深入思考

1. 跨服务转账:A 扣 100、B 加 100。设计 saga + 失败模式分析。如果 B 加完后 saga orchestrator 自己挂了,怎么办?

这是 saga 最经典的考题。核心是『编排器自身的故障也要可恢复』,否则只是把单点从 2PC coordinator 挪到 saga orchestrator,没解决任何问题

正向流程

  1. T1: A.debit(100, idempKey=tx-42)
  2. T2: B.credit(100, idempKey=tx-42)
  3. completed

补偿:T2 失败 → C1: A.credit(100, idempKey=tx-42-comp)(不是 refund,是反向 credit)。

关键失败模式

  • T1 完成后 orch 挂了:恢复时从 event log replay,看到 T1 已完成,继续 T2。前提是 orch 状态必须每步持久化(Temporal 的 event sourcing 模型)。
  • T2 调用发出后 orch 挂了,不知 B 是否成功:恢复后重试 T2,B 端用 idempotency key 去重——如果上次已成功,返回相同结果;如果上次没收到请求,正常处理。这是 idempotency 的核心价值
  • T2 永远失败(B 服务长期不可达):补偿 C1。但要警惕『补偿也失败』——A 服务挂了怎么办?工业实践:补偿动作进入 DLQ + 告警,工程师介入;不要让 saga 无限重试。Temporal 默认有 max attempts。
  • T1 完成但 outbox 没写:不可能。如果用了 outbox,T1 = (UPDATE accounts; INSERT outbox),是同一事务原子。

更深的反思:这个例子常被用来证明 saga 弱。但在真实银行系统里,转账压根不靠分布式事务——它靠 双重记账(double-entry bookkeeping):账本不是『改 A 改 B』而是『记一条 debit、记一条 credit』,两条记录的总和必为 0。任何不平衡靠日终对账发现。这是几百年的金融工程智慧,远早于分布式数据库。启示:很多『需要分布式事务』的需求,换个数据模型就消失了

2. Saga 的隔离性消失了——给一个『读到中间态导致 bug』的真实例子。怎么解决?

场景:电商 saga 步骤为『扣库存 → 扣余额 → 发货』。用户 A 触发了一个 saga,扣库存已成功(库存 -1),扣余额还没开始。此时另一查询接口被调用,读到库存 -1 的状态,推荐系统判断『库存紧张』,给用户 B 推送『限时抢购』。但 A 的 saga 接下来扣余额失败,触发补偿(库存 +1)。

结果:用户 B 收到了基于从未真正发生过的库存减少的营销推送,可能立即下单后却发现库存其实是足的——业务逻辑紊乱。这就是 saga 期间『中间态泄漏』导致的隔离性违反。ACID 事务下,未提交事务对其他事务不可见,不会有这种问题

解决方案,按代价从低到高

  1. Semantic Lock(Chris Richardson):库存表加 pending 列。saga 期间标记 pending=true,其他读取者明确知道『这是 saga 中间态』,自行决定如何处理(推荐系统可以忽略 pending 行)。
  2. Commutative Operations:把『扣库存』改成『预留 +1』『发货 -1』,让顺序无关。补偿就是『预留 -1』,不会留下 ghost state。
  3. Reorder Steps:把不可逆 / 高副作用的步骤往后挪。能补偿的(DB 写)先做,不可补偿的(发邮件、调外部 API)最后做。
  4. Versioning:每次 saga 写带 saga_id + version,读取者可按 saga 状态决定可见性(类似 MVCC)。

深层原理:Saga 用『最终一致 + 可见中间态』换『跨服务可行 + 高吞吐』。如果业务无法容忍中间态可见,这部分数据就不该跨服务——应该合在一个服务内用本地事务。这又回到了『领域边界画对』。

3. Outbox 引入了『从 DB 到 Kafka 的秒级延迟』。如果业务需要『订单写入后立即被搜索索引到』怎么办?给 3 个方案。

真实场景:用户下完单要立刻在『我的订单』搜得到。如果 outbox + Debezium 有 2 秒 lag,用户会困惑『刚下的订单去哪了』。这是同步性需求异步 outbox 的冲突。

方案 1:双读策略(推荐,便宜)

  • 『我的订单』先查搜索索引,额外 查主 DB 拿出最近 N 分钟的订单合并展示。
  • 客户端 / API gateway 聚合两个数据源。
  • 代价:多一次 DB 查询,但只查当前用户的最近订单(高度命中索引,< 5ms)。
  • 用户体验:完美 RYW;下游搜索系统压力小(不需要『立即同步』)。

方案 2:双写 + 容忍不一致(适合『可丢一点点』数据)

  • 业务代码同时写 DB 和 ES。承认有概率不一致,靠对账批处理修复。
  • 代价:dual write 问题——但如果业务能容忍『搜索结果偶尔少一条』,可接受。
  • 不要用在金融场景。

方案 3:同步 Outbox(重,仅特殊场景)

  • 业务 commit 后同步调 Kafka producer,确认 ACK 才返回客户端。outbox 作为兜底。
  • 代价:写延迟从 5ms 涨到 30-100ms(Kafka 跨 broker 同步);Kafka 故障会拖垮写路径。
  • 适用:必须严格『写后秒级可读』的场景(金融对账、监管报表)。

更深的反思:『立即一致』几乎总是过度设计。Google、Amazon 的搜索都有索引延迟。问业务方『真的 1 秒都不能等吗?』——80% 情况下答案是 5 秒可接受。剩下 20% 用方案 1(双读)就能搞定。把『立即一致』当默认是性能与可用性的隐形税。

4. 跨章节:Day 6 (Consistency) 说『按数据类型选一致性』,今天说『避免分布式事务』。把两者结合,为同一个电商系统画出『一致性 + 事务策略』地图。

这是架构师必备的『分层决策』能力。同一个系统的不同子域,一致性 + 事务策略都该独立选

子域一致性 (Day 6)事务策略 (Day 7)实现
库存扣减Linearizable单 DB 本地事务 + 行锁 / CASPostgres SELECT FOR UPDATE 或 Spanner
下单结账(跨 5 服务)Eventual + 补偿Saga (Temporal orchestration)每步本地事务 + 反向补偿 + idempotency
支付(调 Stripe)外部事务,至多一次扣款Idempotency key + retry + 对账Stripe key 复用 saga_id + 日终对账
订单状态推送(搜索索引、推荐、消息)EventualOutbox + Kafka + at-least-once 消费者Debezium → Kafka → 多 consumer
『我的订单』展示Read-your-writes查主 DB + 二级索引兜底session token 携带 LSN + 双读
用户行为日志 / 埋点Eventual + 可丢失fire-and-forget + 批量本地 buffer → 异步发 Kafka

架构总图

  • 『硬性正确性』数据(库存、余额、订单状态)→ 单服务内本地 ACID。
  • 『跨服务流程』→ Saga + Outbox + Idempotency 三件套。
  • 『下游派发』→ Eventual via Kafka。
  • 『展示层一致性』→ 客户端 token + 双读兜底。

反模式

  1. 全系统强一致(『安全点』):上 Spanner 跨大洲,每笔结账 P99 飞涨,转化率掉。
  2. 全系统最终一致(『简单点』):超卖、重复扣款、客服爆炸。
  3. 到处分布式事务:耦合严重、故障传播,一个服务挂全系统挂。

架构师的核心能力:能在 5 分钟内为一个新业务画出这张表,每一行的选择都有数字支持(QPS、SLO、违反代价)。这才是『资深』和『架构师』的真正分界。

5. 反直觉:『最好的分布式事务就是不做』——给一个『把分布式事务通过领域重新划分消灭』的真实例子。

这是 Pat Helland 论文的精髓,也是『微服务设计成熟度』的标志。当你发现 90% 流程都跨同两个服务、saga 越写越复杂时,边界画错了

真实案例 1:电商『订单 + 库存』曾被拆成两服务

  • 初版:订单服务、库存服务分离。每次下单都要 saga 协调。
  • 痛点:90% 的请求都跨两个服务,saga 补偿逻辑占代码 40%,故障率升高,运维成本极高。
  • 重构:合并为『订单履约服务』(同一 DB 同一团队),订单与库存在同一本地事务,SELECT FOR UPDATE 一行库存 + INSERT 订单,5ms 完成。saga 完全消失。
  • 启示:『微服务越拆越好』是误区。边界应该划在『独立演进 + 独立扩缩 + 独立失败』的地方,不是『代码模块边界』。

真实案例 2:金融转账用『双重记账』取代分布式事务

  • 常见错误设计:账户 A 服务、账户 B 服务,转账要分布式事务。
  • 更优设计:所有账户在一个『账本服务』,账本表只 INSERT 不 UPDATE,每笔交易写两行(debit / credit),sum=0 即正确。
  • 读余额 = SUM(credits) - SUM(debits)(可加快照)。
  • 『跨账户转账』退化为单服务单事务的两行 INSERT,完全消灭分布式事务。这是 Stripe Ledger、Square 等真实账本系统的核心设计。

真实案例 3:用『事件溯源』把多服务状态汇聚成单源真相

  • 多服务各自维护状态 → 跨服务一致性问题层出。
  • 改用 event sourcing:所有服务写事件到中央 event store(Kafka / EventStoreDB),每个服务从事件流物化自己的 view。
  • 『跨服务事务』变成『追加一条事件』,本地操作。view 不一致由消费者自行处理。
  • 例:Netflix 部分系统、LinkedIn 部分系统、几乎所有现代金融科技。

哲学层面的洞察:分布式系统的复杂度从不消失,只能转移。Saga 把『coordinator 复杂度』转移到『补偿逻辑复杂度』;双重记账把『跨服务事务』转移到『数据模型设计』;event sourcing 把『跨服务一致性』转移到『事件 schema 演进』。

架构师的成熟标志:识别『复杂度藏在哪里』,并把它放在最容易理解和演进的位置。不是消灭复杂度,而是把它放在恰当的地方。这一原则不仅适用于分布式事务,适用于所有系统设计决策——本质上和『把不一致放在 UX 层、把一致性放在 DB 层』是同一种思维。