存储引擎 WiredTiger
MongoDB 3.2 起的默认引擎(取代 MMAPv1,后者在 4.2 被彻底移除)。面试问「MongoDB 为什么写入快」,答案几乎全在这里。
核心特性
| 特性 | 说明 |
|---|---|
| 文档级并发控制 | 写锁粒度到单个文档,不同文档的写入互不阻塞 |
| MVCC | 读操作看快照,读写不互斥 |
| B+ 树存储 | 和 InnoDB 一样,不是 LSM(但 WiredTiger 也支持 LSM 配置,MongoDB 默认不用) |
| 压缩 | 集合默认 Snappy,索引默认前缀压缩,可选 zlib / zstd |
| 无 doublewrite | 靠 checkpoint + journal 保证一致,不需要双写缓冲 |
为什么写入快
要分几层答,这是和 MySQL 对比的重点:
1. 锁粒度 —— MMAPv1 时代是库级锁(2.2 之前甚至是实例级),WiredTiger 做到文档级。InnoDB 是行级锁,两者在这一层其实相当,但 MongoDB 的乐观并发控制(冲突时重试而非阻塞)在低冲突场景下更轻。
2. 默认不等刷盘 —— 这是最关键的一点。MongoDB 默认 writeConcern: {w: 1, j: false},意思是主节点内存写入成功就返回,不等 journal 落盘。而 InnoDB 默认 innodb_flush_log_at_trx_commit = 1,每次提交都 fsync。
所以「MongoDB 写入比 MySQL 快」这个结论在很大程度上是默认持久化级别不同造成的,不完全是引擎优劣。把 MongoDB 调成 {w: "majority", j: true},写入延迟会显著上升。面试主动指出这一点会很加分。
3. 没有 JOIN 和强约束 —— 不需要检查外键、不需要维护跨表一致性。
4. 嵌入式文档减少了往返 —— 一次写入完成原本需要多张表多次插入的工作。
MVCC 与快照
WiredTiger 为每个事务分配一个快照(snapshot),读操作只看该快照中已提交的数据。实现上和 InnoDB 的思路一致(版本 + 可见性判断),但有两个差异:
- 版本存在内存的 update 链表里,而非 InnoDB 那样的 undo log 文件。所以长事务会撑爆内存(WiredTiger cache),而不只是让版本链变长
- 乐观并发控制 —— 两个事务改同一个文档时,后提交的会收到
WriteConflict错误。MongoDB 会在服务端自动重试(单文档操作)或把错误抛给应用(事务中需要应用层重试)
:::warning TransientTransactionError 必须重试
在多文档事务里遇到写冲突,MongoDB 返回带 TransientTransactionError 标签的错误。这是设计上要求应用重试整个事务,不是 bug。官方驱动的 withTransaction() 辅助方法内置了重试逻辑,手写事务代码则必须自己实现。这是一个很能区分「用过」和「只看过文档」的问题。
:::
Checkpoint 与 Journal
双重机制保证持久性,容易混淆,要能讲清分工:
Checkpoint(检查点)
- 每 60 秒(或 journal 写满 2GB)做一次,把内存中的脏页以一致性快照的形式写入磁盘
- 采用写时复制 —— 新数据写到新位置,checkpoint 完成后才更新根页指针。所以 checkpoint 本身是原子的,不存在「写到一半」的状态
- 崩溃后如果没有 journal,最多回退到上一个 checkpoint,最多丢 60 秒数据
Journal(WAL 日志)
- 作用等同于 InnoDB 的 redo log,记录自上次 checkpoint 以来的所有变更
- 默认每 100ms 刷盘一次(
j: true的写入会立即触发刷盘并等待) - 用 Snappy 压缩,单文件 100MB 滚动
- 崩溃恢复:加载最后一个 checkpoint,然后重放之后的 journal
所以默认配置下的丢数据窗口是 100ms(journal 刷盘间隔),而不是 60 秒。但如果用默认的 j: false,客户端收到成功响应时数据可能还在内存里 —— 这个窗口内主节点崩溃,数据就丢了。
WiredTiger Cache
默认大小 = max((物理内存 - 1GB) × 50%, 256MB)。
这个 50% 常被误解为「MongoDB 只用一半内存」。实际上剩下的内存并没有浪费 —— 它留给操作系统的文件系统缓存(page cache)。两者分工:
- WiredTiger cache 存解压后的数据(B+ 树页)
- OS page cache 存压缩后的数据块
所以调大 WiredTiger cache 不一定更快,可能挤占 page cache 反而变慢。官方建议不要超过 80%。
淘汰(eviction) —— cache 使用率达到 80% 开始后台淘汰,到 95% 时应用线程会被拉去参与淘汰(此时延迟剧增,是一个重要的告警指标)。
压缩
| 对象 | 默认 | 可选 |
|---|---|---|
| 集合数据 | Snappy | zlib(压缩率高、CPU 高)、zstd(4.2+,平衡最好) |
| 索引 | 前缀压缩 | 可关闭 |
| journal | Snappy | — |
Snappy 典型压缩率 2~4 倍。这是 MongoDB 相对 MySQL 的一个实际优势 —— InnoDB 的页压缩(COMPRESSED 行格式)效果和易用性都不如 WiredTiger。
索引前缀压缩:相邻索引键共享前缀只存一次。对 email、url 这类有公共前缀的字段效果显著。
和 InnoDB 的对照
| WiredTiger | InnoDB | |
|---|---|---|
| 数据结构 | B+ 树 | B+ 树 |
| 锁粒度 | 文档级(乐观,冲突重试) | 行级(悲观,阻塞等待) |
| 索引组织 | 非聚簇 —— 索引叶子存 RecordId,数据另存 | 聚簇 —— 主键索引叶子直接存行数据 |
| MVCC 版本位置 | 内存 update 链 | undo log(磁盘) |
| WAL | journal(默认 100ms 刷) | redo log(默认每次提交刷) |
| 部分页写保护 | 不需要(写时复制 + 原子 checkpoint) | 需要 doublewrite buffer |
| 压缩 | 默认开启 Snappy | 默认不压缩 |
:::tip 索引组织方式的差异很值得展开 InnoDB 是聚簇索引:主键查询一次定位就拿到数据,二级索引要回表。
WiredTiger 是非聚簇:所有索引(包括 _id)叶子节点存的都是 RecordId,再用 RecordId 去数据文件取文档。换句话说,MongoDB 连主键查询都相当于「回表」一次。
- 好处:没有「主键大小影响所有二级索引大小」的问题;没有页分裂导致的插入性能悬崖
- 坏处:主键查询比 InnoDB 多一跳
MongoDB 5.3+ 引入了 clustered collection(只能按 _id 聚簇),补上了这个短板,适合时序和日志类集合。
:::