DDIA · 复制

Ch.6: 多机同份数据 — 复制不难, 难的是"变更如何传播": 同步还是异步, 滞后怎么兜底, 并发冲突怎么收敛

单主复制: 写走 leader, 变更沿日志传播 Leader 唯一接受写 同步复制 异步复制 Follower 1 lag≈0 · 可读 Follower 2 lag=2s · 读到旧值 异步复制: leader 宕机 → 已确认的写可能还没到 follower → 丢失 failover 三险: 丢写 · 脑裂 (双主) · 超时判定难 — 需 quorum + fencing 复制日志三形式: 语句日志 / WAL 物理日志 / 逻辑(行级)日志 — 第三种可被 CDC 消费 无主复制 (Dynamo 风格): quorum 相交 n=3 副本 A B C w=2 (绿: 写到 A,B) w + r > n 2 + 2 = 4 > 3 ✓ 读写集合必有交集 读时取最新版本 配套三件套: read repair: 读时顺手回写旧副本 hinted handoff: 宕机节点代收写 anti-entropy: 后台比对补差 sloppy quorum: 保可用但不保可读 w=1: 快但可能读到旧值; w=n: 强但一节点挂就不可写 — 取舍写在配置里 n=3, w=2, r=2: 容 1 节点故障, 读到最新值的概率 = 读写集合必交 复制延迟的三种"用户可见症状" 读己之写 read-your-writes: 刚发的帖子自己看不见 修: 用户自己的读走 leader / 记录时间戳按滞后筛选副本 / 客户端记忆 只对"用户自己的数据"生效 — 不必全网强一致 单调读 monotonic reads: 刷新一下, 时间"倒流"了 两次读打到 lag 不同的两个 follower, 后一次反而更旧 修: 每个用户固定粘住同一个副本 (sticky routing) 一致前缀读 consistent prefix: 回复先于原帖出现 因果相关的写被切到不同分区, 传播速度不同 修: 因果相关写入同一分区 / 全序广播 (见共识页) 最终一致性 eventual consistency: 停写并等待后终将一致 — "eventually" 刻意含糊 并发写冲突: 两条路收敛 LWW 最后写入者获胜 按时间戳取最大 静默丢弃另一个写! 怕时钟偏移: 时钟快 的节点赢了, 数据没了 适合: 丢得起的地方 (缓存) sibling + CRDT 合并 并发写并存为兄弟值 客户端读取后合并 CRDT: 可交换合并结构 计数器/集合/序列 各有算法 适合: 协作编辑/购物车 (Automerge) happens-before: 并发 ≠ 物理时间 B 知道/依赖 A ⇒ A happened-before B; 互不知晓 = 并发 — 与时钟读数无关 version vector: 每副本计数器集合, 判断两值是"先后"还是"并发"

三种复制拓扑

  • • 单主: 最主流 (PG/MySQL/Kafka 本质)
  • • 多主: 多 DC/离线优先 — 冲突是常态
  • • 无主: Dynamo 风格 quorum 读写
  • • 同步=不丢, 异步=低延迟, 半同步=折中

quorum 数字题

  • • n=3, w=2, r=2: 容 1 故障, 读写必交
  • • w+r>n 是最低要求, 不是一致性保证
  • • read repair / hinted handoff / 反熵
  • • sloppy quorum 保可用不保可读

延迟与冲突

  • • 三症状: 看不到自己写 / 时间倒流 / 前缀乱序
  • • 各有便宜解, 不必全库强一致
  • • LWW 静默丢数据, 时钟偏移放大
  • • 并发判定靠 happens-before, 不靠钟

💡 一句话理解

复制像课堂抄笔记: 老师 (leader) 在黑板上写, 同学们 (follower) 照抄 — 同步抄是"写一笔等你确认", 异步抄是"老师自己往下讲, 慢慢抄"; 抄写有快有慢 (复制延迟), 于是出现三种怪事: 自己刚写的字自己没看见 (读己之写)、回头看笔记发现内容"变旧了" (单调读)、同学 B 的笔记里回复排在提问前面 (一致前缀)。而无主复制像小组作业: 作业发给 n 个人, 等其中 w 个收下才算交了, 交作业时问 r 个人拿版本最新的 — 只要 w+r>n, 收作业的和发作业的必然碰面。

🧠 必知必会 必考 & 必会

