系统架构 · 一致性与共识

副本之间信谁: 线性一致最贵, 最终一致最险, Raft 用多数派把全序写进日志 — 谱系轴 / Quorum 交集 / 选举与提交, 一张图定价

一致性 & 共识 · 副本之间信谁 — 谱系轴 / Quorum 交集 / Raft 日志 / 两条路 violet = 模型与存储 · emerald = 写路径 · cyan = 读路径 · amber = 边界与告诫 ① 一致性谱系 — 越强越贵 线性一致 Linearizable 像只有一份副本; 每读都要共识往返 例: etcd 默认读 · Spanner 顺序一致 Sequential 人人看到同一顺序, 但未必最新 例: ZooKeeper 读(可落后) 因果一致 Causal 有因果必有序; 并发写可各自不同 例: 评论楼 · COPS 最终一致 Eventual 停写后终会收敛, 窗口无上界 例: DNS · Cassandra 强: 每读必最新 · 延迟高 代价与风险沿箭头此消彼长 弱: 停写才收敛 · 延迟低但险 ② Quorum 读写交集 — N=3 · W=2 · R=2, 交集保证至少读到一份最新写 N=3 · W=2 · R=2 W + R = 4 > N ⇒ 读写集合必有交集 读到的多份若不同, 按时间戳/版本挑最新 W=1 或 R=1 → 交集可能落空 → 读到旧值 写集合 {n1, n2} 读集合 {n2, n3} 写入方 CL = W(2) n1、n2 两副本确认才算写成功 副本 n1 value = v2 (新) 副本 n2 value = v2 (新) 交集: n2 必在两组里 副本 n3 value = v1 (旧, 追平中) 读取方 CL = R(2) 问 n2、n3, 按时间戳挑最新 ③ Raft 日志复制 — 多数派把全序写进日志, commit 之后才对读可见 Leader · term=7 客户端写只进 Leader, 日志只追加 idx 5 SET a=1 idx 6 SET b=2 idx 7 SET c=3 idx5/6 已提交; idx7 待多数派确认 已提交的日志永不回滚 AppendEntries 复制 idx7 Follower n2 追加 idx7, 回 ack Follower n3 追加 idx7, 回 ack ack ×2 多数派确认 (2/3) commitIndex = 7 commit applied 状态机 此后读才看得到 c=3 读只认已 applied 的值; 未提交条目可能在换主后被覆盖 — 这就是"写入成功"必须等多数派确认的原因 ④ 两条路 — 同一次"写后立刻读"的两种结局 写 balance = 50 读 → 旧副本 n2 (CL=ONE) 读到 100 ✗ stale read Jepsen: read(100) 与已确认 write(50) 矛盾 # 同页用户刷新又见 50 → 读倒退(单调读破坏) # 工单进来, 值班第一反应: 是不是副本坏了 写 balance = 50 读 → QUORUM (n1+n2) 读到 50 ✓ 已提交最新值 会话粘住 + W+R>N 交集保底 # 另一现场: 5 节点挂 3 → 剩 2 < 3, 少数派自保 etcdserver: request timed out 读法: ① 谱系轴 = 一致性的价目表 · ② 交集图 = Quorum 为什么读得到新值 · ③ Raft = 全序从哪来 · ④ 两条路 = 治理前后的差别

机制视角 — 一致性谱系

  • • 从强到弱: 线性一致 → 顺序 → 因果 → 最终; 越强越慢, 越弱越险
  • • 共识(Raft/Paxos)只保证日志全序, 值要 applied 之后才对读可见
  • • Quorum 用 W+R>N 换读写交集, 但交集里的"最新"还要靠时间戳/版本裁决
  • • CAP 只在分区时逼你 C/A 二选一; 平时是 PACELC 的延迟 vs 一致

行为视角 — 异常从哪来

  • • 读倒退: 两次读被均衡到不同副本, 单调读被破坏
  • • 读己之写失败: 写完立刻读, 读到了没追平的副本
  • • 丢写: LWW 拿不可信时钟当裁判, 时钟回拨 = 白写
  • • 不脑裂丢数据的前提: 多数派仲裁 + 少数派自觉拒绝服务

