上一篇确认了一件事:mongod 自己不管数据怎么存,真正落盘、管并发、管崩溃恢复的是 WiredTiger。这一篇往下钻一层,回答三个具体问题:两个人同时读写同一条文档,读者为什么不会被写者卡住(MVCC);一份文档在磁盘上到底是什么形状(B-tree 页);数据什么时候才算真正落盘、崩溃后能恢复到哪(checkpoint)。
三个问题都不是查文档得到的答案——本机把 mongod 临时改成单节点副本集,跑了一个真实的双会话并发实验;又直接读了正在跑的 WiredTiger 引擎的真实统计数据;再对照 MongoDB Server 真实源码(v8.3.7 标签)核实每一条结论。
多文档事务(以及下面要用的 snapshot 读)在单机 standalone 模式下不可用,MongoDB 要求至少是副本集。
$ mongosh --quiet --eval "rs.status().ok"MongoServerError: not running with --replSet # 给 /opt/homebrew/etc/mongod.conf 加两行,重启,初始化一个单节点副本集: $ echo -e "replication:\n replSetName: rs0" >> /opt/homebrew/etc/mongod.conf $ brew services restart mongodb-community $ mongosh --quiet --eval "rs.initiate()" { info2: 'no configuration specified. Using a default configuration for the set', ok: 1, ... }
单节点副本集在本地开发里很常见——它不提供真正的高可用,但让本机具备了跟生产集群一致的事务、readConcern、writeConcern 语义,后面几篇讲副本集选举、读写一致性也会继续用这个环境。
会话 A 开一个 snapshot 事务读一次;会话 B 在旁边直接改并提交同一条文档;A 在事务里再读一次——看它读到的是新值还是旧值。
$ mongosh --quiet mvcc_demo.jsA: first read inside snapshot txn -> {"_id":1,"item":"stapler","qty":25} B: updated qty to 999 OUTSIDE the transaction, matched=1 modified=1 B: immediate read outside any transaction -> {"_id":1,"item":"stapler","qty":999} A: second read, STILL inside the same open snapshot txn -> {"_id":1,"item":"stapler","qty":25} A: committed the (read-only) transaction A: read AFTER commit, new connection/no txn -> {"_id":1,"item":"stapler","qty":999}
B 已经把 qty 改成 999 并提交了,B 自己立刻读到的也是 999——数据本身没有任何问题。但 A 在事务里的第二次读,读到的还是 25。A 没有被锁住、没有报错、也不是读到了脏数据,它就是在一个"过去的时刻"里,而且这个时刻从事务开始那一刻起就被冻结了。这就是 MVCC:并发靠的不是互斥,是给每个读者发一份属于它自己的、固定不变的数据快照。
WiredTiger 给每个事务分配一个递增的 txn_id,再给每个读事务一对 (snap_min, snap_max),用来判断别人的写对自己可不可见。
src/third_party/wiredtiger/src/include/txn.h · mongodb/mongo @ r8.3.7, L301 /* * WT_TXN_SNAPSHOT -- * A structure to store the transactions snapshot details. */ struct __wt_txn_snapshot { /* * Snapshot data: * txn_ids >= snap_max are invisible, * txn_ids < snap_min are visible, * everything else is visible unless it is in the snapshot. */ uint64_t snap_max, snap_min; uint64_t *snapshot; uint32_t snapshot_count; };
这三行注释就是上面那个实验的完整解释。A 的事务开始时被分配了 snap_min=101, snap_max=102(数值是示意,规则是真的)。B 提交时用的 txn_id 是 102,满足 txn_id ≥ snap_max → 对 A 不可见。A 事务里不管等多久、B 提交了多少次,这对 snap_min/snap_max 只在事务开始那一刻确定一次,不会跟着后面的提交变化——所以 A 前后两次读结果完全一样。事务提交之后,A 下一次读会重新申请一对新的 snap_min/snap_max,这时 102 已经小于新的 snap_min,变成可见。
按上面真实实验 + 真实可见性规则,把整个过程拆成 8 步——文档在磁盘/内存里其实是一条按 txn_id 排列的版本链,"读到哪个值"只是在这条链上按规则找第一个可见版本。
事件数据由 Python 脚本按上面的可见性规则生成:A 事务内两次读的结果断言必须相等;A 提交前后的两次不同快照结果必须不同;还有一套独立重写的 resolver 函数交叉核对过每一步。最关键的一步——脚本算出来的 4 个读结果,和上面真实 mongosh 会话捕获的 SUMMARY_JSON 逐字段断言相等,不是"看起来差不多"。
上一篇看到的 .wt 文件不是一整块连续数据,内部是 B-tree:internal page 存的是指向子页的指针,leaf page 才真正存文档。
src/third_party/wiredtiger/src/include/btmem.h · mongodb/mongo @ r8.3.7, L596 /* * WT_PAGE -- * The WT_PAGE structure describes the in-memory page information. */ struct __wt_page { /* Per page-type information. */ union { /* * Internal pages (both column- and row-store). * * In-memory internal pages have an array of pointers to child * structures, maintained in collated order.
src/third_party/wiredtiger/src/include/btmem.h · mongodb/mongo @ r8.3.7, L722 /* * Page entry count, page-wide prefix information, type and flags are positioned at the end of * the WT_PAGE union to reduce cache misses when searching row-store pages. * * The entries field only applies to leaf pages, internal pages use the page-index entries * instead. */ uint32_t entries; /* Leaf page entries */
同一个 WT_PAGE 结构体,internal page 和 leaf page 存的是两种完全不同的东西:internal page 的 union.intl 里是一个指向子页的指针数组(page-index);leaf page 才有真正的 entries 字段存 key/value。查一条文档,就是从根页(internal page)按 key 比较一路选子指针往下,走到某个 leaf page 才真正碰到数据。
$ mongosh --quiet blogdemo --eval "db.orders.aggregate([{\$collStats:{storageStats:{}}}]).toArray()[0].storageStats.wiredTiger.btree"
{
'maximum internal page size': 4096,
'maximum leaf page size': 32768,
'maximum tree depth': 2,
'btree checkpoint generation': 140,
...
}
这就是上面结构体在真实运行实例里的具体数字:internal page 上限 4KB(只放指针和 key,要小,一次 I/O 能取更多、树能更"胖"、层数更少),leaf page 上限 32KB(要放真正的文档数据)。maximum tree depth: 2 是因为 orders 这个 collection 数据量太小——一个 leaf page 装得下,树还没有机会真正长高。
journal 让每次写都能崩溃后恢复,但 journal 会无限变长——checkpoint 就是定期把内存里的脏页真正刷到 B-tree 文件,让 journal 可以从这个点截断。
每次 checkpoint 要遍历所有脏页并写盘,频率太高会跟正常的读写请求抢磁盘 I/O,大部分刷盘工作还是重复的(同一批页在两次 checkpoint 之间没怎么变)。
journal 里未被 checkpoint 覆盖的部分不能删,攒得越久文件越大;一旦崩溃重启,要重放的 journal 越多,恢复时间越长。
默认每 60 秒触发一次(storage.syncPeriodSecs,即 syncdelay),而且这个周期线程是 mongod 自己实现的,特意没有用 WiredTiger 引擎自带的周期 checkpoint 机制。
mongod 里有一个专门的 Checkpointer 线程,睡 syncdelay 秒就醒来触发一次;而且源码里特意警告过不要同时开 WiredTiger 自己内置的 checkpoint 线程。
src/mongo/db/storage/checkpointer.cpp · mongodb/mongo @ r8.3.7, L98 // Wait for 'storageGlobalParams.syncdelay' seconds; or until either shutdown is // signaled or a checkpoint is triggered. LOGV2_DEBUG(7702900, 1, "Checkpoint thread sleeping", "duration"_attr = static_cast<std::int64_t>(storageGlobalParams.syncdelay.load())); _sleepCV.wait_for(lock, stdx::chrono::seconds( static_cast<std::int64_t>(storageGlobalParams.syncdelay.load())), [&] { return _shuttingDown || _triggerCheckpoint; });
src/mongo/db/storage/wiredtiger/wiredtiger_kv_engine.cpp · mongodb/mongo @ r8.3.7, L3269 if (wtConfig.extraOpenOptions.find("checkpoint=") != std::string::npos) { LOGV2_WARNING( 10781300, "WiredTiger internal checkpointing is configured via the WiredTiger engine " "configuration string. mongod runs its own periodic checkpointing thread, and " "configuring `checkpoint=` in the WiredTiger config may result in two " "independent checkpointing loops. Please remove `checkpoint=` from the " "WiredTiger engine config string."); }
第一段是这个周期线程的核心循环:睡眠时长直接就是 syncdelay,醒来后(除非被提前唤醒)触发一次 checkpoint。第二段更直接地说明了为什么——WiredTiger 引擎本身也带一套内置的周期 checkpoint 机制(通过连接配置字符串里的 checkpoint= 开启),但 mongod 选择自己另起一个线程管这件事,如果两边同时开,会变成两条互相不知道对方存在的 checkpoint 循环,所以源码里直接对这种配置发警告。
直接问正在跑的这个 mongod 实例。
$ mongosh --quiet --eval "db.adminCommand({getParameter:1, syncdelay:1})"
{ syncdelay: 60, ok: 1 }
# 这个实例从启动到现在,WiredTiger 层面真实执行过的 checkpoint 次数:
$ mongosh --quiet --eval "db.serverStatus().wiredTiger.checkpoint['total succeed number of checkpoints']"
138
syncdelay 的默认值确实是 60 秒,跟上面 Checkpointer::run() 里睡眠的秒数是同一个变量;138 这个数字来自本机这个 mongod 实例真实跑了一段时间后积累下来的 checkpoint 计数,不是文档里抄来的数字。