replication 动机
多机保存相同副本: 就近读降低延迟、副本容错、读扩展。难点只有一个: 变更如何传播。
# 读扩展: 8 副本扛读, 1 leader 收写
# 容错: 挂 1 副本, 数据还在 (看同步/异步)
# 低延迟: 用户读就近 DC 副本
单主 / 多主 / 无主
单主: 一个 leader 接写、日志传播 — 最主流; 多主: 多节点同时接写, 冲突成常态; 无主: 客户端并行写 n 副本等 w 确认。
# 单主: PG/MySQL/Redis 主从, Kafka 单主本质
# 多主: 多 DC 各有主, 离线优先应用 (CouchDB)
# 无主: Dynamo/Cassandra/Riak, 无 failover 概念
同步 / 异步 / 半同步
同步: 等 follower 确认才返回, 不丢但慢; 异步: 不等, leader 宕机可能丢已确认写; 半同步: 一个同步 + 多个异步的折中。
# 异步丢写剧本:
# 1) 写 x=1, leader 确认
# 2) 日志还没传到 follower, leader 宕机
# 3) follower 升主 → x=0 — "已确认"的写消失了
复制日志三形式
语句日志 (传 SQL, 有不确定性函数坑)、WAL 物理日志 (耦合存储引擎版本)、逻辑日志 (行级变更, 与引擎解耦 — 可被 CDC 消费)。
# 语句日志的坑: NOW() / RAND() 各副本结果不同
# WAL: 复制必须同版本同引擎 (升级麻烦)
# 逻辑日志: {"op":"update","row":...} → Debezium
failover 三风险
leader 挂了要: 检测→选新主→重配置。三险: 新主丢写 (异步)、双主脑裂 (旧主复活)、超时判定 (慢≠死)。
# 脑裂剧本: 旧 leader 只是网络分区, 没死
# 新 leader 已选出 → 两个都接受写
# 解法: 多数派选主 + 旧主 fencing (见共识页)
最终一致性
停止写入并等待后, 副本终将一致。它是活性性质 — 只说"最终", 不说多久, 不说读到的顺序。
# "eventually" 的含糊:
#   100ms? 10min? 没人承诺
# 所以应用层要自保: 三一致性 + 幂等
三种用户可见症状
读己之写 (看不见自己的写)、单调读 (时间倒流)、一致前缀读 (因果乱序) — 各有便宜的针对性修法, 不必全库强一致。
# 读己之写: 用户数据读 leader, 其他读 follower
# 单调读:   user_id 哈希到固定副本
# 一致前缀: 因果链写同一分区
happens-before
B 知道/依赖 A ⇒ A 先于 B; 两个操作互不知晓 = 并发 — 与物理时间戳无关。判断靠版本信息, 不靠钟。
# A: 客户端1 set cart={apple}
# B: 客户端2 set cart={banana}  (基于旧值?)
# B 不知道 A → A、B 并发 → 需要合并而非覆盖
LWW 的代价
最后写入者获胜: 按时间戳取最大, 静默丢弃并发写。时钟偏移会让"慢一拍的正确数据"输给"时钟快的错误数据"。
# 节点A 时钟 +5min: cart={apple} ts=10:05
# 节点B 时钟正常:  cart={banana} ts=10:00
# LWW → banana 丢, 用户无感知 — 最危险的静默
sibling 与 CRDT
并发写并存为兄弟值 (sibling), 由客户端合并; CRDT 是"怎么合并都不冲突"的数据结构族 — 计数器/集合/序列各有算法, 保强最终一致。
# PN-Counter: 加减各一个 G-Counter, 合并取 max
counter_a = {"n1": 3, "n2": 1}
counter_b = {"n1": 2, "n2": 4}
merged = {k: max(counter_a[k], counter_b[k]) for k in ...}
# → {n1:3, n2:4} 任意顺序合并结果一致
quorum: w+r>n
n 副本写 w 读 r: 读写集合必相交, 读时取最新版本即"大概率最新"。n=3,w=2,r=2 容 1 节点故障。
n, w, r = 3, 2, 2
# 写: 等 2 个副本确认 (C 可能是旧的)
# 读: 问 3 个, 取版本最新的 2 个交集 → 见到最新写
# w+r<=n: 读可能完全错过写 — 一致性破洞
修复三件套
read repair: 读时发现旧值顺手回写; hinted handoff: 节点宕机时代收写, 恢复转交; anti-entropy: 后台定期比对副本差异补齐。
# 读 A,B (最新) + C (旧) → 顺手把 C 更新 = read repair
# C 宕机 → D 代收写并记 hint → C 回来转交
# 漏网之鱼 → 后台 anti-entropy 定期兜底
version vector
每副本每 key 的计数器集合: 比较 [A:1,B:2] 与 [A:2,B:1] — 互不压制 = 并发, 需要 sibling 合并; 完全压制 = 因果先后。
va = {"n1": 1, "n2": 0}   # n1 写过 1 次
vb = {"n1": 0, "n2": 1}   # n2 写过 1 次
# 谁也不"大于"谁 → 并发 → sibling