生产价值 — 选型即配比

  • • 配置中心/选主/调度锁: CP (etcd/ZooKeeper), 宁拒不错
  • • 点赞/画像/购物车: AP + 最终一致, 延迟优先
  • • 余额/库存: 共识存储或单写点, 别拿 LWW 硬凑
  • • 上线前用 Jepsen 思路过一遍: 你的模型会被它挑出什么刺

💡 一句话理解

把副本想成五个会计同时抄同一本账, 一致性模型回答的只有一个问题: 你问谁才不会被忽悠。问记账的 leader 本尊最准但最慢(线性一致); 只保证人人抄的页码顺序一致(顺序一致); 只保证"回复不会出现在原帖前面"(因果一致); 或者干脆说"大家停笔等一会儿总会抄齐"(最终一致)。

Raft 的贡献是把"谁是权威"变成可执行的机械流程: 多数派投票选主, leader 把写操作编成一条全序日志, 复制到多数派才算提交。代价也明码标价——每笔写多等一个多数派往返, 分区时少数派宁可拒绝服务。看懂这条主线, CAP/Quorum/LWW/CRDT 就不再是孤立名词, 而是同一笔账在不同预算下的不同买法。

🧠 必知必会 必考 & 必会

Linearizability 线性一致
一个写一旦返回, 任何后续读都必须看到它, 系统表现得像只有一份副本。是单对象上最强的保证, 也是最贵的: 读通常也要走共识/等 leader。etcd v3 的 Get 默认就是线性一致读。
// etcd v3: 默认 Get 走共识, 是线性一致读
resp, _ := cli.Get(ctx, "cfg/limit")
resp, _ = cli.Get(ctx, "cfg/limit",
    clientv3.WithSerializable())  // 关键: 串行读走本地状态, 可能比线性读旧一个 rev
// → resp.Kvs[0] 仍可用, 但不再承诺"全集群最新"
Sequential 顺序一致
所有观察者看到同一个操作总顺序, 但不保证这个顺序里包含"最新写入"。ZooKeeper: 写经 ZAB 全序广播, 读却直接回本机数据——顺序对, 值可以旧。
# ZooKeeper: 写全序, 读是本机快照
get /app/leader     # → 可能返回上一任 leader 的信息
# 关键: 需要最新值时先 sync /app/leader 再 get
Causal 因果一致
有因果关系的操作人人看到同序(回复必须排在原帖后面), 无因果的并发写允许各副本各不相同。实现靠向量时钟或依赖追踪随消息传播。
def happens_before(a, b):
    return (all(x <= y for x, y in zip(a.vc, b.vc))
            and a.vc != b.vc)
