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 写同一行:后到事务在行级锁上阻塞等待,前一个提交后它接着改——序列化但不丢工作。WiredTiger 写同一文档:后到事务在提交期直接判败(WriteConflict),整个操作 abort 后由上层重放。边界:这意味着 MongoDB 的写冲突代价是"重做一遍",在高争用同文档场景下重试会放大;Postgres 的代价是"排队等待",体现为延迟而非重算。选择不同,热点行为也不同。
// 确认存储引擎与每集合的 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 把回收折叠进了本来就要做的刷盘动作,不再有单独的清扫扫描。
一个事务开了一个很久不提交的快照读,期间同一批文档被反复更新很多版本。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 把刷盘压力直接回灌到应用线程,所以过载表现更"硬"。
磁盘上一个集合压缩后是 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 重放)——这一层两者的形态最接近,区别更多在默认间隔和参数名。
| 维度 | 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
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 为什么 MongoDB 没有 VACUUM?被替换掉的旧文档版本去了哪里、什么时候被真正回收?
- cache 满到阈值时具体发生了什么?为什么把这个行为叫"延迟悬崖"而不是"逐渐变慢"?
- checkpoint 和 journal 各负责持久性的哪一段?崩溃恢复时两者怎么配合?
- (对照题)WiredTiger 的文档级乐观 MVCC 与 Postgres 的行级 MVCC,在处理"两个写打到同一条记录"时差在哪一步?
答案(先做完再展开)
- 因为旧版本回收被折叠进了 eviction / checkpoint 的页重整,不需要单独的清扫扫描。旧版本在页重整时被推进磁盘上的 history store,一旦没有任何活跃快照引用它,就在重整中被丢弃;Postgres 则要靠后台 VACUUM 扫表清死元组。
- 越过
eviction_trigger/eviction_dirty_trigger阈值后,应用线程被征召同步参与 eviction,必须先帮引擎腾出空间才能推进自己的操作。叫悬崖是因为这是阶跃式的——从"后台默默 evict"突然切到"前台线程替引擎打工",p99 陡升,而非线性退化。 - checkpoint(~60s)落一份一致磁盘快照,覆盖到快照点为止的所有已刷数据;journal/WAL 覆盖两次 checkpoint 之间的窗口。恢复 = 加载最后一个完好 checkpoint,再重放其后的 journal 到崩溃点。
- 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。