Chapter 04

WiredTiger 存储引擎

前三章都停在逻辑层——文档怎么建模、怎么查、怎么建索引。这章下沉到物理层:数据和索引到底怎么落盘、并发怎么控制、崩溃后怎么不丢。对照对象是 Postgres 的 heap + shared_buffers + WAL + VACUUM,逐条还账。

本章你将建立的 schema

  • WiredTiger 是每集合 / 每索引一棵 B-tree;并发是文档级乐观 MVCC,不是行锁。
  • cache(默认约 50% RAM)与 eviction:cache 满时应用线程被征用去 evict——这是延迟悬崖,不是优雅降级。
  • checkpoint(约 60s)+ journal/WAL 共同保证持久性。
  • 旧版本进 history store → 没有 VACUUM;逐条对照 Postgres 的 heap + shared_buffers + WAL + VACUUM。

4.1B-tree 与文档级并发

WiredTiger 给每个集合、每个索引各维护一棵 B-tree;并发写靠文档级乐观 MVCC,冲突即重试。

为什么需要它

逻辑层的"集合"和"索引"在物理层各自落成一棵独立的 B-tree 文件。这一层决定了三件事——数据按什么结构排布、两个并发写如何裁决、热点页放在哪里。理解它,03 章的索引选择和本章后面的 cache 行为才有机制依据。

底层机制(比文档深一层):WiredTiger 默认是行存 B-tree——每个集合一棵 B-tree、每个索引一棵 B-tree,各自是磁盘上一个独立文件。WiredTiger 也提供 LSM-tree 选项(type=lsm),但默认不开,因为 LSM 用读延迟换写吞吐,不适合 MongoDB 的通用读写画像。并发控制是文档级乐观 MVCC,不是锁:一次写入把版本化更新挂到 B-tree page 内存里的 update chain(一条 skip list)上,不就地改 page。两个并发事务改同一文档时,其中一个在提交期检测到冲突、抛出 WriteConflict,由 MongoDB 层自动 abort 并 retry。对照 Postgres:Postgres 是 MVCC-in-heap——新版本作为新元组追加进 heap、旧元组就地留在原页等 VACUUM 清理;写冲突走的是行级锁阻塞,后到的事务等锁释放,而不是被判败重来。机制点完全不同:一个是乐观重试,一个是悲观阻塞。

对照 · Postgres

Postgres 写同一行:后到事务在行级锁上阻塞等待,前一个提交后它接着改——序列化但不丢工作。WiredTiger 写同一文档:后到事务在提交期直接判败(WriteConflict),整个操作 abort 后由上层重放。边界:这意味着 MongoDB 的写冲突代价是"重做一遍",在高争用同文档场景下重试会放大;Postgres 的代价是"排队等待",体现为延迟而非重算。选择不同,热点行为也不同。

storage-engine-stats mongosh
// 确认存储引擎与每集合的 B-tree 文件
db.serverStatus().storageEngine
// → { name: "wiredTiger", ... }

// 看某集合的 WiredTiger 细节:block-manager、btree、cache 占用
db.orders.stats().wiredTiger["block-manager"]
// 写冲突计数(高 = 多个写打在同一批文档上,触发乐观重试)
db.serverStatus().metrics.operation.writeConflicts

4.2MVCC 快照与 history store

每个操作看到一个时间戳确定的快照;旧文档版本被写进磁盘上的 history store。

为什么需要它

快照让读不阻塞写、写不阻塞读——一个长读不会因为别人在写就被挡住,它看到的是开始那一刻的一致视图。代价是旧版本必须留着,直到没有任何快照还需要它。这些旧版本去哪了、谁来回收,正是 MongoDB 与 Postgres 分道的地方。

底层机制(比文档深一层):可见性由 transaction id + commit timestamp 决定——一个操作只看到 commit timestamp 不晚于自己快照点的版本。关键伏笔在这里:MongoDB 用 oplog 的 optime 驱动 WiredTiger 的 commit timestamp,让存储层的版本时间线和复制时间线对齐(05 章副本集会接上这条线)。eviction 把一个 page 写回磁盘时,若该 page 上挂着多个版本,最新值写进数据文件、旧版本写进 history store(磁盘上的一个独立表)。因此 MongoDB 没有 VACUUM——旧版本的回收发生在 eviction / checkpoint 的页重整(reconciliation)里:重整时引擎判断哪些旧版本已无任何活跃快照引用,就地丢弃。对照 Postgres:死元组留在原 heap 页,必须靠后台 VACUUM 进程扫描表把它们标记可重用、并推进 freeze;回收是一个独立的、需要调参(autovacuum 阈值)的后台任务。WiredTiger 把回收折叠进了本来就要做的刷盘动作,不再有单独的清扫扫描。

