Chapter 05

副本集与一致性

04 章讲了单节点怎么存、怎么本地持久——还埋了一个伏笔:WiredTiger 的 commit timestamp 由 oplog 驱动。这章把数据复制到多节点,看 oplog、选举、读写关注,以及"返回成功"到底意味着什么。

本章你将建立的 schema

  • oplog:local 库里的 capped 集合,条目幂等,secondary 主动 PULL 拉取并重放。
  • 副本集协议:受 Raft 启发但不是 Raft——pull 而非 push、term、选举、priority takeover、dry-run election。
  • write concern / read concern:w:majority 的 commit point 怎么算;readConcern majority/snapshot/linearizable 各读到什么。
  • 因果一致性 + HLC($clusterTime 带 HMAC 签名);持久性 ≠ 可见性。

5.1oplog:幂等操作日志

oplog 是 primary 上一个 capped 集合(local.oplog.rs),记录幂等的操作条目,secondary 主动拉取重放。

为什么需要它

多节点复制要解决一个核心矛盾:同一批操作在不同节点上重放,结果必须一致,而网络重传、节点重启会让同一条操作被重放不止一次。oplog 把每条变更设计成可重复执行而结果不变的形式,重放就不必担心"放了几遍"。

底层机制(深一层):oplog 条目被设计成幂等——$inc 被转写成绝对值 $set,一条改多文档的操作被拆成逐文档条目——所以重复重放同一批不会出错。secondary 用一个 OplogFetcher exhaust cursor 按 optime tail primary,主动 PULL,primary 从不 push。oplog window(大小 ÷ 写入速率)决定 secondary 最多能落后多久;落后超窗口就要 full resync。对照 Postgres:既不是物理 WAL ship,也不是逻辑解码,而是一个可查询的、幂等的操作日志,由状态机重放。

机制对照

$inc: 1 这种相对操作不是幂等的——重放两遍就多加了一次。oplog 在 primary 执行后把结果落成绝对值:primary 把计数器从 41 加到 42,写进 oplog 的是 $set: { counter: 42 }。secondary 重放一遍是 42,重放十遍还是 42。把相对操作转写成绝对结果,是幂等的来源。

下面是同一条写入操作在 primary 执行后落进 oplog 的样子。注意 o 字段里已经是绝对值。

oplog-entry mongosh
// 应用层发的是相对操作
db.counters.updateOne({ _id: "page" }, { $inc: { hits: 1 } })

// primary 执行后,落进 oplog 的条目(节选)——已转写为绝对值
db.getSiblingDB("local").oplog.rs.find().sort({ $natural: -1 }).limit(1)
// {
//   op: "u",                       // update
//   ns: "app.counters",
//   ts: Timestamp(1717300000, 1),  // optime:secondary 按它 tail
//   o:  { $v: 2, diff: { u: { hits: 42 } } },  // 绝对值 42,非 +1
//   o2: { _id: "page" }            // 定位目标文档
// }
副本集:3 节点,secondary 主动拉取 PRIMARY 持有 oplog · 接受写 oplog.rs capped · 幂等 SECONDARY OplogFetcher SECONDARY OplogFetcher PULL / tail PULL / tail
图 5.1副本集拓扑与复制方向。注意:箭头从 secondary 指向 primary——复制是 secondary 主动拉,不是 primary 推,这点和 Raft 的 leader-push 相反。
想一想

一个 secondary 宕机维护了几小时,期间 primary 写入很猛。它重新上线后,能直接从断点继续 tail oplog 吗?

展开答案(先停 10 秒)

不一定。取决于它落后的量有没有超过 oplog window。oplog 是 capped 集合,容量固定,写入速率越高、覆盖越快。如果 secondary 需要的那条 optime 已经被新条目覆盖滚掉,断点处的 oplog 不存在了,它无法增量追赶,只能 full resync(全量重新同步整个数据集)。所以高写入负载下要把 oplog 调大,给慢节点留出追赶窗口。

5.2选举与协议:像 Raft,但不是 Raft

副本集用 protocolVersion 1,借了 Raft 的 term 与单 leader 思想,但在复制方式、提交点计算、选举细节上都不同。

为什么需要它

primary 宕机后,集群要在没有人工介入的情况下选出新 primary 并继续接受写入,同时保证不会有两个 primary 同时被认可(脑裂)。Raft 提供了 term + 多数票的骨架来解决这件事,副本集采用了这套骨架,但按自己的复制模型做了改造。

底层机制(深一层):与 Raft 的差异要明确列出——(1) 日志条目是幂等操作,不是状态机命令;(2) 复制是 secondary pull,不是 Raft 的 AppendEntries push;(3) commit point 由 primary 轮询多数节点的 lastDurable/lastWritten 算出,而不是逐条计票;(4) 增加了 priority takeover(高优先级节点上线后抢主)和 dry-run election(选赢之前不 bump term,避免无谓的 term 膨胀)。心跳每 2s,10s 超时触发选举。rollback 用 WiredTiger 的 recover-to-stable-timestamp 回到公共点,而不是截断日志。

核心洞察