🏭 生产实战 real world

场景 1 · 异步复制丢写: 切主后"消失"的订单

主库宕机切换, 用户"支付成功"的订单不见了 — 异步复制的经典丢失剧本。

# 时间线还原:
# 10:00:00 写订单 x, leader 确认 (binlog 未传完)
# 10:00:00.3 leader 宕机, 提升 follower (缺 x)
# → x 永久丢失, 但用户已收到"成功"

# 修复: MySQL 半同步
-- 至少 1 个从库确认收到 binlog 才返回客户端
SET GLOBAL rpl_semi_sync_master_enabled = 1;
rpl_semi_sync_master_timeout = 1000;  -- 1s 超时降级异步

半同步是折中: 保"至少 2 节点有数据", 超时降级保可用 — 权衡要写进文档。

场景 2 · 脑裂双主: 旧主复活后的写冲突

网络抖动 30 秒, 旧 leader 没死只是失联, 恢复后发现"我是主"还狂写 — 双主诞生。

# 防护组合拳:
# 1) 选主必须多数派同意 ( minority 的"主"无效)
# 2) 旧主每次写前检查: 我还是主吗? (lease/etcd session)
# 3) 存储层 fencing: 只接受最高 epoch 的写

# MySQL 侧: Super Read Only 兜底
SET GLOBAL super_read_only = ON;  -- 疑似旧主只读
# 配合 VIP/arbitrator: 仲裁节点防止 minority 自行称主

口诀: 脑裂不是"修"好的, 是被多数派 + fencing 从机制上排除的。

场景 3 · 读己之写: 改完头像"没变"

用户改完资料刷新页面, 显示的还是旧头像 — 写进了 leader, 读到了 lag 的 follower。

# 修法 1: 用户自己的写后 N 秒内强制读 leader
if user.last_write_at > now() - 60s:
    read_from = leader          # 自己的资料
else:
    read_from = random_follower

# 修法 2: 记录写时间戳, follower 落后超过则换副本
def pick_follower(user_ts):
    for f in followers:
        if f.lag_behind() < user_ts_offset:
            return f            # 够新的副本
    return leader

范围控制: 只有"用户查看自己的资料"走 leader, 公开页面照常读副本。

场景 4 · 单调读: 刷新一下时间倒流

用户连续刷新, 先看到新评论再看到旧评论 — 两次读被 LB 打到 lag 不同的副本。

# sticky routing: 同一用户粘同一副本
def route(user_id):
    idx = hash(user_id) % len(followers)
    return followers[idx]       # 总是同一个

# 该副本故障时: 切到 lag ≤ 原副本的节点, 而不是任意节点
# 配合: 会话粘性 (session affinity) 在网关层实现

取舍: 粘副本牺牲一点均衡性, 换"用户视角时间不倒流"。

场景 5 · 一致前缀读: 回复比帖子先出现

评论分区和帖子分区复制速度不同, 好友页出现"回复挂在不存在的问题下"。

# 根因: 帖子和回复被分到不同分区, 复制进度独立

# 修法 1: 因果相关数据同分区 (partition key 用帖子 id)
-- 帖子与其回复进同一分区, 复制顺序天然一致
PARTITION BY post_id;

# 修法 2: 全序广播 — 所有写都按同一顺序可见 (强工具, 见共识页)

判断题: "先有回复后有帖"是 UI 事故, 也是因果没有同区的架构信号。

场景 6 · 复制延迟监控: lag 曲线比"复制在跑"重要

复制"正常"但 lag 涨到 40 分钟没人知道 — 用户看到的全是昨天的数据。

-- PG: 每个从库落后多少字节
SELECT client_addr,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;