cache(内存) B-tree page update chain v3 最新 活跃快照可见 v2 旧版本 v1 旧版本 数据文件(磁盘) 最新值落这里 history store 磁盘 · 旧版本归宿 页重整 旧版本被推进 history store,无活跃引用即丢弃
图 4.1一个 B-tree page 上的版本链与去向。注意:旧版本不是就地覆盖、也不是等 VACUUM 扫描,而是在页重整时由最新值进数据文件、旧版本被推进 history store——这就是 MongoDB"无 VACUUM"的机制来源。
想一想

一个事务开了一个很久不提交的快照读,期间同一批文档被反复更新很多版本。history store 会怎样?对照 Postgres 的什么现象?

展开答案(先停 10 秒)

只要那个老快照还活着,它可能仍需要某些旧版本,这些旧版本就不能被页重整丢弃,于是 history store 持续累积、占用磁盘并拖慢相关读。这对应 Postgres 的经典现象:一个长事务持有老 xmin,VACUUM 无法回收晚于它的死元组,导致表膨胀(bloat)。机制不同(history store vs 死元组堆积),但病根同源——长快照拖住旧版本回收。两边的运维结论一致:盯住长事务。

4.3cache 与 eviction:延迟悬崖

WiredTiger cache 默认约 (RAM−1GB) 的 50%,装大多未压缩页;填满到阈值后,应用线程被迫亲自参与 eviction 才能继续。

为什么需要它

cache 是热数据驻留内存的地方,命中率直接决定读写延迟。它的容量公式和"装的是未压缩页"这两点,决定了同一台机器能放多大的工作集;而它满了之后的行为,决定了过载时是平滑变慢还是突然抖动。这一节是本章对生产延迟最有解释力的部分。

底层机制(比文档深一层):WiredTiger cache 默认大小是 max(256MB, 50% × (RAM − 1GB)),缓存的是未压缩页——压缩只发生在磁盘上,进了 cache 就是解压后的形态,所以 cache 的有效容量按未压缩体积算。eviction 由专门的 eviction server + eviction worker 线程做近似 LRU,把页分成 clean / dirty / urgent 几个队列处理:clean 页直接丢,dirty 页要先写回磁盘(顺带做上一节的页重整)。引擎维持两道水位——eviction_target / eviction_dirty_target(后台开始干活)和 eviction_trigger / eviction_dirty_trigger(上限)。一旦越过 trigger 阈值,应用线程被"征召"同步参与 eviction——它得先帮引擎 evict 出空间,才能推进自己的读写。这是一道延迟悬崖(latency cliff):不是后台默默降速,而是前台请求线程突然被拉去打工,p99 陡升。当 working set > cache,页会被反复换进换出(外加每次 miss 的磁盘读),形成持续 thrash。对照 Postgres shared_buffers:脏页由后台 bgwriter 和 checkpointer 刷,前台查询一般不被拉去刷盘(极端情况才会因 buffer 不足而等待);缺页时走 OS page cache 兜底,行为更平滑。WiredTiger 把刷盘压力直接回灌到应用线程,所以过载表现更"硬"。

正常态:cache 未满 超阈值态:cache 满 应用线程 畅通读写 eviction worker 后台 cache 有空位 后台默默腾空间 应用线程 被征用 cache 已满 先帮引擎 evict 才能推进自己的操作 → 延迟悬崖 · p99 陡升
图 4.2cache 两态对比。注意:working set 超过 cache 不是平滑变慢,而是应用线程突然要替引擎打工——被高亮的那条征用路径就是延迟悬崖,p99 延迟会陡升,而非线性退化。
想一想

磁盘上一个集合压缩后是 8GB,cache 还剩 10GB。能断定这个集合的工作集"装得下"吗?

展开答案

不能。cache 装的是未压缩页。snappy 在文档型数据上常有 2–4 倍压缩比,8GB 压缩数据解压后可能是 16–32GB,远超 10GB 剩余 cache。要估能否装下,得按未压缩体积(以及实际热点页占比)算,不能直接拿磁盘文件大小比 cache。这是 cache 缓存未压缩页这一机制最容易被忽略的实务后果。

4.4持久性:checkpoint + journal

checkpoint 每约 60s 落一份一致快照,journal/WAL 覆盖两次 checkpoint 之间的窗口。

为什么需要它

cache 里的脏页在落盘前一旦断电就会丢失。持久性靠两件东西分工兜底:一份定期的一致磁盘快照,加上两次快照之间的连续日志。理解这对组合,才能判断"断电会丢多少"以及不同写关注(write concern)改变了什么。

