Ch.6: 多机同份数据 — 复制不难, 难的是"变更如何传播": 同步还是异步, 滞后怎么兜底, 并发冲突怎么收敛
复制像课堂抄笔记: 老师 (leader) 在黑板上写, 同学们 (follower) 照抄 — 同步抄是"写一笔等你确认", 异步抄是"老师自己往下讲, 慢慢抄"; 抄写有快有慢 (复制延迟), 于是出现三种怪事: 自己刚写的字自己没看见 (读己之写)、回头看笔记发现内容"变旧了" (单调读)、同学 B 的笔记里回复排在提问前面 (一致前缀)。而无主复制像小组作业: 作业发给 n 个人, 等其中 w 个收下才算交了, 交作业时问 r 个人拿版本最新的 — 只要 w+r>n, 收作业的和发作业的必然碰面。
# 读扩展: 8 副本扛读, 1 leader 收写 # 容错: 挂 1 副本, 数据还在 (看同步/异步) # 低延迟: 用户读就近 DC 副本
# 单主: PG/MySQL/Redis 主从, Kafka 单主本质 # 多主: 多 DC 各有主, 离线优先应用 (CouchDB) # 无主: Dynamo/Cassandra/Riak, 无 failover 概念
# 异步丢写剧本: # 1) 写 x=1, leader 确认 # 2) 日志还没传到 follower, leader 宕机 # 3) follower 升主 → x=0 — "已确认"的写消失了
# 语句日志的坑: NOW() / RAND() 各副本结果不同 # WAL: 复制必须同版本同引擎 (升级麻烦) # 逻辑日志: {"op":"update","row":...} → Debezium
# 脑裂剧本: 旧 leader 只是网络分区, 没死 # 新 leader 已选出 → 两个都接受写 # 解法: 多数派选主 + 旧主 fencing (见共识页)
# "eventually" 的含糊: # 100ms? 10min? 没人承诺 # 所以应用层要自保: 三一致性 + 幂等
# 读己之写: 用户数据读 leader, 其他读 follower # 单调读: user_id 哈希到固定副本 # 一致前缀: 因果链写同一分区
# A: 客户端1 set cart={apple} # B: 客户端2 set cart={banana} (基于旧值?) # B 不知道 A → A、B 并发 → 需要合并而非覆盖
# 节点A 时钟 +5min: cart={apple} ts=10:05 # 节点B 时钟正常: cart={banana} ts=10:00 # LWW → banana 丢, 用户无感知 — 最危险的静默
# 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} 任意顺序合并结果一致
n, w, r = 3, 2, 2 # 写: 等 2 个副本确认 (C 可能是旧的) # 读: 问 3 个, 取版本最新的 2 个交集 → 见到最新写 # w+r<=n: 读可能完全错过写 — 一致性破洞
# 读 A,B (最新) + C (旧) → 顺手把 C 更新 = read repair # C 宕机 → D 代收写并记 hint → C 回来转交 # 漏网之鱼 → 后台 anti-entropy 定期兜底
va = {"n1": 1, "n2": 0} # n1 写过 1 次
vb = {"n1": 0, "n2": 1} # n2 写过 1 次
# 谁也不"大于"谁 → 并发 → sibling主库宕机切换, 用户"支付成功"的订单不见了 — 异步复制的经典丢失剧本。
# 时间线还原: # 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 节点有数据", 超时降级保可用 — 权衡要写进文档。
网络抖动 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 从机制上排除的。
用户改完资料刷新页面, 显示的还是旧头像 — 写进了 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, 公开页面照常读副本。
用户连续刷新, 先看到新评论再看到旧评论 — 两次读被 LB 打到 lag 不同的副本。
# sticky routing: 同一用户粘同一副本 def route(user_id): idx = hash(user_id) % len(followers) return followers[idx] # 总是同一个 # 该副本故障时: 切到 lag ≤ 原副本的节点, 而不是任意节点 # 配合: 会话粘性 (session affinity) 在网关层实现
取舍: 粘副本牺牲一点均衡性, 换"用户视角时间不倒流"。
评论分区和帖子分区复制速度不同, 好友页出现"回复挂在不存在的问题下"。
# 根因: 帖子和回复被分到不同分区, 复制进度独立 # 修法 1: 因果相关数据同分区 (partition key 用帖子 id) -- 帖子与其回复进同一分区, 复制顺序天然一致 PARTITION BY post_id; # 修法 2: 全序广播 — 所有写都按同一顺序可见 (强工具, 见共识页)
判断题: "先有回复后有帖"是 UI 事故, 也是因果没有同区的架构信号。
复制"正常"但 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。
两台设备并发改购物车, LWW 静默丢一个 — 改成集合并集 (CRDT 思想)。
# LWW: 手机端 add(苹果) 与 Web 端 add(香蕉) 并发 # 时间戳定胜负 → 有一台的数据凭空消失 # 修复: 购物车建模为"加集 + 删集", 合并取并集 cart_add = {"apple", "banana"} # 两端合并 cart_del = {"pear"} # 删除也集合并集 final = cart_add - cart_del # 每端各自合并, 结果收敛且不丢 — OR-Set (CRDT 家族)
选型: 丢不起的数据别用 LWW; 结构能并集/计数就上 CRDT。
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 只保证"读到最新写", 不保证跨对象约束 (那是事务/共识的事)。
笔记应用手机/电脑离线各改各的, 上网后自动合并 — 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 结果一致 = 收敛性保证
代价: 元数据(操作历史)比状态本身还大 — 本地优先应用的内存税。
某节点宕机两天, 回来后数据全旧 — 读修复只能救被读到的 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。
# 错: 支付库 async replica + 自动 failover # 对: semisync / group replication / Raft
# 错: VIP 漂移就完事 # 对: 写前验 lease + 只收最高 epoch
# 错: 告警叫人拔网线 # 对: minority 自动 read-only
# 错: 只监控 slave 进程存活 # 对: Seconds_Behind_Master > 30 → page
# 错: 所有读随机打副本 # 对: 写后 60s 内自己的读走 leader
# 错: 每次请求随机副本 # 对: sticky by user_id
# 错: posts 与 replies 各自哈希分区 # 对: partition by post_id (帖+回复同区)
# 错: 文档编辑 LWW # 对: CRDT (RGA/Yjs) 逐操作合并
# 错: ts = time.time() 定胜负 # 对: Lamport/HLC 计数器优先
# 错: 同机房 2 个可写节点 # 对: 跨 DC 容灾需求再上多主
# 错: A→B→C→A 无限循环 # 对: 消息带 visited=[A,B], C 不回发
# 错: RF=2 w=1 r=1 "省资源" # 对: RF=3 w=2 r=2 起步
# 错: w OK 就当写成功可读 # 对: UI 提示"同步中" + 后续反熵修复
# 错: "handoff 会补的" 就不管了 # 对: 恢复后强制 repair 一次
# 错: "有 read repair 就够" # 对: 6h 一次 anti-entropy job
# 错: 整个文档状态塞 RGA # 对: 计数器用 PN-Counter, 文本用 RGA
# 错: 每个临时设备都进 version vector # 对: 固定副本集 + client 用独立合并通道
# 错: failover 代码三年没跑过 # 对: chaos: kill leader 每月一次
# 错: lag=1h 时强切从库 # 对: 切换前校验 lag 阈值
# 错: 50 leader 两两互联 # 对: DC 内主从 + DC 间星形