Day 5 Hard Database Replication Consistency Failover

复制 (Replication) — 你以为复制是为了读扩展,其实是为了那一次故障Leader-Follower vs Multi-leader vs Leaderless, Sync vs Async, Replication Lag, Failover

问题场景与约束

设计一个跨大洲的协作文档平台(类 Notion / Linear / GitHub),读写比 100:1,写 ~5k QPS、读 ~500k QPS,欧美亚三大洲都有用户,欧洲用户希望读延迟 P99 < 50 ms。同时要求:

这些约束看似温和,组合在一起就把『随便加个 read replica』的方案打回原型。复制是少数几个『不复杂的方案根本无法工作』的领域——单机扛不住、主从有 lag、跨区域有光速延迟、failover 有 split-brain 风险。复制本质是为故障买保险,读扩展只是顺带的副作用

高层架构

graph TD
    subgraph US["us-east (Primary Region)"]
        APP_US["App us-east"]
        L["Leader (Primary)
所有写, 同区强一致读"] F1[("Sync Replica
同 AZ, semi-sync")] F2[("Async Replica
跨 AZ")] end subgraph EU["eu-west"] APP_EU["App eu-west"] R_EU[("Read Replica
async, lag 100-500ms")] end subgraph AP["ap-south"] APP_AP["App ap-south"] R_AP[("Read Replica
async, lag 200-800ms")] end CONSUL["Orchestrator + Consul
健康检查 / failover"] APP_US -- write --> L APP_US -- read --> L L == sync ==> F1 L -. async .-> F2 L -. async (WAN) .-> R_EU L -. async (WAN) .-> R_AP APP_EU -- read --> R_EU APP_AP -- read --> R_AP APP_EU -- write --> L APP_AP -- write --> L CONSUL -. monitor .-> L CONSUL -. monitor .-> F1 classDef leader fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 classDef sync fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef async fill:#0e2030,stroke:#5eead4,color:#e8eef5 classDef ctl fill:#1a1a30,stroke:#ffb450,color:#e8eef5 class L leader class F1 sync class F2,R_EU,R_AP async class CONSUL ctl

单 leader 写入 + 同 AZ semi-sync 副本(保 RPO)+ 跨 AZ async(保读容量)+ 跨 region async(保就近读)。Orchestrator 负责健康检查与自动 failover。

关键技术点

1. 拓扑选型:Leader-Follower vs Multi-leader vs Leaderless

原理:复制拓扑决定了写路径走哪里、冲突由谁裁决。三种基本模式:

Leader-FollowerMulti-leaderLeaderless (Dynamo)
写路径必须打 leader就近 leader客户端打 N 个副本
冲突有, 需 CRDT / LWW / 应用裁决有, sloppy quorum 时
跨区域写延迟差 (跨洋 RTT 150ms)好 (就近)好 (就近)
读一致性强 (读 leader) 或最终 (读 follower)最终可调 (R+W>N → 强)
故障切换复杂 (split-brain 风险)简单 (其他 leader 接管)不存在
典型Postgres, MySQL, MongoDB, RedisBDR Postgres, MySQL Group Repl, CouchDBCassandra, DynamoDB, Riak, ScyllaDB
怎么选:
# 多主冲突的经典 LWW (Last-Writer-Wins) 陷阱
# Region A: user.email = "alice@old.com"  at t=100
# Region B: user.email = "alice@new.com"  at t=101 (时钟漂移 +50ms 实际是 51)
# 复制后两边都取 max(timestamp) → "alice@new.com" ✓ 看似没问题

# 但是:
# Region A: balance -= 100  (扣款)   at t=200
# Region B: balance -= 200  (扣款)   at t=200 (并发)
# LWW 只保留一笔扣款! 另一笔静默丢失.
# → 数值/集合类必须用 CRDT (g-counter / OR-set) 或显式合并函数,
#   不能依赖 LWW.
现实案例:

2. Sync vs Async vs Semi-sync:一致性、延迟、可用性的三角

原理:leader 收到写之后,什么时候回 ACK 给客户端?取决于复制是同步还是异步。

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant S as Sync Replica
    participant A as Async Replica

    Note over C,A: Semi-sync (Postgres synchronous_commit=on, 1 副本)
    C->>L: write x=1
    L->>L: WAL fsync
    L->>S: stream WAL
    L->>A: stream WAL
    S-->>L: ACK (durable)
    L-->>C: OK (此时已有 2 副本)
    A-->>L: ACK (later, 延迟)

    Note over C,A: 若 S 卡住超时, 退化为 async (可用性 > 一致性)
三种语义的边界:
# Postgres semi-sync 配置 (示例)
# postgresql.conf
synchronous_commit = on
synchronous_standby_names = 'ANY 1 (replica1, replica2, replica3)'
# 含义: 等任意 1 个副本 fsync 完成即 ACK
# 若改为 'FIRST 2 (...)' 则要求按顺序 2 个副本 ACK

# ⚠️ synchronous_commit=remote_apply 比 on 多一步:
# 等副本 apply 到内存可读, 才 ACK.
# 用于"写后立刻在副本读"场景, 但代价是 RTT × 2.

# ⚠️ 关键 trap:
# 若所有 synchronous_standby 都挂, leader 永久阻塞写!
# 必须配合监控 + 手动降级 / Patroni 自动降级.
现实案例:

3. Replication Lag:你以为一致性是 0 或 1,其实是渐变光谱

原理:异步复制必然有 lag(毫秒到分钟)。lag 引发的三大异常:

  1. Read-your-writes 违反:用户更新头像后刷新,读到 follower(lag 未到),看到旧头像,以为系统坏了。
  2. Monotonic read 违反:第一次读打 follower A(已同步),第二次打 follower B(lag),数据『回到过去』。
  3. Consistent prefix 违反:副本看到 reply 在 question 之前到达(因果反转)。
四种应对策略(按强度递增):
  1. 『刚写完的请求强制读 leader』:本人写的内容在 N 秒内读 leader,其他请求读 follower。最简单、最常用。Twitter、Instagram 都这么做。
  2. Session sticky + leader pinning:用户会话粘到一个 follower,避免跨 follower 跳。配合 server-side『最后写入时间』比较。
  3. Read with replica lag bound:客户端记录上次写的 LSN/version,读时附带;副本若 LSN < client 则等待或重定向 leader。Aurora、Spanner、TiDB 支持。
  4. Causal consistency:每个写记录因果依赖,读保证看到所有依赖。学术干净、工程复杂;MongoDB 5.0+ causal consistency session 是工业实现。
# 策略 3 的实现: 客户端携带 LSN
# 写后, 服务端返回当前 LSN
write_response = leader.write(doc_id, content)
client.last_seen_lsn = write_response.lsn  # 存 session/cookie

# 读时附带 LSN, 副本若落后则等待 (有超时) 或转主
def read(doc_id, min_lsn):
    replica = pick_least_loaded_replica()
    current = replica.current_lsn()
    if current >= min_lsn:
        return replica.read(doc_id)
    elif current + ACCEPTABLE_GAP >= min_lsn:
        replica.wait_for_lsn(min_lsn, timeout=50)  # ms
        return replica.read(doc_id)
    else:
        return leader.read(doc_id)  # fallback

# 优点: 精确, 不浪费 leader 容量
# 缺点: 客户端要存 LSN; 跨设备不一致 (手机写, 电脑读不到)
#       → 用 user-level "version vector" 解决, 但更复杂
现实案例:

4. Failover:主挂了之后真正发生的事

原理:failover 包含 4 步:(a)检测主故障;(b)选新主;(c)切换路由;(d)老主复活时的角色裁定。每一步都能制造 outage

sequenceDiagram
    participant O as Orchestrator
    participant L as "Old Leader 1a"
    participant R1 as "Replica R1 sync"
    participant R2 as "Replica R2 async"
    participant App as Apps

    Note over L,R2: 正常 — L 写, R1 sync ACK, R2 lag 800ms
    L--xO: 心跳超时 10s
    O->>R1: 检查 R1 LSN
    O->>R2: 检查 R2 LSN
    Note over O: R1 lsn=1000, R2 lsn=880
选 R1 最新 O->>R1: PROMOTE O->>App: 切路由到 R1 作为新 leader R1->>R2: 重新建立复制 Note over L: L 网络恢复, 自认还是 leader
但 fencing token 已失效 O->>L: DEMOTE / kill Note over L: 旧 L 上 lsn 1000~1010 的写若未 ACK 直接丢
若已 ACK 给客户端 — 数据丢失
关键设计决策:
# Fencing token 的最小可行实现 (类 ZooKeeper / etcd lease)
# 每次主选举, epoch 递增, 写时携带
class Leader:
    epoch: int          # 由 coordinator (Consul / etcd) 颁发
    def write(self, k, v):
        coordinator.cas("epoch", expected=self.epoch)  # 验证我仍是 leader
        storage.write(k, v, epoch=self.epoch)

# 路由层 / 副本拒绝低 epoch 的写
def accept_write(req):
    if req.epoch < current_known_epoch:
        return REJECT  # 旧主的 zombie write 在此被拦
    return APPLY

# 旧主网络分区后即使复活, 它的 epoch 已是 stale,
# 任何写到副本/存储层都被拒. 这就是 fencing.
现实案例 / 教训:

扩展与优化

常见陷阱与面试问题

1. 把 read replica 当 backup:DROP TABLE 在主上,几毫秒后副本也 DROP 了。备份必须是 PITR snapshot,不是副本。
2. 副本数量 ≠ 写吞吐扩展:每加一个副本,主库 IO 和带宽都涨一份。Postgres 单主带 10+ async 副本就到瓶颈了。要写扩展去分片,不是加副本。
3. 副本只读但偷偷写:管理员『在副本上跑一个 quick fix UPDATE』,下次 failover 那个副本被提升 → 数据分歧。副本必须强制只读(default_transaction_read_only=on)。
4. 没 fencing 的 failover:旧主 GC 暂停 30 秒,新主选出后旧主醒来继续接写——split-brain,两边都赢,数据不可调和。
5. semi-sync 副本全挂时主静默卡死:Postgres 默认行为是阻塞等待,业务以为是慢查询其实是写完全停止。要么监控 sync 副本可用性 → 手动降级,要么用 Patroni 自动配置。

面试可能追问:

  1. 你设计 Notion 跨区域读副本,欧洲用户改了文档刷新页面看不到改动,怎么排查 + 修复?
  2. 解释 read-your-writes、monotonic read、consistent prefix 三种一致性,每个对应什么用户可见异常?
  3. 主库挂了,你有 3 个 follower:A lsn=1000、B lsn=995、C lsn=1000 但跨 region。选谁?为什么?
  4. Multi-leader 配置下如何处理『同一行被两个 region 同时改』?LWW / CRDT / 业务裁决各适合什么?
  5. Postgres semi-sync 把 synchronous_commitremote_applyon 多保证什么?代价?

深入资源

深入思考

1. 为什么 Spanner / CockroachDB 这种 Raft 多数派写延迟反而比传统 semi-sync 主从『更稳定』?这跟 tail latency 有什么关系?

表面看 Raft 写需要等多数派 ACK(N=3 时等 2 个),传统 semi-sync 也是等 1 个副本,应该差不多。差异在 谁来等谁

  • Semi-sync:通常等『固定那一个 sync replica』。这个副本卡了(GC、磁盘 stall、网络丢包),主库写就卡。即使另外两个副本都健康也不行。Tail latency = max(leader, sync_replica)。
  • Raft 多数派:等『最快的 N/2+1 个』。N=3 时 2/3,意味着自动避开 1 个最慢的副本。Tail latency ≈ median 副本延迟,而非 max。

这就是分布式系统经典的 tail tolerance 设计:通过『多数派 + 不指定哪个』,把单节点尾延迟从 critical path 移除。Google 在 Spanner 论文里详细讨论过这点,Aurora 6 副本要求 4/6 ACK 也是同样思路(自动跳过最慢两个)。

反过来代价是:(a)至少 3 节点(2 节点 Raft 退化),成本更高;(b)实现复杂度(leader election、log compaction、配置变更)远超主从。所以小规模 OLTP 仍偏 semi-sync,超大规模 / 全球部署用 Raft 类。从 semi-sync 到 quorum 是『可用性 - 复杂度』曲线上的明确升级,而非简单替换

2. GitHub 2018 那次 outage,技术上『自动 failover 不要跨区域』就能避免吗?还是更深层?为什么 Orchestrator 没识别那是『43 秒网络分区』而不是『真主库挂了』?

表层结论确实是『不要跨区域 auto-failover』,但更深层是 分布式系统中『故障检测』本质是不完美的——FLP 不可能定理告诉我们,异步系统中不可能既正确又完整地检测故障。43 秒网络分区和『主库真挂』在 follower 视角完全一样:心跳没了。

  • 选择是 trade-off,不是『正确答案』:等更久(比如 60 秒)→ RTO 变差;不等 → 误判率高。GitHub 当时阈值是几秒。
  • 真正的根因是『一致性边界 ≠ 故障检测边界』。跨区域时,detector 在一个区域,主在另一区域,detector 看不到主,但主自己还活着、还在接受写(被网络隔离的客户端的写)。这种『活分区』是最危险的。
  • 修复思路(GitHub 后来采用):(a)跨区域 failover 必须人工确认;(b)每个 region 内部用 Raft 选主,跨 region 只做 read replica,不参与选主;(c)增加 quorum-style fencing——新主必须能联系多数 region 才接受写。
  • 系统设计哲学:『可恢复性』优于『高可用性』。多扛 5 分钟 downtime 远好于 24 小时数据修复。Auto 自动化时,要为最坏情况设计——『如果它判错了我能多快回滚』比『它有多准』更重要。

类比:自动驾驶车在城市道路。Tesla 选『大胆自动 + 偶尔追尾』;Waymo 选『谨慎自动 + 必要时人工接管』。GitHub 那次是前者哲学撞墙,转向后者。分布式系统的成熟度不是『多自动化』而是『多懂得在哪里停下来等人』

3. 容量估算:100 万 DAU 的 SaaS,主 Postgres 10k 写 QPS、读 50k QPS。CFO 想省钱问:『真的需要 5 个 read replica 吗?能不能砍到 2 个?』给一个有数字的分析。

砍副本前要把『副本承担什么职责』拆开。一刀切回答都是错的。

副本的 5 种职责,每种对副本数的需求不同:

  • 读容量扩展:50k 读 QPS / 单副本承载力(假设 15k QPS) = 至少 4 副本。砍到 2 个直接读爆。
  • HA / failover 备份:至少 1 个 semi-sync 同 AZ,不能复用读流量节点(避免 failover 时读容量瞬间减半)。
  • 分析查询隔离:BI / 数据导出跑在专用副本,避免影响在线读。常 1 个 dedicated。
  • 地理就近读:每个用户密集区域 1 个。欧美亚就是 3 个。
  • 备份 / DR:1 个跨 region async 用于灾备。

分项加总:4(读)+ 1(HA sync)+ 1(分析)+ 跨 region 多个 ≈ 7-8。当前 5 个已经压缩了。

砍到 2 个的真实后果

  • 读容量从 60k 降到 30k QPS(含 leader 兜底),峰值流量直接挂;
  • 分析查询打到 OLTP 节点,慢查询拖累整库;
  • 失去地理就近读,欧洲用户 P99 从 50ms 涨到 200ms;
  • failover 时唯一可用副本被全流量打爆,可能雪崩。

替代方案(真省钱)

  1. 缓存(Redis)拦住 70% 读 → 副本数可以从 4 降到 2;
  2. 分析查询走 CDC → ClickHouse / BigQuery,副本不再背 BI;
  3. 跨 region 读副本只在欧洲(最大市场)保留,亚洲降级走 us-east + 边缘 CDN;
  4. HA 副本用便宜机型(不承读流量,只要追平 WAL)。

给 CFO 的回答:不能盲砍。但可以把 8 万/月(5 副本 × r6i.4xlarge)优化到 4 万/月——前提是先投资 Redis 集群、CDC 管线、监控。这是『把成本从硬件移到工程』,工程一次性投入,长期省。

『副本数』从来不是孤立配置,是整个读路径架构的结果。

4. CRDT 听起来是 multi-leader 冲突的银弹(自动合并、无需协调)。它有什么硬性限制?为什么 Google Docs / Figma 即使用 OT/CRDT 也仍然需要一个 server-side authority?

CRDT(Conflict-free Replicated Data Type)确实把『合并』数学化,但有几条硬约束让它不能替代 server authority:

  • (a) 只对『结构化数据』有效。Counter、Set、Map 这种有代数性质(结合律、交换律、幂等)的可以;复杂业务规则(库存扣减需要 ≥ 0)、有副作用(发邮件)的写无法 CRDT 化。
  • (b) 元数据膨胀。OR-Set 等需要为每个元素记 actor+timestamp,删除后还要保留 tombstone 防止『复活』;大文档 CRDT 元数据是内容本身的 10-100 倍,长期使用必须 GC,GC 又要协调——回到协调问题。
  • (c) 不能表达『最近发生』。CRDT 合并是 commutative,意味着不知道事件真实顺序。『先收到一条消息,再回复』在 CRDT 视角可能颠倒(commutative 不保 causal)。
  • (d) 权限、审计、计费需要 ground truth。两个客户端都加了同一个 doc 的协作者,CRDT 合并的并集是正确的,但『谁先加』『何时加』决定了计费起点——必须有 server 仲裁。
  • (e) 实时协作要『其他人正在做什么』的 awareness。光标位置、选区高亮不是 CRDT 状态(这些是 ephemeral),必须有 hub 转发。

Figma 的真实架构:CRDT 处理图形对象(图层 z-order、节点属性 CRDT 化),但 server 负责会话管理、权限、版本快照、跨用户协调。Server 不是『状态权威』而是『协调与持久化中心』。Google Docs 类似——OT 算法在客户端协调编辑,server 负责接受、序列化、广播 + 保存。

结论:CRDT 是『多写不冲突合并』的工具,不是『完全无 server』的架构。它把 server 的角色从『仲裁每一次写』降到『协调元事件 + 持久化』。理解这一点能避免『用 CRDT 重写一切』的陷阱。真正 leaderless 的系统几乎不存在;只是 leader 的职责被压缩到了最小

5. 跨章节:分片 (Day 4) + 复制 (Day 5) 一起部署,故障域怎么算?比如 32 shard × 3 副本 = 96 节点,一个 AZ 挂了,业务可用性是?

分片和复制是正交概念但故障域必须一起算。简单情况下:

  • 跨 AZ 部署:每个 shard 的 3 个副本分布在 3 个 AZ。一个 AZ 挂 → 每个 shard 都失去 1 副本,剩 2/3 副本 → 仍可写(多数派存活)。可用性几乎 100%。
  • 同 AZ 部署:3 副本都在同 AZ → 一个 AZ 挂 = 整个 shard 失效。32 shard 全挂 = 业务完全不可用。
  • 混合(最常见):leader + 1 sync replica 同 region 不同 AZ;1 async replica 跨 region。AZ 挂 → 失去 leader 或 sync replica 之一,failover 到剩余可用副本。

关键洞察:分片放大了 AZ 故障的影响面。单库时 AZ 挂只影响 1 个库;32 shard 时 AZ 挂触发 32 次并发 failover——orchestrator 容量、网络容量、客户端重试都被同时压测。GitHub、Slack 都遇到过『AZ 挂 → 多 shard 同时 failover → 控制面被打爆 → 整体雪崩』。

缓解措施

  • Failover 流量整形(rate limit promotion to 5 shard/sec);
  • 客户端重试加 jitter + exponential backoff,避免 thundering herd;
  • 定期演练 AZ 级故障(Chaos engineering),暴露隐藏的同步串行点;
  • 容量规划按『1 AZ 挂 + 1 故障域同时不可用』算(N-2 而非 N-1)。

数量级:96 节点跨 3 AZ 均匀分布,单 AZ 挂理论 100% 可用,但实际通常下降到 95-98%(部分 shard failover 期间 5-30 秒不可写、客户端重试风暴、监控告警噪声)。Multi-region 部署(active-active 跨 region)能进一步把单 region 故障降到 100% 可用,但代价是冲突合并 + 跨区域写延迟。

面试金句:『分片是水平扩展,复制是故障保险,两者解决不同问题且互相放大对方的复杂度——任何只谈一个的方案都是不完整的』。