设计一个跨境电商「下单结账」流程,单次结账要同时完成 5 件事:
规模:峰值 5k TPS 下单,单笔结账 P99 ≤ 800ms,0 笔超卖、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。
原理:两阶段提交(Two-Phase Commit)由一个 coordinator 协调多个 participant:
正确性保证 ACID 的 atomicity 跨节点。问题在于:
# 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 仍持锁. 这是分布式系统中
# 最糟糕的故障模式: "一个进程的死亡导致整个系统停摆".
原理: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 且不能再失败 (失败要人工介入).
Saga primitive,Uber 内部 1000+ 服务使用;2024 年发布 Cadence 1.0。订单履约、司机调度等关键流程都走 saga workflow。问题:服务要『写 DB』+『发 Kafka 事件』。两个动作不在一个事务里,必有不一致窗口:
这就是 dual write problem,是微服务最常见的事务陷阱。
Outbox 解法:
outbox 表在同一个本地 DB 事务里一起写。本地事务 = ACID 保证,要么都成功要么都失败。
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 两个系统.
原理:分布式系统里所有 RPC、消息、回调都是 at-least-once——网络超时无法区分『请求丢了』还是『响应丢了』,重试是必然。幂等性保证『同一操作执行 N 次的效果等同于执行 1 次』,是 saga 重试、outbox 重投、补偿动作的安全网。
| 实现方式 | 原理 | 适用 |
|---|---|---|
| 天然幂等的 op | SET 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 永久存在.
面试可能追问:
这是 saga 最经典的考题。核心是『编排器自身的故障也要可恢复』,否则只是把单点从 2PC coordinator 挪到 saga orchestrator,没解决任何问题。
正向流程:
补偿:T2 失败 → C1: A.credit(100, idempKey=tx-42-comp)(不是 refund,是反向 credit)。
关键失败模式:
更深的反思:这个例子常被用来证明 saga 弱。但在真实银行系统里,转账压根不靠分布式事务——它靠 双重记账(double-entry bookkeeping):账本不是『改 A 改 B』而是『记一条 debit、记一条 credit』,两条记录的总和必为 0。任何不平衡靠日终对账发现。这是几百年的金融工程智慧,远早于分布式数据库。启示:很多『需要分布式事务』的需求,换个数据模型就消失了。
场景:电商 saga 步骤为『扣库存 → 扣余额 → 发货』。用户 A 触发了一个 saga,扣库存已成功(库存 -1),扣余额还没开始。此时另一查询接口被调用,读到库存 -1 的状态,推荐系统判断『库存紧张』,给用户 B 推送『限时抢购』。但 A 的 saga 接下来扣余额失败,触发补偿(库存 +1)。
结果:用户 B 收到了基于从未真正发生过的库存减少的营销推送,可能立即下单后却发现库存其实是足的——业务逻辑紊乱。这就是 saga 期间『中间态泄漏』导致的隔离性违反。ACID 事务下,未提交事务对其他事务不可见,不会有这种问题。
解决方案,按代价从低到高:
pending 列。saga 期间标记 pending=true,其他读取者明确知道『这是 saga 中间态』,自行决定如何处理(推荐系统可以忽略 pending 行)。深层原理:Saga 用『最终一致 + 可见中间态』换『跨服务可行 + 高吞吐』。如果业务无法容忍中间态可见,这部分数据就不该跨服务——应该合在一个服务内用本地事务。这又回到了『领域边界画对』。
真实场景:用户下完单要立刻在『我的订单』搜得到。如果 outbox + Debezium 有 2 秒 lag,用户会困惑『刚下的订单去哪了』。这是同步性需求与异步 outbox 的冲突。
方案 1:双读策略(推荐,便宜)
方案 2:双写 + 容忍不一致(适合『可丢一点点』数据)
方案 3:同步 Outbox(重,仅特殊场景)
更深的反思:『立即一致』几乎总是过度设计。Google、Amazon 的搜索都有索引延迟。问业务方『真的 1 秒都不能等吗?』——80% 情况下答案是 5 秒可接受。剩下 20% 用方案 1(双读)就能搞定。把『立即一致』当默认是性能与可用性的隐形税。
这是架构师必备的『分层决策』能力。同一个系统的不同子域,一致性 + 事务策略都该独立选。
| 子域 | 一致性 (Day 6) | 事务策略 (Day 7) | 实现 |
|---|---|---|---|
| 库存扣减 | Linearizable | 单 DB 本地事务 + 行锁 / CAS | Postgres SELECT FOR UPDATE 或 Spanner |
| 下单结账(跨 5 服务) | Eventual + 补偿 | Saga (Temporal orchestration) | 每步本地事务 + 反向补偿 + idempotency |
| 支付(调 Stripe) | 外部事务,至多一次扣款 | Idempotency key + retry + 对账 | Stripe key 复用 saga_id + 日终对账 |
| 订单状态推送(搜索索引、推荐、消息) | Eventual | Outbox + Kafka + at-least-once 消费者 | Debezium → Kafka → 多 consumer |
| 『我的订单』展示 | Read-your-writes | 查主 DB + 二级索引兜底 | session token 携带 LSN + 双读 |
| 用户行为日志 / 埋点 | Eventual + 可丢失 | fire-and-forget + 批量 | 本地 buffer → 异步发 Kafka |
架构总图:
反模式:
架构师的核心能力:能在 5 分钟内为一个新业务画出这张表,每一行的选择都有数字支持(QPS、SLO、违反代价)。这才是『资深』和『架构师』的真正分界。
这是 Pat Helland 论文的精髓,也是『微服务设计成熟度』的标志。当你发现 90% 流程都跨同两个服务、saga 越写越复杂时,边界画错了。
真实案例 1:电商『订单 + 库存』曾被拆成两服务
真实案例 2:金融转账用『双重记账』取代分布式事务
真实案例 3:用『事件溯源』把多服务状态汇聚成单源真相
哲学层面的洞察:分布式系统的复杂度从不消失,只能转移。Saga 把『coordinator 复杂度』转移到『补偿逻辑复杂度』;双重记账把『跨服务事务』转移到『数据模型设计』;event sourcing 把『跨服务一致性』转移到『事件 schema 演进』。
架构师的成熟标志:识别『复杂度藏在哪里』,并把它放在最容易理解和演进的位置。不是消灭复杂度,而是把它放在恰当的地方。这一原则不仅适用于分布式事务,适用于所有系统设计决策——本质上和『把不一致放在 UX 层、把一致性放在 DB 层』是同一种思维。