底层机制(比文档深一层):checkpoint(默认约 60s 一次,或写满约 2GB journal 触发)把 cache 里的脏页刷成一份一致的磁盘快照;旧 checkpoint 在新 checkpoint 完整落盘之前一直有效——崩溃时可以回退到上一个完好的 checkpoint,不会读到写了一半的状态。journal/WAL 提供两次 checkpoint 之间的持久性:写操作先记 journal,引擎组提交(group commit)把一批 journal 攒在一起刷盘,默认约 100ms 一批(用 j:true 则该写同步等自己的 journal 落盘)。崩溃恢复 = 加载最后一个完好 checkpoint,再重放(replay)其后的 journal 到崩溃点。压缩方面:数据块默认 snappy(可选 zstd / zlib,更小但更慢),索引用前缀压缩(相邻 key 共享前缀)。对照 Postgres:checkpoint ≈ Postgres 的 checkpoint(也是落一致快照点),journal ≈ WAL(也是组提交、也是崩溃后从 checkpoint 重放)——这一层两者的形态最接近,区别更多在默认间隔和参数名。

表 4.1 · WiredTiger 机制 ↔ Postgres 机制对照
维度WiredTiger(MongoDB)Postgres
存储布局每集合一棵 B-tree、每索引一棵 B-tree,各自独立文件heap(无序)+ 独立的索引文件
缓存WT cache,缓存未压缩页,默认 ~50%×(RAM−1GB)shared_buffers,缓存页,另有 OS page cache 兜底
并发文档级乐观 MVCC,冲突即 abort+retry行级 MVCC(多版本元组)+ 行锁阻塞
日志journal 组提交(默认 ~100ms 一批)WAL 组提交
旧版本回收history store + 页重整,无 VACUUM死元组堆积 + 后台 VACUUM 扫描清理
刷脏后台 worker;过阈值时应用线程被征用后台 bgwriter / checkpointer,前台一般不刷
想一想

断电时,最后约 100ms 内、未用 j:true 提交的写会怎样?

展开答案

可能丢失。journal 默认约 100ms 才组提交一次落盘,这段窗口内已确认给客户端、但尚未刷进 journal 的写,在断电后无法被恢复重放——它们既不在最后的 checkpoint 里,也不在已落盘的 journal 里。要更强保证:用 j:true(每次写都等自己的 journal 落盘后才返回,牺牲延迟换不丢);或在副本集层用 w:majority(多数节点确认,05 章展开)。两个旋钮作用在不同层:j:true 管单机落盘,w:majority 管跨副本持久。

§本章 self-check

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

  1. 为什么 MongoDB 没有 VACUUM?被替换掉的旧文档版本去了哪里、什么时候被真正回收?
  2. cache 满到阈值时具体发生了什么?为什么把这个行为叫"延迟悬崖"而不是"逐渐变慢"?
  3. checkpoint 和 journal 各负责持久性的哪一段?崩溃恢复时两者怎么配合?
  4. (对照题)WiredTiger 的文档级乐观 MVCC 与 Postgres 的行级 MVCC,在处理"两个写打到同一条记录"时差在哪一步?
答案(先做完再展开)
  1. 因为旧版本回收被折叠进了 eviction / checkpoint 的页重整,不需要单独的清扫扫描。旧版本在页重整时被推进磁盘上的 history store,一旦没有任何活跃快照引用它,就在重整中被丢弃;Postgres 则要靠后台 VACUUM 扫表清死元组。
  2. 越过 eviction_trigger / eviction_dirty_trigger 阈值后,应用线程被征召同步参与 eviction,必须先帮引擎腾出空间才能推进自己的操作。叫悬崖是因为这是阶跃式的——从"后台默默 evict"突然切到"前台线程替引擎打工",p99 陡升,而非线性退化。
  3. checkpoint(~60s)落一份一致磁盘快照,覆盖到快照点为止的所有已刷数据;journal/WAL 覆盖两次 checkpoint 之间的窗口。恢复 = 加载最后一个完好 checkpoint,再重放其后的 journal 到崩溃点。
  4. WiredTiger 让后到的写直接判败(WriteConflict)、abort 后由上层 retry——乐观重试;Postgres 让后到的写在行级锁上阻塞等待,前一个提交后继续——悲观阻塞。一个重算,一个排队。
进阶挑战 · 刚好够不着

估算 working set 与 cache:会不会 thrash

场景:单机内存 64GB,数据集磁盘体积 200GB,负载以随机点查为主。先不查资料,估算 WiredTiger cache 容量、判断这个工作负载会不会 thrash,并给出至少两条缓解方向。

提示(卡住再展开)

cache ≈ (64 − 1) × 0.5 ≈ 31.5GB。判断 thrash 看的不是 200GB 全量,而是热点工作集(未压缩):随机点查若热点工作集 > 31.5GB,页会持续换进换出 + 磁盘读,进入 thrash,p99 抖。缓解方向:(1) 加内存,把 cache 抬到覆盖热点工作集;(2) 分片(06 章)把热点摊到多台,各自工作集落进各自 cache;(3) 收紧 schema——短字段名 / 去掉冗余大字段 / 拆出冷字段,直接缩小文档体积从而缩小工作集。注意全程按未压缩体积算,别拿 200GB 磁盘数比 cache。