MongoDB 系列 · 第七篇

读写一致性:写进去了,为什么读不到

上一篇的选举里提到,新主节点要靠"多数派确认"来判断自己没有落后。这个"多数派"概念不只用在选举上——它同时决定了写入要不要等确认(writeConcern)、读的时候要不要看到"确认过的"数据(readConcern)。这一篇故意制造一个从节点"卡住"的场景,亲手写一条数据,然后用不同的 readConcern 去读同一条数据,看它到底"读不读得到"。

本机在真实 3 节点副本集上,用 MongoDB 自带的真实 rsSyncApplyStop failpoint 冻结两个从节点的 oplog 应用,制造一个可控的"多数派确认不了"的真实场景。

1 次真实冻结实验
冻结两个从节点的 oplog 应用,写入后分别用 local/majority 两种 readConcern 读同一条数据
4 处真实源码
readConcern 级别定义、多数派提交点的等待逻辑、writeConcern 超时错误的真实来源

真实实验:冻结两个从节点

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)会一直停在冻结前那一刻,不会再往前走。

真实实验:写一条数据,用两种 readConcern 分别读

在主节点上用 w:1 写一条数据(只要主节点自己写完就算成功,不等从节点),然后立刻用 localmajority 两种 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 看来,这条数据"还没发生"。

真实源码:readConcern 一共有哪几种

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 主要用在分片场景,不关心文档是否已经完成迁移。这一篇聚焦最常被拿来对比的 localmajority

真实源码: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 应用到某个位置之后才会往前推进——两个从节点被冻结,这个"多数确认点"就停在冻结前那一刻,新写入自然进不去。

真实实验:writeConcern:majority 会等到超时

既然多数派确认不了,那要求"必须等多数派确认"的写入会怎样?

真实实测
$ 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 输出断言相等。

参考与说明

  • 本文源码引用(ReadConcernLevel 枚举、waitUntilMajorityOpTimeWriteConcernTimeout 错误构造)均取自 mongodb/mongo 仓库 r8.3.7 标签,与本机安装的 MongoDB Community 8.3.7 版本一致,直接从 GitHub 拉取源文件核对过函数签名、关键调用和行号。
  • 全部写入结果、readConcern 读取结果、超时报错均为本机真实 3 节点副本集(端口 29001/29002/29003)产生,通过真实的 rsSyncApplyStop failpoint 冻结从节点复制,未做删改;实验结束后已关闭这 3 个测试用的 mongod 进程。
  • 演示数据的自检:多数派提交点用两种完全不同的算法(排序取值 / 逐候选值计数)独立算过,每一步结果一致;5 个关键的可见性/超时结果逐项和真实 mongosh 输出断言相等。
  • 没有涉及:linearizable 和 snapshot 两种 readConcern 的具体实现细节、causal consistency(因果一致性会话)如何用 afterClusterTime 实现、分片集群下读写关注的额外复杂度(下一篇分片策略会涉及部分内容)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电