2. MongoDB存储引擎与索引底层结构
要真正理解 MongoDB 的索引为什么快、什么时候会慢,必须把视角下沉到存储引擎层。MongoDB 从 3.0 起默认使用 WiredTiger 作为存储引擎,索引的物理组织、页的读写、缓存的管理都由它负责。本节围绕四个核心问题展开:WiredTiger 如何组织数据、B 树索引的物理结构长什么样、页与缓存如何协同工作、索引文件在磁盘上如何组织。
WiredTiger 存储引擎的定位
WiredTiger 是一个支持行级并发控制的存储引擎,它向上为 MongoDB 提供集合与索引的读写接口,向下管理磁盘文件、内存缓存和事务日志。理解它需要抓住三个关键机制:
- 文档级并发控制:写操作通过乐观并发控制(Optimistic Concurrency Control)在文档粒度上加锁,不同文档的写入可以并行,这是 MongoDB 高并发写入的基础。
- 检查点(Checkpoint):WiredTiger 默认每 60 秒或日志达到 2GB 时创建一次检查点,把内存中的一致性快照刷到磁盘,形成可恢复的持久化点。
- 预写日志(Journal / WAL):写操作先写入 journal,再在检查点时落盘,保证崩溃后能重放到最近一次检查点之后的状态。
对索引而言,WiredTiger 最重要的贡献是:索引和数据都存放在 B 树结构中,且索引页与数据页共享同一套缓存与淘汰机制。这意味着索引不是游离于数据之外的“加速表”,而是与数据同等对待的持久化结构。
B 树索引的物理结构
MongoDB 的索引是一棵 B 树(更准确地说,是 B+ 树的变体)。它的节点分为三类:
- 根节点(Root):整棵树的入口,常驻内存。
- 内部节点(Internal):存放键值与子页指针,负责路由。
- 叶子节点(Leaf):存放索引键值与对应的 RecordId(指向数据记录的物理位置),叶子节点之间通过双向链表相连,支持范围扫描。
以在 users.age 上建立普通索引为例,假设 age 取值 18~60,页大小为 4KB,每个索引项约 20 字节,则一个叶子页可容纳约 200 个索引项。若集合有 100 万文档,叶子页约 5000 个,内部节点按每页 200 个指针计算,只需约 25 个内部页,根节点 1 个。整棵树高度约为 3 层,意味着一次等值查询最多 3 次页读取,这就是 B 树索引高效的根本原因。
页与缓存机制
WiredTiger 以页(Page)为最小读写单位,默认页大小为 4KB(内部节点页通常更小)。页在内存中的集合称为 WiredTiger Cache,默认大小为 max(256MB, (RAM - 1GB) × 0.5)。例如一台 16GB 内存的机器,缓存约为 7.5GB。
页的生命周期遵循“按需加载、LRU 淘汰”的原则:
- 查询命中某页时,若页不在缓存中,从磁盘读入并放入缓存。
- 写操作修改页时,先在缓存中修改,标记为脏页。
- 检查点触发时,脏页被刷写到磁盘。
- 缓存不足时,按 LRU 策略淘汰干净页;脏页需先刷盘再淘汰。
这里有一个容易被忽视的点:索引页和数据页竞争同一块缓存。如果索引过多、索引键过大,索引页会挤占数据页的空间,导致数据页频繁换入换出,反而拖慢查询。因此“索引越多越好”是错误认知,索引数量需要与缓存容量匹配。
索引文件组织方式
在磁盘上,每个集合对应一个 .wt 数据文件,每个索引对应一个独立的 .wt 索引文件。索引文件内部同样以 B 树页为单位组织,页与页之间通过文件偏移量定位。WiredTiger 使用 块管理器(Block Manager) 管理这些页的分配、回收和校验。
索引文件与数据文件之间通过 RecordId 关联:叶子节点存储的是“索引键 → RecordId”的映射,RecordId 指向数据文件中该文档的位置。因此一次索引查询实际上包含两步:先在索引 B 树中定位 RecordId,再根据 RecordId 到数据 B 树中取出完整文档。如果查询只需要索引字段,MongoDB 可以直接从索引返回结果,这就是覆盖查询(Covered Query),省去了第二步的数据页读取。
下面用一个可执行的示例观察索引文件与查询路径。先创建集合并建立索引:
// 插入 10 万条示例文档
for (let i = 0; i < 100000; i++) {
db.users.insertOne({ name: "user_" + i, age: i % 60 + 18, city: "city_" + (i % 10) });
}
// 在 age 上建立普通索引
db.users.createIndex({ age: 1 });
// 查看索引大小与统计信息
db.users.stats().indexSizes;
// 示例输出(合理假设):
// { _id_: 1228800, age_1: 897024 }
接着用 explain("executionStats") 观察查询路径:
db.users.find({ age: 30 }).explain("executionStats");
// 关注字段:
// winningPlan.stage → FETCH(需回数据页)或 IXSCAN
// executionStats.totalKeysExamined → 索引扫描键数
// executionStats.totalDocsExamined → 回表文档数
// executionStats.executionTimeMillis → 执行耗时
判断依据很直接:如果 totalKeysExamined 远大于 nReturned,说明索引选择性差;如果 totalDocsExamined 接近 nReturned,说明回表次数少,索引效率高。若查询只涉及 age 字段,可以构造覆盖查询:
db.users.find({ age: 30 }, { age: 1, _id: 0 }).explain("executionStats");
// winningPlan.stage 应为 PROJECTION_COVERED 或 IXSCAN,无 FETCH 阶段
_id 被显式排除。这是减少数据页读取、提升查询吞吐最直接的手段之一。
页与缓存对性能的实际影响
理解了页与缓存机制后,很多性能现象就能解释清楚。例如:
- 索引键过长导致树变高:若索引键平均 200 字节,同样 4KB 页只能放约 20 个索引项,树高可能从 3 层升到 5 层,查询路径翻倍。
- 索引过多挤占缓存:每个索引都占用缓存页,当索引总大小接近缓存容量时,数据页被频繁淘汰,回表操作变成磁盘随机读。
- 检查点抖动:大量脏页在检查点集中刷盘,会造成短暂的 I/O 峰值,表现为查询延迟的周期性波动。
因此在实际工程中,索引设计不只是“在哪些字段上建索引”,还包括控制索引数量、精简索引键、优先设计覆盖查询。这些决策的底层依据,正是 WiredTiger 的 B 树页结构与缓存淘汰策略。