之前四篇都是单节点视角。副本集要解决的问题是:主节点上的一次写入,怎么变成从节点上一份一模一样的数据?答案是一份叫 oplog 的特殊 capped collection——每次写入的同时,都会在这份日志里追加一条描述"发生了什么"的记录,从节点做的事情,说到底就是不停地把这份日志读过来、按顺序重放一遍。
这一篇本机把 mongod 配成了单节点副本集,直接翻真实的 local.oplog.rs 集合看每种操作长什么样,还故意把同一条 oplog 记录重放了两遍验证"幂等"这件事是不是真的——对照 MongoDB Server 真实源码(v8.3.7 标签)确认每一条结论。
本机已经是一个单节点副本集(rs0)。插入一条文档,直接去 local.oplog.rs 里找刚写的这条记录。
$ mongosh --quiet blogdemo --eval 'db.orders.insertOne({_id:1, item:"pen", qty:10})'
$ mongosh --quiet --eval '
db.getSiblingDB("local").oplog.rs
.find({ns:"blogdemo.orders"}).sort({$natural:-1}).limit(1)
'
{
op: 'i',
ns: 'blogdemo.orders',
ui: UUID('5012380f-...'),
o: { _id: 1, item: 'pen', qty: 10 },
o2: { _id: 1 },
ts: Timestamp({ t: 1788341591, i: 3 }),
t: Long('1'),
v: Long('2'),
wall: ISODate('2026-09-02T09:33:11.340Z')
}
op:'i' 表示这是一次插入,o 就是插入的完整文档,ns 是命名空间,ts 是这条操作在整个集群时间线上的唯一位置(用来给从节点当"读到哪了"的书签)。从节点要做的,就是把这个 o 原样插入自己的 blogdemo.orders。
上面这份文档不是随手拼的 JSON,字段名和操作类型都是源码里定义好的。
src/mongo/db/repl/oplog_entry.idl · mongodb/mongo @ r8.3.7, L48 enums: OpType: description: "The type of an operation in the oplog" type: string values: kCommand: "c" kInsert: "i" kUpdate: "u" kDelete: "d" kNoop: "n"
src/mongo/db/repl/oplog_entry.idl · mongodb/mongo @ r8.3.7, L131 DurableReplOperation: description: "A document that represents an operation. ..." fields: op: { type: OpType, description: "The operation type" } ns: { type: namespacestring, description: "The namespace on which to apply the operation" } ui: { type: uuid, optional: true, description: "The UUID of the collection" } o: { type: object, description: "The operation applied" } o2: { type: object, optional: true, description: "Additional information about the operation applied" }
OpType 枚举里的 i/u/d,跟上面真实抓到的 op:'i' 完全对上;DurableReplOperation 这个结构体定义了每条 oplog 记录该有哪些字段——o 是"这次操作本身",o2 是"额外信息"(对更新和删除来说通常是被操作文档的 _id,用来定位)。
如果 oplog 原样记录"给 qty 加 1",从节点断线重连后可能会重放到同一条记录两次,那就变成"加了 2"——真实情况不是这样。
$ mongosh --quiet blogdemo --eval '
db.orders.updateOne({_id:1}, {$set:{qty:99}, $inc:{views:1}})
'
$ mongosh --quiet --eval '
db.getSiblingDB("local").oplog.rs
.find({ns:"blogdemo.orders", op:"u"}).sort({$natural:-1}).limit(1)
'
{
op: 'u',
ns: 'blogdemo.orders',
o: {
'$v': 2,
diff: { u: { qty: 99 }, i: { views: 1 } }
},
o2: { _id: 1 }
}
$inc:{views:1} 在 oplog 里变成了 i:{views:1}——不是"给 views 加 1",是"把 views 设成 1"这个绝对值。u 和 i 分别表示"把已有字段设为这个值"和"插入一个新字段为这个值",两者存的都是最终结果,不是操作过程。
src/mongo/db/update/update_oplog_entry_version.h · mongodb/mongo @ r8.3.7, L57 // Delta style update, introduced in 4.7. When a pipeline based update is executed, the pre and // post images are diffed, producing a delta. The delta is recorded in the oplog. On // secondaries, the delta is applied to the pre-image to recover the post image. // // Delta style updates cannot be executed directly by users. kDeltaV2 = 2,
关键在"pre and post images are diffed"——服务器先把更新前后的完整文档做一次 diff,记录下来的是这次 diff(最终该长什么样),不是用户敲的那个 $inc 操作符本身。这样一来,不管这条 oplog 记录被重放几次,结果都是"把这些字段设成这些值",而不是"再加一次"。
直接用 applyOps 把上面那条 u 记录在本机重放两次,看 qty 会不会变成 198。
$ mongosh --quiet blogdemo --eval '
const op = { op:"u", ns:"blogdemo.orders",
o:{ $v:2, diff:{ u:{qty:99}, i:{views:1} } }, o2:{_id:1} };
print("重放前:", JSON.stringify(db.orders.findOne({_id:1})));
db.runCommand({ applyOps: [op] });
print("重放#1后:", JSON.stringify(db.orders.findOne({_id:1})));
db.runCommand({ applyOps: [op] });
print("重放#2后:", JSON.stringify(db.orders.findOne({_id:1})));
'
重放前: {"_id":1,"item":"pen","qty":99,"views":1}
重放#1后:{"_id":1,"item":"pen","qty":99,"views":1}
重放#2后:{"_id":1,"item":"pen","qty":99,"views":1}
重放两次,qty 始终是 99,views 始终是 1——完全没有"越重放越大"的问题。这就是幂等性在真实数据库里的样子,不是一句口号。
从节点不是定时轮询,是开一个一直挂着的游标,主节点这边一有新记录就立刻推过去。
src/mongo/db/repl/oplog_fetcher.h · mongodb/mongo @ r8.3.7, L72 /** * The oplog fetcher, once started, reads operations from a remote oplog using a tailable, * awaitData, exhaust cursor. * * The initial `find` command is generated from the last fetched optime.
tailable 游标专门为 capped collection 设计,读到末尾不会关闭,而是等新数据;awaitData 让这个等待在服务端阻塞而不是客户端空转轮询;exhaust 省掉了每次 getMore 的往返。断线重连时,从节点带着自己"最后一次成功应用到哪个 ts"重新发起 find,从那个位置继续读——这就是为什么 oplog 里的每一条记录都要带 ts,它是从节点唯一的书签。
oplog 是一个 capped collection,满了就从最老的记录开始覆盖。断线太久,书签指向的那个位置可能已经被冲掉了。
$ mongosh --quiet --eval '
db.getSiblingDB("local").oplog.rs.stats().capped
'
true
$ mongosh --quiet --eval "rs.printReplicationInfo()"
configured oplog size: '192 MB'
log length start to end: '21175 secs (5.88 hrs)'
这个副本集当前配置的 oplog 上限是 192MB,按现在的写入速度,能装下大约 5.88 小时的历史记录。断线时间只要不超过这个窗口,从节点重连后还能在 oplog 里找到自己上次的 ts,直接续着读;一旦超过,那个位置的记录已经被更新的记录覆盖掉了,只能做一次完整的初始同步(initial sync),把整个数据集重新拷一遍。
把上面全部真实实验按发生顺序串成一条演示。
判断逻辑由 Python 脚本转写:幂等重放部分直接实现真实 diff 格式的 apply 语义(u/i 都是绝对值),重放两次的结果和真实 applyOps 输出逐字段断言相等,还有一个用字典字面量合并重新实现的独立 apply 函数交叉核对;oplog 窗口部分用真实 rs.printReplicationInfo() 的两个数字(192MB / 21175 秒)算出临界点,断线时长恰好等于窗口的那一刻精确地从"能续传"翻转成"要重新同步"。