为什么强调"不是 Raft"——照搬 Raft 心智会误判它的提交语义。在 Raft 里,leader 把一条日志复制到多数节点就提交了该条目;在副本集里,提交点是 primary 周期性轮询各 secondary 已 durable 的 optime 后算出的一个"多数已达"水位线,是 optime 维度的水位,而不是逐条目的计票。把它当 Raft 来推断"写到多数即提交的精确时刻"会偏差。

dry-run election 是一个容易被忽略的细节:候选人在真正发起选举、bump term 之前,先问一轮"若此刻发起选举,能否拿到多数票"。

election-mechanics 概念
// 选举触发与 dry-run 的次序(伪代码,描述协议行为)
onHeartbeatTimeout() {                 // 10s 收不到 primary 心跳
  if (dryRunElection().wonMajority()) {  // 1) 试选:不 bump term
    bumpTerm();                          // 2) 真选才 +1,避免 term 膨胀
    requestVotes(currentTerm);           // 3) 正式拉票
  }
  // 试选失败:term 保持原值,集群不被一次无谓选举打扰
}

// priority takeover:高优先级节点追平 oplog 后主动抢主
if (this.priority > primary.priority && this.isCaughtUp()) {
  callElection();                        // 把主导权交还给更高优先级节点
}
想一想

primary 失联又恢复,发现自己写过几条 oplog 是当时的少数派、从未被多数确认。这些条目会怎样?日志会被截断吗?

展开答案

这些未达多数的条目会被 rollback。但机制上不是截断 oplog 文件——而是借 WiredTiger 的 recover-to-stable-timestamp,把整个存储引擎状态回退到与新 primary 的公共提交点一致的稳定时间戳,被回退的写从存储层一并消失。这把"日志截断"换成了"存储回到一个已知稳定快照",和 04 章的 stable timestamp 是同一套机制。

5.3write concern / read concern

writeConcern 决定一次写要多少节点确认才返回,readConcern 决定一次读看到哪个一致性级别的数据。

为什么需要它

持久性和延迟是一对可调的权衡,不存在对所有写入都最优的固定点。writeConcern 与 readConcern 把这个权衡交到每次操作手里:要更强的不丢与不回滚保证,就多等几个节点;要更低延迟,就少等。两个旋钮分别管"写返回前等谁"和"读看见什么"。

底层机制(深一层):w:majority 在多数节点 durable 后才返回;primary 跟踪 lastCommittedOpTime(majority commit point)。readConcern 的层级:local(不保证已提交,存在回滚风险)/ majority(读 majority commit point 的 WiredTiger 快照,不会被回滚)/ snapshot(钉住一个集群级多数提交时间戳,事务用)/ linearizable(额外发一个 no-op majority 写来确认自己仍是 primary,给实时线性读)。read preference:primary/secondary/nearest——从 secondary 读会读到滞后数据。

concern-knobs mongosh
// 写:等多数节点 durable 才返回(不丢、不被回滚)
db.accounts.updateOne(
  { _id: 7 },
  { $set: { balance: 100 } },
  { writeConcern: { w: "majority" } }
)

// 读:只看已被多数提交的快照,读到的值不会被 rollback
db.accounts.find(
  { _id: 7 }
).readConcern("majority")

// 实时线性读:linearizable 会额外发一个 no-op majority 写,
// 确认本节点仍是 primary,代价最高,只能读 primary
db.accounts.find({ _id: 7 }).readConcern("linearizable")

把常见组合摊开看保证与代价。表里每一行是一个真实会被选用的搭配。

表 5.1 · writeConcern × readConcern 典型组合
组合保证代价
w:1 + readConcern local最快返回;单节点 durable 即应答failover 时有丢失或回滚风险;可读到未提交、将回滚的数据
w:majority + readConcern majority不丢、不回滚;读到的是多数提交快照写要等多数 durable,读看的是稍旧的提交点,延迟更高
w:majority + readConcern linearizable实时线性读,读到最新已提交值额外 no-op majority 写确认主身份,最慢,仅 primary
w:1 + 从 secondary 读读写吞吐高,分散读负载secondary 有复制滞后,读到旧值;写在 failover 时也有丢失风险
想一想

一次 w:majority 的写已返回成功,紧接着用 readConcern: local 从 primary 读同一文档。读到的一定是这次写的值吗?读到的会被回滚吗?

展开答案

读到的是这次写的值——primary 上 local 读看到的是最新已应用的状态,而这次写既已在 primary 应用、又已多数 durable。它不会被回滚,但这份"不会回滚"的保证来自写用了 w:majority,不是来自 readConcern local。换 readConcern majority 读会同样安全;二者的区别在别处——若写只是 w:1,local 读可能读到尚未多数提交、将被回滚的数据,而 majority 读不会。

5.4因果一致性、HLC,与"持久性 ≠ 可见性"

一个写在本地持久,不等于它已对多数可见;这两件事被 write/read concern 分开调。

为什么需要它

分布式读写里有一类高频期望:"刚写入的值,紧接着的读要能看到"(read-your-writes),以及"读到的顺序不倒退"(单调读)。这些保证不靠墙钟,因为节点间时钟不可信。因果一致性用一个逻辑时间把"谁先于谁"传递下去,读端据此等待节点追平。

