上一篇的选举里提到,新主节点要靠"多数派确认"来判断自己没有落后。这个"多数派"概念不只用在选举上——它同时决定了写入要不要等确认(writeConcern)、读的时候要不要看到"确认过的"数据(readConcern)。这一篇故意制造一个从节点"卡住"的场景,亲手写一条数据,然后用不同的 readConcern 去读同一条数据,看它到底"读不读得到"。
本机在真实 3 节点副本集上,用 MongoDB 自带的真实 rsSyncApplyStop failpoint 冻结两个从节点的 oplog 应用,制造一个可控的"多数派确认不了"的真实场景。
3 节点副本集,用 MongoDB 内部真实的测试用 failpoint 让两个从节点暂停应用 oplog——模拟"从节点卡住了"。
$ mongosh --port 29002 --eval '
db.adminCommand({ configureFailPoint: "rsSyncApplyStop", mode: "alwaysOn" })
'
$ mongosh --port 29003 --eval '
db.adminCommand({ configureFailPoint: "rsSyncApplyStop", mode: "alwaysOn" })
'
{ count: 0, ok: 1, ... } // 两个从节点都返回成功,现在它们不再应用新的 oplog 记录
rsSyncApplyStop 是 MongoDB 源码里真实存在、专门用于测试复制延迟场景的 failpoint(需要在启动参数里加 enableTestCommands=1 才能用)。冻结之后,这两个从节点的 oplog 应用位置(appliedOpTime)会一直停在冻结前那一刻,不会再往前走。
在主节点上用 w:1 写一条数据(只要主节点自己写完就算成功,不等从节点),然后立刻用 local 和 majority 两种 readConcern 各读一次。
$ mongosh --port 29001 blogdemo --eval '
db.runCommand({
insert: "accounts",
documents: [{_id:1, balance:100}],
writeConcern: { w: 1 }
});
db.runCommand({ find:"accounts", filter:{_id:1}, readConcern:{level:"local"} });
db.runCommand({ find:"accounts", filter:{_id:1}, readConcern:{level:"majority"} });
'
write result (w:1): { n: 1, ok: 1, ... } // 写入立刻成功
readConcern local: [ { _id: 1, balance: 100 } ] // 读到了
readConcern majority: [ ] // 读不到!
同一条刚写完的数据,local 立刻能读到,majority 却读不到——这不是 bug,也不是主节点丢了数据(数据明明就在那)。majority 读的是"已经被多数节点确认过"的那个版本,而两个从节点都被冻结了,压根没机会确认这条新写入,所以在 majority 看来,这条数据"还没发生"。
src/mongo/db/repl/read_concern.idl · mongodb/mongo @ r8.3.7, L39 enums: ReadConcernLevel: description: Enumeration representing ReadConcern levels type: string values: kLocalReadConcern: "local" kMajorityReadConcern: "majority" kLinearizableReadConcern: "linearizable" kAvailableReadConcern: "available" kSnapshotReadConcern: "snapshot"
local 就是"我这个节点自己现在有什么就读什么",不关心有没有被别的节点确认;majority 只读被多数节点确认过的数据;linearizable 比 majority 更严格(只能在主节点上用,会多一次写入确认自己仍是主节点);snapshot 用在多文档事务里;available 主要用在分片场景,不关心文档是否已经完成迁移。这一篇聚焦最常被拿来对比的 local 和 majority。
src/mongo/db/repl/replication_coordinator_impl.cpp · mongodb/mongo @ r8.3.7, L1982 Status ReplicationCoordinatorImpl::waitUntilMajorityOpTime(...) { ... auto ok = opCtx->waitForConditionOrInterruptUntil( _currentCommittedSnapshotCond, lock, deadline.value_or(Date_t::max()), [&] { return _inShutdown || (targetOpTime <= _getCurrentCommittedSnapshotOpTime(lock)); });
关键就是这一行条件:targetOpTime <= _getCurrentCommittedSnapshotOpTime(lock)——只有当"这条写入的位置"小于等于"当前已被多数节点确认的位置"(_currentCommittedSnapshot),majority 读才会把它包含进来。而 _currentCommittedSnapshot 只有在多数节点(3 个里的 2 个)都把 oplog 应用到某个位置之后才会往前推进——两个从节点被冻结,这个"多数确认点"就停在冻结前那一刻,新写入自然进不去。
既然多数派确认不了,那要求"必须等多数派确认"的写入会怎样?
$ mongosh --port 29001 blogdemo --eval '
db.runCommand({
insert: "accounts",
documents: [{_id:2, balance:200}],
writeConcern: { w: "majority", wtimeout: 3000 }
});
'
MongoServerError: waiting for replication timed out // 3021ms 后抛出
# 但数据本身写没写进去?再用 local 读一次:
$ mongosh --port 29001 blogdemo --eval '
db.accounts.find({_id:2}).toArray()
'
[ { _id: 2, balance: 200 } ] // 写进去了!超时的只是"等确认"这一步
两个关键点:第一,超时时间精确卡在 wtimeout 设的 3000ms 附近(实测 3021ms);第二,超时报错不代表写入失败——balance:200 已经真实躺在主节点的 collection 里了,w:"majority" 只是在写入完成之后,多等了一步"有没有足够多节点确认",这一步等不到才报错,跟数据有没有落盘是两件事。
src/mongo/db/repl/replication_coordinator_impl.cpp · mongodb/mongo @ r8.3.7, L2352 // If we get a timeout error and the opCtx deadline is >= the writeConcern wtimeout, then we // know the timeout was due to wtimeout (not opCtx deadline) and thus we return // ErrorCodes::WriteConcernTimeout. if (status.code() == timeoutError && opCtxDeadline >= wTimeoutDate) { status = Status{ErrorCodes::WriteConcernTimeout, "waiting for replication timed out"}; }
"waiting for replication timed out"这句话是死代码里写死的错误文案,跟我们真实抓到的报错一字不差。这段代码本身也说明了它的位置——这是在"等待复制完成"这一步单独设的超时判断,跟写入本身(前面已经完成)是分开的两个阶段。
把上面全部真实实验按发生顺序串成一条演示,每一步展示 3 个节点各自的应用位置和"多数派提交点"。
判断逻辑由 Python 脚本转写:多数派提交点用"排序取第 2 高值"计算,还有一个用"挨个候选值数有多少节点达到"的完全不同算法独立复算,两者每一步都断言相等;5 个关键结果(写入#1 后 local/majority 可见性、写入#2 是否超时、超时后 local 是否仍可见、恢复复制后 majority 可见的文档数)逐项和真实 mongosh 输出断言相等。