-- MySQL: Seconds_Behind_Master
SHOW SLAVE STATUS LIKE 'Seconds_Behind_Master';

# 告警: lag > 30s 持续 5min → page; 写后读敏感页面降级提示

配套: 大批量删除/导数据前预估 lag, 提前把读切 leader。

场景 7 · LWW 丢数据: 购物车合并修复

两台设备并发改购物车, LWW 静默丢一个 — 改成集合并集 (CRDT 思想)。

# LWW: 手机端 add(苹果) 与 Web 端 add(香蕉) 并发
# 时间戳定胜负 → 有一台的数据凭空消失

# 修复: 购物车建模为"加集 + 删集", 合并取并集
cart_add = {"apple", "banana"}     # 两端合并
cart_del = {"pear"}                # 删除也集合并集
final = cart_add - cart_del
# 每端各自合并, 结果收敛且不丢 — OR-Set (CRDT 家族)

选型: 丢不起的数据别用 LWW; 结构能并集/计数就上 CRDT。

场景 8 · quorum 参数推演: n=3,w=2,r=2 的边界

Cassandra 键空间配置审查: 用数字推演每种配置的可用性与一致性。

# 推演表 (n=3):
# w=1,r=1: 最快;  挂1节点读可能全是旧值 ✗
# w=2,r=2: 挂1节点可读写, 读写必交 ✓  (推荐)
# w=3,r=3: 一致最强; 挂1节点不可写   ✗
# w=2,r=1: 写交了读没交 → 可能读旧    ✗

# 结论写进 schema 注释:
-- orders: RF=3, write CL=QUORUM, read CL=LOCAL_QUORUM

注意: quorum 只保证"读到最新写", 不保证跨对象约束 (那是事务/共识的事)。

场景 9 · 多端协作: CRDT 支撑离线优先应用

笔记应用手机/电脑离线各改各的, 上网后自动合并 — Automerge/Yjs 的主场。

# 每设备都是一个 leader, 离线照常编辑
doc_phone = Automerge.change(doc, d => d.text = "hello world")
doc_web   = Automerge.change(doc, d => d.text = "hello CRDT")

merged = Automerge.merge(doc_phone, doc_web)
# 序列型 CRDT (RGA) 按位置标识合并 — 不靠时钟
# 任意顺序 merge 结果一致 = 收敛性保证

代价: 元数据(操作历史)比状态本身还大 — 本地优先应用的内存税。

场景 10 · 副本漂移修复: read repair + anti-entropy 双保险

某节点宕机两天, 回来后数据全旧 — 读修复只能救被读到的 key。

# 1) 读修复 (被动): 读到旧值顺手回写
rows = read_quorum(key)      # 发现 C 是旧值
write_back(node_C, latest)   # 顺手补齐

# 2) anti-entropy (主动): 定期比对 Merkel 树
#    只同步有差异的 range, 带宽省 99%
repair_job.schedule(interval="6h", rate_limit="50MB/s")

# 3) 冷数据没流量没 job → hinted handoff 兜底转交