底层机制(深一层):HLC(Hybrid Logical Clock)生成 $clusterTime,随每条消息 gossip,并且 HMAC 签名防客户端伪造逻辑时间;afterClusterTime 让读等节点追上某个 cluster time,给 read-your-writes / 单调读。HLC 选型:胜过 Lamport 钟(无物理时间亲和)、向量钟(消息 O(N) 膨胀)、纯墙钟(要时钟同步)。

核心洞察 · 第二条主线

w:1 返回成功的写,在 failover 后可被回滚——有时甚至没有 rollback 文件留下痕迹。"返回成功" ≠ "持久且可见"。一次写返回成功只说明它在某个节点本地落了盘;它是否已跨越多数、是否不可回滚、是否对其他读者可见,是由 writeConcern 和 readConcern 分别决定的另外两件事。把"应答"等同于"安全",是这一层最常见的误判。

一次写入的两个时刻:返回 ≠ 不可回滚 time t0 primary 本地 durable w:1 此刻返回 t1 多数确认 = majority commit 此后不可回滚 危险窗口:此时 failover,w:1 的写就丢
图 5.2一次写入的持久性时间线。注意:w:1 在 t0 就返回成功,但真正"不可回滚"要等到 t1;这段时间差就是持久性与可见性的缝隙。

因果一致性的用法是开一个会话,让连续操作携带并推进 $clusterTime,读端用 afterClusterTime 等节点追平。

causal-session mongosh
// 因果一致性会话:写后读保证 read-your-writes
const s = db.getMongo().startSession({ causalConsistency: true });
const coll = s.getDatabase("app").profiles;

coll.updateOne({ _id: 7 }, { $set: { nick: "lin" } },
               { writeConcern: { w: "majority" } });

// 同一会话内的读携带上一步的 $clusterTime(HMAC 签名,不可伪造),
// 读端用 afterClusterTime 等本节点追平该 cluster time 再返回
coll.find({ _id: 7 }).readConcern("majority");  // 必读到 nick: "lin"
s.endSession();
想一想

从 secondary 读,readConcern local,刚写入(w:1)的数据一定读得到吗?

展开答案

不一定——secondary 可能还没重放到该 oplog 条目,读到旧值。w:1 只保证写在 primary 本地落盘,复制到 secondary 有滞后,而 local 读又不等任何提交点。要 read-your-writes 用因果一致性会话 + majority:会话把写的 $clusterTime 带给后续读,afterClusterTime 让 secondary 先追平再返回,majority 又保证读到的值不会被回滚。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 为什么 oplog 条目必须幂等?举一个被改写的例子($inc → $set)。
  2. 副本集协议和 Raft 的两个关键差异是什么?
  3. w:majority 与 readConcern majority 各保证什么?
  4. (综合题)"返回成功"的写在什么条件下还会丢?怎么配置避免?
答案(先做完再展开)
  1. 因为同一批 oplog 会被重传、节点重启后重放,重复重放不能改变结果。例:应用发 $inc: { hits: 1 },primary 执行后把结果落成绝对值 $set: { hits: 42 } 写进 oplog;secondary 重放一遍是 42,重放多遍仍是 42。相对操作转成绝对结果即幂等。
  2. 任取其二:(1) 日志条目是幂等操作而非状态机命令;(2) 复制是 secondary pull 而非 Raft 的 AppendEntries push;(3) commit point 由 primary 轮询多数节点的 lastDurable/lastWritten 算出的 optime 水位,而非逐条计票;(4) 有 priority takeover 与 dry-run election;(5) rollback 用 recover-to-stable-timestamp 而非截断日志。
  3. w:majority:写在多数节点 durable 后才返回,因此不丢、不会被回滚。readConcern majority:读 majority commit point 的 WiredTiger 快照,看到的数据已被多数提交、不会被回滚(但可能比最新写稍旧)。一个管写返回前等谁,一个管读看见哪个快照。
  4. 用 w:1 的写在 t0 返回后、t1 多数确认前若发生 failover,可能被回滚而丢失(图 5.2 的危险窗口)。避免:写用 w:majority,要读到不被回滚的值再配 readConcern: majority;需要 read-your-writes 时加因果一致性会话。
进阶挑战 · 刚好够不着

按访问点分别选一致性级别

设计一个一致性方案:用户改完资料后立刻跳详情页必须看到新值,但允许全站列表页读到稍旧数据以换取吞吐。先不查资料,写出详情页和列表页各自的 writeConcern / readConcern / read preference,并说明每个选择的理由。

提示(卡住再展开)

详情页用因果一致性会话(causally consistent session)+ readConcern majority 或直接读 primary,保证 read-your-writes:改资料的写用 w:majority,同会话的详情读带上该写的 $clusterTime,读端追平后再返回。列表页可读 secondary + readConcern local 换吞吐,接受复制滞后带来的稍旧数据。要点是按访问点分别选一致性级别,而不是全站一刀切——强一致只加在真正需要 read-your-writes 的那条路径上。