# 关键: 两个时钟互不压制 → 并发事件, 顺序未定义
Eventual 最终一致
停止写入后, 副本终会收敛到同一个值; 但收敛窗口没有上界, 收敛过程中可能读到任意旧值——它承诺的是"会齐", 不是"多快会齐"。
# DNS 是最大牌的最终一致系统
$ dig +ttl www.example.com
# → 203.0.113.7  TTL=37: 旧记录还在全球缓存里排队
# 关键: TTL 内新旧并存是承诺内行为, 不是故障
Read-after-write 读己之写
用户至少能看到自己刚写的值(别人可以晚点看到)。落地手法: 写后一小段时间把该会话的读路由到主库/写副本, 或携带版本令牌。
if time.Since(sess.LastWriteAt) < 2*time.Second {
    route = primaryPool   // 关键: 写后 2s 窗口粘住主库, 读己之写
} else {
    route = replicaPool
}
Monotonic Read 单调读
同一用户不会看到值"倒回去"。违反场景: 第一次读命中已追平的副本, 第二次被负载均衡到慢副本, 新读比旧读还旧。
if gotRev < sess.LastSeenRev {
    return ErrStale  // 关键: 换副本重读, 而不是把 100 退回成 50
}
sess.LastSeenRev = gotRev  // 记住已见版本作为下界
Quorum Read/Write
N 副本写成功 W 份、读询 R 份, W + R > N 保证读写集合有交集, 至少一份是最新写。注意它不等于线性一致: 读到多份不同值时还要按时间戳/版本挑最新, 交集之外的正确性要另外补。
-- Cassandra: RF=3, 写 QUORUM(2), 读 QUORUM(2)
CONSISTENCY QUORUM;  -- → Current consistency level is QUORUM.
# 关键: W+R > N 只保证交集存在, 挑"最新"要靠写时间戳
CAP
网络分区(P)发生时, 只能在 C(拒绝服务保一致)与 A(继续服务容忍旧值)之间二选一; 平时不分区时这条定理闭嘴, 别拿它当"三选二"的选型口诀。
// P 发生:  etcd 少数派拒绝写入(选C) / Cassandra 照常收写(选A)
// 关键: CAP 不是"三选二", 平时谈 CA 是伪命题
PACELC
CAP 的补全: Else(不分区)时, 要在 L(延迟)与 C(一致)之间选。etcd = PC/EC(读写都为一个共识往返买单); Cassandra = PA/EL(平时低延迟, 分区时容忍分歧)。
cli.Get(ctx, k)                                   // 线性读: 等 leader, 多付 1 个 RTT
cli.Get(ctx, k, clientv3.WithSerializable())      // 串行读: 快, 可能旧
// 关键: PACELC 的 Else 分支 = 每次读都在 L 与 C 之间计价
Paxos(概念层)
两阶段共识: Prepare/Promise 用提案编号抢多数派承诺, Accept 再拿多数派落盘; Multi-Paxos 稳定 leader 后退化成一阶段。正确性能证明, 工程极难写对——这是现代系统改选 Raft 的直接原因。
// Prepare(n): 多数派承诺不再接受 < n 的提案 → Promise(n, last)
// Accept(n, v): 多数派落盘即选定              → Accepted(n, v)
// 关键: 两阶段都要凑多数派, 双主与活锁全靠编号仲裁
Raft 选举与任期
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 心跳就续命, 别竞选
}
Raft 日志复制与提交
leader 收写 → 追加日志 → AppendEntries 发给副本 → 多数派 ack → 推进 commitIndex → apply 到状态机, 之后才对读可见。已提交条目永不回滚; 未提交的在换主后可能被覆盖。
idx := raft.Append(cmd)                // ① 本地追加, 尚不可见
if raft.Acked(idx) >= majority {       // ② 多数派确认
    raft.Commit(idx); fsm.Apply(idx)   // 关键: 提交+应用后才算"写成功"
}
Log Compaction 快照压缩
共识日志只追加, 不压缩必然撑爆盘。手段: 周期快照 + 只保留快照点之后的日志; KV 类系统再对 MVCC 历史做 compact。etcd 空间耗尽是真实高频生产事故。
$ etcdctl compact 1871       # 压缩 rev 1871 之前的 MVCC 历史
$ etcdctl snapshot save /backup/etcd-$(date +%F).db
# 关键: 不 compact 的 etcd 终将报 mvcc: database space exceeded
LWW 与 CRDT
LWW(last-write-wins)拿时间戳当裁判: 时钟回拨/偏移直接丢写。CRDT 不比较只合并: 要求 merge 满足交换律、结合律、幂等性, 副本任意顺序同步都收敛到同值。
// G-Counter: 每节点一格, merge 取 max, 值 = 各格之和
for k, v := range other.cells {
    if v > g.cells[k] { g.cells[k] = v } // 关键: max 可交换可幂等 → 必收敛
}

🏭 生产实战 real world

场景 1 · 给自研配置系统写一个 200 行的 Raft 最小骨架(选举 + 日志)

自研元数据服务第一版不需要完整 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 是逻辑时钟; 当选要多数派; 提交也要多数派——两条"多数派"缺一就会出现双主或丢数据。

场景 2 · 订单表从 ONE 提到 QUORUM, 支付状态不再"闪回"

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;  -- 微秒, 服务端生成, 不用手机时钟

