别再迷信“两地三中心”了:没有幂等全局化的多云容灾,大概率只是昂贵幻觉

0 阅读5分钟

作者: jiangbodev
发布时间: 2026-06-01
技术栈: .NET 10 + Saga + Outbox + PostgreSQL + Kafka/RabbitMQ + OpenTelemetry
阅读时长: 13 分钟

先说一个会被喷的观点:

很多团队做的“多云跨地域容灾”,本质是把单点故障升级成“双倍成本故障”。

因为他们只做了这些表面动作:

  • 双活流量切换
  • 多地域部署
  • 复制数据库

却没有解决真正要命的底层问题:

  • 事件跨区复制后重复消费怎么办?
  • 幂等键在跨区域如何全局唯一?
  • 跨区补偿怎么防止“补偿打架”?
  • 演练时怎么证明切流后业务一致性还成立?

这篇我不讲空泛“架构大图”,只讲我把 Saga + Outbox 扩展到多云跨地域后,如何保证:

  1. 切流不丢账
  2. 灾备不重放
  3. 补偿不互撞
  4. 演练可验收

1. 多云容灾里最容易被忽略的真问题

在单地域里你能靠“本地一致性 + 可靠重试”兜住很多事。

一旦跨地域,问题会立刻复杂化:

  1. 网络分区下,复制延迟不可避免
  2. 双写窗口下,事件顺序无法天然保证
  3. 回切时,旧链路可能重放历史消息
  4. 两边都触发补偿时,可能互相抵消或重复执行

所以我把目标从“看起来可用”改成“可证明一致”:

  • 任意单区故障后,业务最终一致
  • 单次业务流程在全局只生效一次
  • 回切后不会出现补偿风暴

2. 架构原则:控制面可切,数据面必须可判定

我最终采用的原则只有两条:

  1. 流量路由可以切,但业务判定规则不能变
  2. 区域可以降级,但幂等和补偿语义必须全局一致

核心组件:

  • 区域内 Saga Runtime(本地执行)
  • 区域内 Outbox(本地事务落库)
  • 跨区 Event Replicator(异步复制)
  • Global Idempotency Store(全局去重判定)
  • DR Controller(切换与回切策略)
flowchart LR
    A[Region A Services] --> OA[(Outbox A)]
    B[Region B Services] --> OB[(Outbox B)]
    OA --> RA[Replicator A->B]
    OB --> RB[Replicator B->A]
    RA --> EB[Event Bus B]
    RB --> EA[Event Bus A]
    A --> G[(Global Idempotency)]
    B --> G
    C[DR Controller] --> A
    C --> B

3. 第一关键:幂等键全局化(不是区域内唯一)

很多事故都出在“区域内唯一键”被跨区复制打穿。

我用的幂等键规范:

idempotency_key = hash(tenant_id + saga_id + step + business_key + version)

要求:

  1. 在所有区域共享判定语义
  2. 写入采用“先判定再执行业务”
  3. 判定库支持 TTL 与分区归档

伪代码:

var key = IdempotencyKey.Build(tenantId, sagaId, step, businessKey, version);
if (await globalStore.ExistsAsync(key, ct))
    return;

await ExecuteBusinessAsync();
await globalStore.MarkAsync(key, ttl: TimeSpan.FromDays(30), ct);

这一步是跨区“只生效一次”的基础,没有它容灾基本不成立。


4. 第二关键:跨区补偿防冲突(Compensation Fencing)

跨区最怕什么?

A 区失败触发补偿,B 区切流后又触发一次补偿,最后把业务状态补到错误方向。

我做了“补偿栅栏”机制:

  1. 每个 Saga 实例维护全局补偿代号 comp_epoch
  2. 补偿执行前先抢占 fencing token
  3. 只有 token 持有者可执行补偿
  4. 失效 token 的补偿请求直接拒绝

简化表结构:

CREATE TABLE saga_fencing (
  saga_id        VARCHAR(64) PRIMARY KEY,
  comp_epoch     BIGINT NOT NULL,
  owner_region   VARCHAR(32) NOT NULL,
  token          VARCHAR(64) NOT NULL,
  expires_at     TIMESTAMPTZ NOT NULL
);

这套机制把“重复补偿”问题从业务代码里抽离成平台规则。


5. 第三关键:复制策略要分“事件类型等级”

不是所有事件都需要同等级跨区复制。

我按业务价值分级:

  • L1(资金/库存):强一致语义,优先复制,回放窗口长
  • L2(订单状态):准实时复制,可短延迟
  • L3(通知/统计):可降级,允许批量补偿

策略示例:

eventReplication:
  L1:
    mode: near-realtime
    maxLag: 5s
    replayWindow: 72h
  L2:
    mode: async
    maxLag: 30s
    replayWindow: 24h
  L3:
    mode: batch
    maxLag: 5m
    replayWindow: 6h

这样做的收益很现实:核心链路保真,非核心链路控成本。


6. 切流与回切:必须是“状态机”而不是“人工流程”

我把容灾切换定义成状态机:

  • Normal
  • PrepareFailover
  • Failover
  • Stabilizing
  • PrepareFailback
  • Failback

每一步都绑定门槛:

  1. outbox 积压阈值
  2. 复制延迟阈值
  3. 幂等拒绝率阈值
  4. 补偿失败率阈值

只要一个指标超阈,状态机禁止前进。


7. 演练策略:每月一次“跨区一致性”强制演练

我把演练固定成 3 个剧本:

剧本 A:单区失联

  • 注入:Region A 与消息总线网络中断 10 分钟
  • 验收:Region B 接管后核心流程成功率不低于阈值

剧本 B:重复回放

  • 注入:强制重放最近 30 分钟 L1/L2 事件
  • 验收:全局幂等拒重命中率符合预期,无重复业务生效

剧本 C:双区补偿竞争

  • 注入:同时触发同一 Saga 的补偿请求
  • 验收:仅一个 fencing token 生效,无双补偿

8. 成本与可靠性平衡:别把容灾做成无限烧钱

跨区后账单会上涨,这很正常。

我控制成本的方法:

  1. 核心链路高保障,非核心链路分级降配
  2. 复制保留窗口按事件等级配置
  3. 观测数据跨区只保留关键标签
  4. 回切后自动执行“沉积消息清算”避免长期堆积

我关注两个核心指标:

R=跨区一致性达成率,UCdr=CdrNworkflowR = \text{跨区一致性达成率}, \quad UC_{dr} = \frac{C_{dr}}{N_{workflow}}

目标不是把 UCdrUC_{dr} 压到最低,而是在 RR 达标前提下最小化 UCdrUC_{dr}


9. 我踩过的 6 个真实坑

  1. 幂等键没带版本,升级后误判重复
  2. 复制只测延迟,不测重放
  3. 切流只看入口可用,不看补偿队列
  4. 回切后没做消息清算,历史消息二次污染
  5. 只演练单区故障,不演练跨区补偿竞争
  6. 把“全量双活”当目标,忽略业务分级现实

10. 落地结果

指标改造前改造后
单区故障切换恢复时间18 分钟4 分钟
跨区重复生效率有明显风险接近 0
补偿冲突事件偶发基本消失
跨区容灾演练通过率60%93%
容灾单位成本 UC_dr1.00x0.78x

结论很直接:

多云容灾能不能真抗打,不看拓扑图,看你有没有做全局幂等和补偿栅栏。


持续更新 .NET 云原生实战: