跳到正文

MongoDB 存储模型与 WiredTiger ​

一句话:WiredTiger 用「内存缓存 + journal 预写日志 + checkpoint 定期落盘」换写入吞吐和崩溃恢复能力;代价是内存要按 cache 单独规划,持久化语义要看写关注和 journal 配置。

适用版本:MongoDB 7.0 / 8.0。WiredTiger 自 3.0 起成为默认引擎;MMAPv1 已在 4.2 移除。

为什么需要 ​

文档数据库如果每次写入都直接改磁盘页,随机 IO 会把吞吐压死;如果只写内存不管磁盘,进程一崩就丢数据。存储引擎要同时回答三个问题:

  1. 写操作怎样尽快返回,又不至于丢太多数据?
  2. 内存里的脏页什么时候、以什么顺序落盘?
  3. 崩溃后从哪里开始恢复,恢复到哪个一致点?

WiredTiger 的答案是:写内存页并记 journal,后台 checkpoint 定期把一致快照落盘;恢复时从最近 checkpoint 开始重放 journal。这套机制和 MySQL 的 redo log + checkpoint 思路相近,但细节不同。

核心机制 ​

BSON 文档 ​

  • BSON 是二进制 JSON,除字符串、数字、布尔、数组、对象外,还有 Date、ObjectId、Binary、Decimal128、Timestamp 等类型。
  • 单个用户文档最大 16MB,嵌套深度最大 100 层;超过 16MB 要用 GridFS 或引用化拆分。
  • _id 默认是 12 字节 ObjectId:4B 秒级时间戳 + 5B 机器/进程随机值 + 3B 自增计数器。前 4 字节让它大致按创建时间有序,对 B 树索引友好。

并发控制与锁粒度 ​

WiredTiger 采用文档级并发控制:

  • 数据库和集合上拿的是意向锁(IS / IX),表示「我打算在更细粒度上加锁」,不是把整库或整集合锁住。
  • 真正的写冲突发生在文档级;两个事务改不同文档可以并行,改同一文档时后写者检测到冲突并失败重试。
  • 读操作走 MVCC 快照,不阻塞写;写也不阻塞读。只有少数管理操作(如某些 DDL、rename)需要更粗的锁。

所以「WiredTiger 是库级锁 / 集合级锁」是错的;「文档级锁比 MySQL 行锁更细」也只对了一半——粒度相当,差异主要在冲突检测和重试方式。

缓存、journal 与 checkpoint ​

  • Cache:默认大小为 max(256MB, 50% × (RAM - 1GB))(MongoDB 3.4 起);可用 --wiredTigerCacheSizeGB 或配置项覆盖。缓存满时后台 eviction 线程把干净页或脏页淘汰到磁盘。
  • Journal:WiredTiger 的预写日志,默认开启(storage.journal.enabled=true)。普通写入按 journalCommitInterval 批量提交,默认 100ms;写关注带 j:true 时,每次写操作都会等待 journal fsync 后才返回。
  • Checkpoint:至少每 60 秒做一次,把当前内存中的一致快照写入数据文件;journal 增长到约 2GB 时也会触发(具体阈值以官方文档为准)。checkpoint 成功后,checkpoint 之前的 journal 才可以安全回收。
  • 恢复:启动时找到最近一个完整 checkpoint,重放该 checkpoint 之后的 journal,得到崩溃前已提交的写入。没有 journal 或 journal 损坏,就只能恢复到上一个 checkpoint。

压缩与索引 ​

  • 集合默认使用 Snappy 块压缩;可选 zlib、zstd,压缩率更高但 CPU 开销更大。
  • 索引默认开启前缀压缩,减少重复键的存储。
  • 索引本身是 WiredTiger B 树,索引页同样经过 cache 和 checkpoint,因此索引不是「直接存在磁盘上」,索引热页也占 cache。

最小上手 ​

bash
# 单机启动,显式限制 cache 为 4GB(生产按内存规划)
mongod --dbpath /data/db --wiredTigerCacheSizeGB 4 --fork --logpath /var/log/mongod.log

mongosh "mongodb://localhost:27017"
javascript
// 看 cache 使用与 eviction
db.serverStatus().wiredTiger.cache

// 看集合的存储大小、压缩与索引大小
db.products.stats()

// 建一个使用 zstd 压缩的集合(7.0/8.0)
db.createCollection("events", {
  storageEngine: { wiredTiger: { configString: "block_compressor=zstd" } }
})

// 确认当前 journal 配置
db.adminCommand({ getCmdLineOpts: 1 }).parsed.storage

判断是否健康:cache.used 长期贴近 cache.maximum bytes configured、evicted pages 持续增长,说明 cache 偏小或存在全表扫描;先查慢查询,再考虑加内存。

生产动作 ​

  • cache 规划:按 max(256MB, 50% × (RAM - 1GB)) 起步;容器部署要确认实际可用内存,不要把宿主机内存当成容器限额。
  • journal:核心集合写关注用 {w:"majority", j:true, wtimeout:5000};j:false 只适合可重放的导入类数据。
  • checkpoint:不要手动干预,监控 db.serverStatus().wiredTiger 里的 checkpoint 次数与耗时即可。
  • 压缩:日志/事件类集合用 zstd 换磁盘;高频更新集合先用默认 Snappy,避免 CPU 成为瓶颈。
  • 恢复演练:定期在副本环境模拟 kill -9,确认能从 checkpoint + journal 恢复,而不是只验证正常重启。
  • 监控:wiredTiger.cache 的 used/max、eviction 次数、wiredTiger.log 的写放大;副本集还要看 oplog 窗口。

边界 ​

  • 16MB 是硬限制:不是「建议值」,超了直接写入失败;大对象拆成引用或 GridFS。
  • cache 不是越大越好:WiredTiger 自己管缓存,OS page cache 还要留给数据文件;把 90% 内存给 cache 可能适得其反。
  • journal 不等于永不丢数据:j:true 保证 journal 落盘,但主节点故障后的数据是否保留还取决于写关注是否到多数派。
  • checkpoint 不是备份:它只是崩溃恢复的一致点;误删、逻辑损坏仍要靠备份或延迟从节点。
  • 文档级并发不等于无锁:同一文档的高频更新仍会冲突重试,热点文档要拆或改用原子操作符。

讲清楚 ​

WiredTiger 的核心是三层:内存 cache、journal、checkpoint。写操作先改内存页并追加 journal,普通情况下 journal 每 100ms 批量提交;checkpoint 至少每 60 秒把一致快照写进数据文件。崩溃后从最近 checkpoint 重放 journal,所以恢复点取决于 checkpoint 和 journal 是否完好。

锁粒度是文档级,数据库和集合上只有意向锁;读走 MVCC 快照,不阻塞写。cache 默认是 max(256MB, 50% × (RAM-1GB)),索引页也占 cache,所以「索引多」和「文档大」都会挤压热数据。生产上先把 cache、journal、checkpoint 三件事分开看:cache 决定常态性能,journal + 写关注决定丢不丢数据,checkpoint 决定恢复要多久。

把一门技术讲透到既懂也会用