三层各管一段: 热数据读修复、全量反熵、宕机期间 hinted handoff。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 异步复制当强一致 — 切主丢"已确认写". 原因: 语义没对齐业务. 正解: 资金类数据半同步/同步, 或共识复制。
# 错: 支付库 async replica + 自动 failover
# 对: semisync / group replication / Raft
坑 2 · failover 无 fencing — 旧主复活继续写, 双主分叉. 原因: 只做了"选新主". 正解: 多数派选主 + 旧主 lease 检查 + epoch fencing。
# 错: VIP 漂移就完事
# 对: 写前验 lease + 只收最高 epoch
坑 3 · 脑裂靠人工救 — 半夜双主写坏数据, 早上人工对账. 原因: 机制缺位. 正解: arbiter/quorum 自动仲裁。
# 错: 告警叫人拔网线
# 对: minority 自动 read-only
坑 4 · 复制延迟无监控 — lag 40 分钟没人知道. 原因: 只看"复制进程在". 正解: lag 字节数/秒数曲线 + 告警。
# 错: 只监控 slave 进程存活
# 对: Seconds_Behind_Master > 30 → page
坑 5 · 读己之写没实现 — 改完昵称刷新"没生效", 客服工单+. 原因: 读写随意分配. 正解: 用户自属数据写后限期读 leader。
# 错: 所有读随机打副本
# 对: 写后 60s 内自己的读走 leader
坑 6 · 单调读没绑副本 — 刷新时间倒流. 原因: LB 轮询. 正解: 会话粘性/用户哈希固定副本。
# 错: 每次请求随机副本
# 对: sticky by user_id
坑 7 · 因果数据不同区 — 回复先于帖子出现. 原因: 分区只按哈希. 正解: 因果链同分区或全序广播。
# 错: posts 与 replies 各自哈希分区
# 对: partition by post_id (帖+回复同区)
坑 8 · LWW 当通用冲突解 — 购物车/文档编辑静默丢写. 原因: 实现最简单. 正解: 丢不起的用 sibling/CRDT。
# 错: 文档编辑 LWW
# 对: CRDT (RGA/Yjs) 逐操作合并
坑 9 · 拿系统时钟排序 — 时钟快 5 分钟的节点永远赢. 原因: LWW 依赖墙钟. 正解: 逻辑时钟/版本向量, 物理钟只做兜底。
# 错: ts = time.time() 定胜负
# 对: Lamport/HLC 计数器优先
坑 10 · multi-leader 乱选 — 单 DC 也上多主, 冲突风暴白白多出. 原因: 图"写入可用". 正解: 单 DC 单主, 多 DC/离线才多主。
# 错: 同机房 2 个可写节点
# 对: 跨 DC 容灾需求再上多主
坑 11 · 复制拓扑回环 — 环形/星形复制把同一变更转一圈. 原因: 无环检测. 正解: 写携带途经节点 ID, 收过的不再转发。
# 错: A→B→C→A 无限循环
# 对: 消息带 visited=[A,B], C 不回发
坑 12 · quorum 数字拍脑袋 — n=2,w=1,r=1: 挂 1 个读全旧. 原因: 没推演. 正解: w+r>n 且按故障容忍度定 n。
# 错: RF=2 w=1 r=1 "省资源"
# 对: RF=3 w=2 r=2 起步
坑 13 · sloppy quorum 语义误解 — 以为"写到代理节点=安全", 实际可能读不到. 原因: 只看写入成功. 正解: 明确 sloppy 保可用不保可读, 读侧降级提示。
# 错: w OK 就当写成功可读
# 对: UI 提示"同步中" + 后续反熵修复
坑 14 · hinted handoff 当无限可靠 — 代收节点也挂了, hint 丢. 原因: 把折中当保证. 正解: hint 是尽力而为, 恢复后必跑反熵。
# 错: "handoff 会补的" 就不管了
# 对: 恢复后强制 repair 一次
坑 15 · 读修复当备份 — 只修被读到的 key, 冷数据永远漂移. 原因: 混淆两种修复. 正解: 读修复 + 定期 anti-entropy 全量兜底。
# 错: "有 read repair 就够"
# 对: 6h 一次 anti-entropy job
坑 16 · CRDT 当万能 — 任意 JSON 直接 CRDT, 元数据爆炸. 原因: 只见收敛性. 正解: 明确数据类型 (计数器/集合/序列) 选对应 CRDT。
# 错: 整个文档状态塞 RGA
# 对: 计数器用 PN-Counter, 文本用 RGA
坑 17 · 版本向量无界 — 副本/写入者无限增长, 向量越来越长. 原因: 不做裁剪. 正解: 副本集合变更时 compact/降级时钟向量。
# 错: 每个临时设备都进 version vector
# 对: 固定副本集 + client 用独立合并通道
坑 18 · 从不演练 failover — 真切换时 40 分钟人工操作. 原因: 怕出事不敢练. 正解: 每月预发演练, SOP 秒级执行。
# 错: failover 代码三年没跑过
# 对: chaos: kill leader 每月一次
坑 19 · 从库当免死金牌 — "有从库就行", 结果从库延迟 1 小时还硬切. 原因: 混淆备份/高可用/一致性. 正解: 明确 RPO/RTO, lag 不达标不切。
# 错: lag=1h 时强切从库
# 对: 切换前校验 lag 阈值
坑 20 · 全连接拓扑 N 大写放大 — 50 节点多主全互联, 每写广播 49 份. 原因: 拓扑随手连. 正解: 环形/分层转发 + 途经检测。
# 错: 50 leader 两两互联
# 对: DC 内主从 + DC 间星形