场景 3 · 用户改完昵称刷新还是旧昵称(写后读闪回 30 分钟工单)

写走主库、读随机副本、复制延迟 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
}

上线后"改了又变回去"类工单归零; 副本池流量只少了几个百分点, 主库压力可忽略。

场景 4 · 评论楼回复跑到了原帖前面(时钟偏差打脸 LWW 排序)

回帖落在 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 一页, 再按需展开子楼 → 因果序由结构保证

场景 5 · 笔记应用用客户端时钟做 LWW, 用户丢了一小时的编辑

手机时钟手动回拨一小时, 这一小时内的每次保存都"输给"历史版本。冲突字段改成版本号 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 让客户端合并

场景 6 · 点赞计数用 G-Counter, 三机房互不覆盖还不出锁

全球三机房各自累加再异步对账, 用分布式锁同步每次点赞等于自杀。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, 无需共识

场景 7 · 注册中心选型: etcd(CP) 还是 Eureka(AP), 配置长什么样

服务发现要的是"网络抖动时别把幸存实例全摘掉", 属 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): 分区时少数派直接拒写—— 要的正是"宁拒不错"

场景 8 · 京沪深三机房部署 Quorum, 写延迟从 2ms 涨到 50ms 的账

共识成员跨广域网, 每笔写都要等多数派往返; 把多数派留在同城、跨机房只放异步副本, 延迟立刻回血。

-- 部署 A: 北京×2 + 上海×1 → 本地(北京)多数派凑得齐, 写 ≈ 2ms RTT
-- 部署 B: 京/沪/穗各 1 台 → 每写跨 1300km ≈ 25ms RTT × 共识往返 → 50ms+
-- Cassandra 的解法: 本地写本地确认, 跨 DC 异步追平
CONSISTENCY LOCAL_QUORUM;   -- 只等本 DC 的 2/3, 远端机房不进写关键路径
-- 代价: 异地机房读本地副本可能落后毫秒级 → 跨地读要接受最终一致

场景 9 · 单调读 SDK: 客户端记住版本号, 读倒退就换节点(正确用法模板)

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  // 读倒退 → 上报, 而不是退回旧值
}

场景 10 · 共识集群上 3 台还是 7 台: 先算多数派再谈高可用

