副本之间信谁: 线性一致最贵, 最终一致最险, Raft 用多数派把全序写进日志 — 谱系轴 / Quorum 交集 / 选举与提交, 一张图定价
把副本想成五个会计同时抄同一本账, 一致性模型回答的只有一个问题: 你问谁才不会被忽悠。问记账的 leader 本尊最准但最慢(线性一致); 只保证人人抄的页码顺序一致(顺序一致); 只保证"回复不会出现在原帖前面"(因果一致); 或者干脆说"大家停笔等一会儿总会抄齐"(最终一致)。
Raft 的贡献是把"谁是权威"变成可执行的机械流程: 多数派投票选主, leader 把写操作编成一条全序日志, 复制到多数派才算提交。代价也明码标价——每笔写多等一个多数派往返, 分区时少数派宁可拒绝服务。看懂这条主线, CAP/Quorum/LWW/CRDT 就不再是孤立名词, 而是同一笔账在不同预算下的不同买法。
// etcd v3: 默认 Get 走共识, 是线性一致读 resp, _ := cli.Get(ctx, "cfg/limit") resp, _ = cli.Get(ctx, "cfg/limit", clientv3.WithSerializable()) // 关键: 串行读走本地状态, 可能比线性读旧一个 rev // → resp.Kvs[0] 仍可用, 但不再承诺"全集群最新"
# ZooKeeper: 写全序, 读是本机快照 get /app/leader # → 可能返回上一任 leader 的信息 # 关键: 需要最新值时先 sync /app/leader 再 get
向量时钟或依赖追踪随消息传播。 def happens_before(a, b): return (all(x <= y for x, y in zip(a.vc, b.vc)) and a.vc != b.vc) # 关键: 两个时钟互不压制 → 并发事件, 顺序未定义
# DNS 是最大牌的最终一致系统 $ dig +ttl www.example.com # → 203.0.113.7 TTL=37: 旧记录还在全球缓存里排队 # 关键: TTL 内新旧并存是承诺内行为, 不是故障
if time.Since(sess.LastWriteAt) < 2*time.Second { route = primaryPool // 关键: 写后 2s 窗口粘住主库, 读己之写 } else { route = replicaPool }
if gotRev < sess.LastSeenRev { return ErrStale // 关键: 换副本重读, 而不是把 100 退回成 50 } sess.LastSeenRev = gotRev // 记住已见版本作为下界
W + R > N 保证读写集合有交集, 至少一份是最新写。注意它不等于线性一致: 读到多份不同值时还要按时间戳/版本挑最新, 交集之外的正确性要另外补。 -- Cassandra: RF=3, 写 QUORUM(2), 读 QUORUM(2) CONSISTENCY QUORUM; -- → Current consistency level is QUORUM. # 关键: W+R > N 只保证交集存在, 挑"最新"要靠写时间戳
// P 发生: etcd 少数派拒绝写入(选C) / Cassandra 照常收写(选A) // 关键: CAP 不是"三选二", 平时谈 CA 是伪命题
cli.Get(ctx, k) // 线性读: 等 leader, 多付 1 个 RTT cli.Get(ctx, k, clientv3.WithSerializable()) // 串行读: 快, 可能旧 // 关键: PACELC 的 Else 分支 = 每次读都在 L 与 C 之间计价
// Prepare(n): 多数派承诺不再接受 < n 的提案 → Promise(n, last) // Accept(n, v): 多数派落盘即选定 → Accepted(n, v) // 关键: 两阶段都要凑多数派, 双主与活锁全靠编号仲裁
term 是逻辑时钟, 单调递增; follower 随机 150~300ms 选举超时, 先到先拉票, 拿到多数派选票才上任。随机化超时是为了错开竞选, 防止选票瓜分。 timeout := time.Duration(150+rand.Intn(150)) * time.Millisecond select { case <-timer.C: becomeCandidate(); requestVoteFromPeers() case <-hbCh: timer.Reset(timeout) // 关键: 有 leader 心跳就续命, 别竞选 }
idx := raft.Append(cmd) // ① 本地追加, 尚不可见 if raft.Acked(idx) >= majority { // ② 多数派确认 raft.Commit(idx); fsm.Apply(idx) // 关键: 提交+应用后才算"写成功" }
compact。etcd 空间耗尽是真实高频生产事故。 $ etcdctl compact 1871 # 压缩 rev 1871 之前的 MVCC 历史 $ etcdctl snapshot save /backup/etcd-$(date +%F).db # 关键: 不 compact 的 etcd 终将报 mvcc: database space exceeded
// G-Counter: 每节点一格, merge 取 max, 值 = 各格之和 for k, v := range other.cells { if v > g.cells[k] { g.cells[k] = v } // 关键: max 可交换可幂等 → 必收敛 }
自研元数据服务第一版不需要完整 Raft, 但"随机超时选举 + 日志复制 + 多数派提交"三件必须先跑通, 否则后面全是空中楼阁。下面是最小可讲解骨架。
// 最小 Raft 骨架(省略持久化/快照/成员变更, 仅为讲机制) func (r *Raft) tick() { if r.role == Follower && time.Since(r.lastHeartbeat) > r.electionTimeout { r.role = Candidate; r.term++ // 先投自己, 再并行拉票 for _, p := range r.peers { go r.requestVote(p, r.term) // 多数派同意才当选 } } } func (r *Raft) Propose(cmd []byte) error { r.log = append(r.log, Entry{Term: r.term, Cmd: cmd}) return r.replicateAndWait(r.log[len(r.log)-1].Index) // 等多数派 ack 才返回客户端 }
面试口径就三句: term 是逻辑时钟; 当选要多数派; 提交也要多数派——两条"多数派"缺一就会出现双主或丢数据。
Cassandra 默认 ONE 在副本抖动时会读到旧支付状态, 客服天天接到"我明明付款了"的工单。按 keyspace 定 RF, 按会话定 CL, 写路径带上时间戳。
-- cqlsh 会话级: 读写都走多数派(N=3 时 = 2 副本) CONSISTENCY QUORUM; -- → Current consistency level is QUORUM. -- 每数据中心 3 副本, 让 QUORUM 有得算 ALTER KEYSPACE orders WITH replication = {'class': 'NetworkTopologyStrategy', 'dc1': '3'}; -- 高频覆盖字段用客户端时间戳做 LWW 裁判(前提: 服务器 NTP 已校准) INSERT INTO orders (order_id, status) VALUES ('A1024','PAID') USING TIMESTAMP 1777000000123456; -- 微秒, 服务端生成, 不用手机时钟
写走主库、读随机副本、复制延迟 300ms——用户 5 秒内刷新三次, 有时新有时旧。正解不是压复制延迟, 而是给"刚写过的会话"一个读主窗口。
// 网关路由中间件: 写后 5s 内把该用户的读粘到主库 func (m *Router) Pick(userID string, isWrite bool) *Pool { if isWrite { m.written.Set(userID, time.Now()) return m.primary } if t, ok := m.written.Get(userID); ok && time.Since(t) < 5*time.Second { return m.primary // 读己之写窗口: 这 5 秒别把读交给副本 } return m.replica }
上线后"改了又变回去"类工单归零; 副本池流量只少了几个百分点, 主库压力可忽略。
回帖落在 replicaA、原帖落在 replicaB, 按 server 时间戳排序时两台机器钟差 80ms, 楼里出现"回复先于原帖"的灵异现场。把因果依赖固化进数据结构, 而不是赌时钟。
-- 不赌全局时钟: 楼中楼永远挂在父评论分组内, 结构即因果 CREATE TABLE comments ( thread_id bigint, parent_id bigint, -- 根评论 parent_id = 0; 回复必须携带父 id created_at timeuuid, -- timeuuid 自带毫秒时序, 免跨机时钟对齐 body text, PRIMARY KEY (thread_id, parent_id, created_at) ); -- 读楼: 先取 parent_id=0 一页, 再按需展开子楼 → 因果序由结构保证
手机时钟手动回拨一小时, 这一小时内的每次保存都"输给"历史版本。冲突字段改成版本号 LWT(Lightweight Transaction, 底层走 Paxos), 只在真正的热点冲突上用。
-- 错法: USING TIMESTAMP 信任客户端时钟 → 回拨机器的写永远输 -- 正解: 版本号单调才接受, 冲突字段用 LWT(注意: LWT 有共识开销, 慢 3~5 倍) UPDATE notes SET body = :body, rev = :rev WHERE note_id = :id IF rev < :rev; -- → [applied] = True 才落盘; False 说明来了更新的版本, 返回 409 让客户端合并
全球三机房各自累加再异步对账, 用分布式锁同步每次点赞等于自杀。G-Counter 每机房一格, max 合并, 天然无冲突。
type GCounter struct { mu sync.Mutex; cells map[string]uint64 } func (g *GCounter) Inc(dc string) { g.mu.Lock(); g.cells[dc]++; g.mu.Unlock() } func (g *GCounter) Merge(other map[string]uint64) { g.mu.Lock(); defer g.mu.Unlock() for k, v := range other { if v > g.cells[k] { g.cells[k] = v } } } func (g *GCounter) Value() (n uint64) { for _, v := range g.cells { n += v }; return } // → dc1:{dc1:120,dc2:98} 与 dc2:{dc1:120,dc2:98} 各自读到 218, 无需共识
服务发现要的是"网络抖动时别把幸存实例全摘掉", 属 AP 场景; 配置/选主要的是"全集群同一份真值", 属 CP 场景。两边真实配置对照。
# Eureka(AP): 分区时保可用, 宁可留着可能过期的实例 eureka: server: enable-self-preservation: true # 自我保护: 不再激进剔除实例 renewal-percent-threshold: 0.85 # 续约低于阈值 85% 触发保护 client: registry-fetch-interval-seconds: 30 # 客户端容忍最多 30s 旧注册表 # etcd(CP): 分区时少数派直接拒写—— 要的正是"宁拒不错"
共识成员跨广域网, 每笔写都要等多数派往返; 把多数派留在同城、跨机房只放异步副本, 延迟立刻回血。
-- 部署 A: 北京×2 + 上海×1 → 本地(北京)多数派凑得齐, 写 ≈ 2ms RTT -- 部署 B: 京/沪/穗各 1 台 → 每写跨 1300km ≈ 25ms RTT × 共识往返 → 50ms+ -- Cassandra 的解法: 本地写本地确认, 跨 DC 异步追平 CONSISTENCY LOCAL_QUORUM; -- 只等本 DC 的 2/3, 远端机房不进写关键路径 -- 代价: 异地机房读本地副本可能落后毫秒级 → 跨地读要接受最终一致
Leaderless 存储读多副本, 想要单调读不能只靠服务端, 客户端 SDK 保留"已见版本下界"是标准模板。
func (s *Session) Read(key string) (string, error) { for i := 0; i < 3; i++ { v, rev, err := s.pool.Any().Get(key) if err != nil { return "", err } if rev >= s.lastRev { // 至少和上次一样新才接受 s.lastRev = rev return v, nil } time.Sleep(20 * time.Millisecond) // 慢副本没追上, 重试/换节点 } return "", ErrMonotonicViolated // 读倒退 → 上报, 而不是退回旧值 }
每次写延迟 ≈ 一个多数派 RTT, 节点越多越跨越慢; 容错数 = (N-1)/2。别为了"更稳"盲目堆节点。
# 挂 N/2 向下取整台仍可写: 3→容忍1, 5→容忍2, 7→容忍3 # 延迟: 7 节点跨机架 > 5 节点同机房 > 3 节点同机架 — 越大越慢 # etcd 官方运维口径: 奇数成员, 大多数场景 3 或 5 就够 $ etcdctl endpoint status --cluster -w table # → 列出 IS LEADER / RAFT TERM / DB SIZE: 扩容前先看谁在当 leader
# 错: 写后 0.2s 读副本, 期待新值 get_balance(replica) # → 100.0 (刚写过 50.0, 副本没追平) # 对: 关键读走主库; 副本读只喂"晚几秒无所谓"的场景
-- 错: 写 ONE 读 QUORUM → 写落 n1, 协调器挑 n2+n3 读, 全是旧值 -- 对: 写读都 QUORUM, 交集里必有最新写 CONSISTENCY QUORUM;
LWW 只比时间戳, 时钟回拨 = 新写的 timestamp 反而更小. 正解: 裁判时间戳必须来自服务端单调时钟或 HLC, 永不回退. -- 错: INSERT ... USING TIMESTAMP client_ts (客户端任意时钟) -- 对: 服务端生成微秒时间戳 / HLC; 冲突敏感字段上版本号 LWT UPDATE notes SET rev=:rev WHERE note_id=:id IF rev < :rev;
# 错: --initial-cluster sfo=…,fra=…,sgp=… 三洲组队, 写延迟 300ms+ # 对: 同城 3 副本扛共识; 跨洲只放镜像读, 不参与多数派
# 错: 每次读随机挑副本, 前后读到 100 → 50 # 对: rev < lastRev 时换副本重试, 并上报单调读违例
PACELC——延迟还是一致. // 错: "我们选 CA, 又一致又可用" ← P 发生时这句话直接破产 // 对: "分区时选 C 拒写; 平时按 PACELC 选 L(低延迟)"
WithSerializable 走单机本地状态, 不经共识, 语义是顺序一致不是线性一致. 正解: 强一致场景用默认线性读, 串行读要在文档里明标"允许旧值". // 错: 宣传"强一致"却给所有读加了 WithSerializable() cli.Get(ctx, k, clientv3.WithSerializable()) // → 可能旧一个 rev // 对: 默认线性读; 能容忍旧值的场景才显式降级并注释
# 错: 5 节点挂 3 后继续写 → etcdserver: request timed out # 对: 告警规则: alive_members < 3 即 page, 别等到写失败才发现
w + r > n, 并写在部署检查清单里. # 错: n=3, w=2, r=1 → 2+1=3 不大于 3, 交集无保证 # 对: n=3 时取 w=2, r=2 → 4 > 3 才有交集
nodetool repair, 别拿读修复当兜底. -- 错: 以为 QUORUM 读 = 三副本同步修复完成 -- 对: 周期性全量 repair(如每周), 读修复只算顺手补刀 $ nodetool repair -pr orders
// 错: 每 100ms 轮询配置也用线性读, 全部压到 leader // 对: 轮询读 WithSerializable(); 生效判定那次才用线性读
(父id, 向量时钟), 消费端先满足依赖再展示. # 错: reply = {thread: 88, body: "..."} (依赖信息丢了) # 对: reply = {thread: 88, parent: 661, vc: {...}} (依赖随消息传播)
# 错: 京沪互为主主, 同 key 各写 → LWW 静默吃掉一边 # 对: user_id 哈希定归属机房, 跨机房写一律路由到归属地
-- 错: BEGIN ISOLATION LEVEL SERIALIZABLE; 之后读从库求"最新" -- 对: 需要最新 → 读主库; SERIALIZABLE 只管本库并发正确性
ReadTimeout. 原因: ALL 要等全部副本, 一台抖动全体陪葬. 正解: 按操作语义定 CL 矩阵并进 code review 清单, 禁止逐请求随手改. -- 错: 有人写 CONSISTENCY ALL; 一台节点重启 → 全读超时 -- → Cassandra timeout during read query at consistency ALL -- (3 responses were required but only 2 replicas responded) -- 对: 读 QUORUM / 写 QUORUM 写进团队规范
# 错: 每个设备一个 vclock entry, 永不清理 → entry 数随用户设备数爆炸 # 对: 写入口收敛到少数服务端节点, 服务端定期 prune 过期 entry
avg 一条都不满足, max/min/sum(分格)才满足. 正解: 合并逻辑只用经典半格运算, 写单测验证任意顺序合并同结果. # 错: merge: cells = avg(cells, other.cells) # 顺序不同结果不同 # 对: merge: cells[k] = max(cells[k], other.cells[k]) # 必收敛
WAIT 1 100 返回 0 还继续往下走, 读到旧值. 原因: WAIT 超时返回已确认数, 0 代表没有一个从库确认, 而代码没检查返回值. 正解: 返回值不足就重试或改读主库; 另记住 WAIT 也不是强一致保证, 只是缩短窗口. # 错: WAIT 1 100 → (integer) 0 (从库还没 ack, 代码当成功) # 对: n = WAIT 1 100; if n < 1: 读主库或重试, 别读从库
compact + defrag, 空间用量 70% 告警. # 错: 从不清理 → etcdserver: mvcc: database space exceeded # 对: 0 */2 * * * etcdctl compact $(rev) && etcdctl defrag
# 错: 写失败立即全量重试 ×N → 新 leader 上任瞬间被打挂 # 对: 指数退避 100ms/400ms/1.6s + 上限, 并容忍 2~3s 不可写窗口