每次写延迟 ≈ 一个多数派 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

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 支付成功页余额没变 — 写完立刻读, 界面还是旧余额. 原因: 把最终一致的副本读当强一致用, 收敛窗口内读到的就是旧值. 正解: 关键读(余额/订单态)走主库或共识存储, 副本读只给容忍旧值的展示.
# 错: 写后 0.2s 读副本, 期待新值
get_balance(replica)      # → 100.0  (刚写过 50.0, 副本没追平)
# 对: 关键读走主库; 副本读只喂"晚几秒无所谓"的场景
坑 2 · 配了 QUORUM 读照样读到旧值 — 写 CL=ONE、读 CL=QUORUM, 以为万无一失. 原因: 写只落 1 个副本, 读的 2 副本可以恰好绕开它, W+R>N 的前提是写侧也凑够数. 正解: 写读同升, 写至少 LOCAL_QUORUM.
-- 错: 写 ONE 读 QUORUM → 写落 n1, 协调器挑 n2+n3 读, 全是旧值
-- 对: 写读都 QUORUM, 交集里必有最新写
CONSISTENCY QUORUM;
坑 3 · LWW 裁判用了手机时钟 — 用户回拨时间后, 一小时内的编辑全被旧版本覆盖. 原因: LWW 只比时间戳, 时钟回拨 = 新写的 timestamp 反而更小. 正解: 裁判时间戳必须来自服务端单调时钟或 HLC, 永不回退.
-- 错: INSERT ... USING TIMESTAMP client_ts  (客户端任意时钟)
-- 对: 服务端生成微秒时间戳 / HLC; 冲突敏感字段上版本号 LWT
UPDATE notes SET rev=:rev WHERE note_id=:id IF rev < :rev;
坑 4 · 共识节点横跨三个大洲 — 每笔写 300ms 起, 高峰期写入超时风暴. 原因: 共识每写至少一个多数派 RTT, 跨洲 RTT 150ms×若干轮. 正解: 多数派放同城, 跨洲节点做异步副本/learner, 不进多数派.
# 错: --initial-cluster sfo=…,fra=…,sgp=…  三洲组队, 写延迟 300ms+
# 对: 同城 3 副本扛共识; 跨洲只放镜像读, 不参与多数派
坑 5 · 用户看到的数值"倒放" — 先读到 100 再读到 50, 像数据库坏了. 原因: leaderless 两次读命中不同副本, 无单调读保障. 正解: 会话粘住同一副本, 或客户端记录已见版本做下界(见场景 9).
# 错: 每次读随机挑副本, 前后读到 100 → 50
# 对: rev < lastRev 时换副本重试, 并上报单调读违例
坑 6 · 面试口误"CAP 三选二" — 方案评审时说"我们是 CA 系统". 原因: CAP 里 P 不可舍弃, 分区时才谈 C/A; 平时根本不适用. 正解: 平时的取舍说 PACELC——延迟还是一致.
// 错: "我们选 CA, 又一致又可用"   ← P 发生时这句话直接破产
// 对: "分区时选 C 拒写; 平时按 PACELC 选 L(低延迟)"
坑 7 · 把串行读当线性一致读宣传 — 文档写"强一致", 用户却在 etcd 读到旧值. 原因: WithSerializable 走单机本地状态, 不经共识, 语义是顺序一致不是线性一致. 正解: 强一致场景用默认线性读, 串行读要在文档里明标"允许旧值".
// 错: 宣传"强一致"却给所有读加了 WithSerializable()
cli.Get(ctx, k, clientv3.WithSerializable()) // → 可能旧一个 rev
// 对: 默认线性读; 能容忍旧值的场景才显式降级并注释
坑 8 · 以为 5 节点挂 3 还能撑 — 剩 2 台后所有写和线性读全部失败, 值班以为雪崩. 原因: 5 节点多数派是 3, 挂 3 剩 2 不构成多数派, 这是共识的设计而非故障. 正解: 容量规划按"挂 2 仍可用"算, 监控存活成员数告警在掉到 3 时触发.
# 错: 5 节点挂 3 后继续写 → etcdserver: request timed out
# 对: 告警规则: alive_members < 3 即 page, 别等到写失败才发现
坑 9 · quorum 参数配成 w=2, r=1 — 自称 Quorum 部署却读旧值. 原因: W+R=3 不大于 N=3, 读写集合可以完全错开, 交集根本不存在. 正解: 上线前核对 w + r > n, 并写在部署检查清单里.
# 错: n=3, w=2, r=1 → 2+1=3 不大于 3, 交集无保证
# 对: n=3 时取 w=2, r=2 → 4 > 3 才有交集
坑 10 · 以为读修复会把副本立刻修齐 — 修完一次 QUORUM 读就去查副本, 发现 n3 还是旧值. 原因: read repair 只修"这次读实际碰到的 key 与数据", 全量对账靠周期性 anti-entropy repair. 正解: 定期 nodetool repair, 别拿读修复当兜底.
-- 错: 以为 QUORUM 读 = 三副本同步修复完成
-- 对: 周期性全量 repair(如每周), 读修复只算顺手补刀
$ nodetool repair -pr orders
坑 11 · 所有读都打线性一致, leader 网卡先死 — etcd 集群 QPS 不高, 但 leader 带宽/PPS 打满. 原因: 线性读默认全走 leader, 副本闲着. 正解: 读语义分层——能容忍旧值的监控/展示读用 serializable + 本地缓存, 只有决策读走线性.
// 错: 每 100ms 轮询配置也用线性读, 全部压到 leader
// 对: 轮询读 WithSerializable(); 生效判定那次才用线性读
坑 12 · 转发消息不带依赖, 楼层永远排不对 — 转发/回复丢了原消息的 id 或逻辑时钟, 收端无从排序. 原因: 因果一致的依赖信息跟着消息走, 你不传它就当并发处理. 正解: 消息体携带(父id, 向量时钟), 消费端先满足依赖再展示.
# 错: reply = {thread: 88, body: "..."}          (依赖信息丢了)
# 对: reply = {thread: 88, parent: 661, vc: {...}}  (依赖随消息传播)
坑 13 · 双向复制, 同一 key 两地互覆盖 — 京沪双活各写各的, 每天凌晨对账少一批写. 原因: 双主各自 LWW, 时钟/顺序不同导致一边的写被另一边静默覆盖. 正解: 按 key 分归属(单写点), 或换 CRDT/带版本合并, 禁止无冲突策略的双向复制.
# 错: 京沪互为主主, 同 key 各写 → LWW 静默吃掉一边
# 对: user_id 哈希定归属机房, 跨机房写一律路由到归属地
坑 14 · "我用了 SERIALIZABLE 怎么还读到旧数据" — 隔离级别拉满仍见复制延迟. 原因: 事务隔离管的是单库并发交错, 复制一致性管的是跨副本可见性, 两层正交. 正解: 读最新值走主库/线性读, 隔离级别解决不了复制延迟.
-- 错: BEGIN ISOLATION LEVEL SERIALIZABLE; 之后读从库求"最新"
-- 对: 需要最新 → 读主库; SERIALIZABLE 只管本库并发正确性
坑 15 · consistency level 按开发者心情选 — 同一模块有人 ONE 有人 ALL, 延迟抖一个数量级, 还偶发 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 写进团队规范
坑 16 · 向量时钟膨胀到把消息头撑爆 — 高并发客户端下每对象几十个时钟条目, 元数据比数据还大. 原因: 每个并发写者占一个 entry, 从不裁剪; Riak 当年真实踩过. 正解: 客户端 ID 收敛为固定服务端写者 + 周期 prune 裁剪旧条目.
# 错: 每个设备一个 vclock entry, 永不清理 → entry 数随用户设备数爆炸
# 对: 写入口收敛到少数服务端节点, 服务端定期 prune 过期 entry
坑 17 · CRDT merge 写成了求平均 — 两副本各自合并一次, 值居然不一样. 原因: 收敛要求 merge 满足交换律+结合律+幂等, avg 一条都不满足, max/min/sum(分格)才满足. 正解: 合并逻辑只用经典半格运算, 写单测验证任意顺序合并同结果.
# 错: merge: cells = avg(cells, other.cells)   # 顺序不同结果不同
# 对: merge: cells[k] = max(cells[k], other.cells[k])  # 必收敛
坑 18 · Redis 写完不等 ack 就去读从库 — WAIT 1 100 返回 0 还继续往下走, 读到旧值. 原因: WAIT 超时返回已确认数, 0 代表没有一个从库确认, 而代码没检查返回值. 正解: 返回值不足就重试或改读主库; 另记住 WAIT 也不是强一致保证, 只是缩短窗口.
# 错: WAIT 1 100 → (integer) 0  (从库还没 ack, 代码当成功)
# 对: n = WAIT 1 100; if n < 1: 读主库或重试, 别读从库
坑 19 · etcd 从不 compact, 全集群瘫痪 — K8s 集群某天所有组件同时报错, etcd 日志刷屏. 原因: MVCC 历史无限累积吃满 2GiB 配额, etcd 进入只读保护. 正解: cron 定期 compact + defrag, 空间用量 70% 告警.
# 错: 从不清理 → etcdserver: mvcc: database space exceeded
# 对: 0 */2 * * * etcdctl compact $(rev) && etcdctl defrag
坑 20 · leader 挂了以为 1 秒就能恢复写入 — 切主窗口内大量写报错, 客户端把重试风暴送给新 leader. 原因: 选举超时 + 拉票 + 日志对齐是秒级过程, 窗口内写入必然失败. 正解: 客户端把"共识不可写窗口"当常态预算, 退避重试而非立刻重放全量.
# 错: 写失败立即全量重试 ×N → 新 leader 上任瞬间被打挂
# 对: 指数退避 100ms/400ms/1.6s + 上限, 并容忍 2~3s 不可写窗口