主题
MongoDB 索引
一句话:MongoDB 索引用 B 树把「字段值 → 文档位置」排序,换点查和范围查的亚毫秒定位;代价是每次写入都要维护索引,索引热页还会挤占 WiredTiger cache。
适用版本:MongoDB 7.0 / 8.0。索引构建行为在 4.2 之后有变化,旧版本的 background 选项不要照搬。
为什么需要
没有索引时,find({status:"paid"}) 只能做集合扫描(COLLSCAN):文档越多,扫描越慢,还会把大量冷页读进 cache,把热数据挤出去。索引把字段值排好序,查询先定位到少量键,再回表取文档。
但索引不是免费的:每个索引都要占磁盘和 cache,每次插入/更新都要改 B 树。索引设计的目标不是「多建」,而是让主要查询走 IXSCAN,且扫描量接近返回量。
核心机制
B 树,不是 MySQL 的 B+ 树
MongoDB 官方明确索引结构是 B-tree。把它直接套成 B+ 树,会推出错误结论:
| 维度 | B+ 树(常见于 MySQL InnoDB) | MongoDB B-tree |
|---|---|---|
| 数据存放 | 内部节点只存键,数据只在叶子 | 内部节点和叶子节点都可存键值 |
| 叶子链接 | 叶子之间有链表,范围扫描顺序走 | 不按 B+ 树的「叶子链表」模型理解 |
| 点查 | 必须走到叶子 | 可能在内部节点就命中 |
| 页分裂 | 分裂主要影响键分布 | 分裂可能携带数据,写放大更明显 |
结论:MongoDB 的范围查询和覆盖查询仍然可用,但不要用「B+ 树叶子链表」去推断它的页访问次数;具体页布局属于 WiredTiger 实现细节,以官方文档和 explain 实测为准。
索引类型与边界
| 类型 | 写法 | 适用 | 关键边界 |
|---|---|---|---|
| 单字段 | {status:1} | 等值/范围 | 选择性低的字段收益有限 |
| 复合 | {a:1,b:-1} | 多条件查询 | 字段顺序决定能否用索引排序 |
| 多键 | 数组字段自动 | 数组元素包含查询 | 一个复合索引最多一个数组字段;数组字段不能覆盖查询 |
| 文本 | {content:"text"} | 全文检索 | 每集合最多一个文本索引;中文效果远不如 ES |
| 地理 | {loc:"2dsphere"} | 经纬度/GeoJSON | 需要正确的坐标顺序和 CRS |
| 哈希 | {userId:"hashed"} | 分片键均匀分布 | 不支持范围查询,不能唯一约束 |
| TTL | {createdAt:1}, {expireAfterSeconds:3600} | 自动过期 | 单字段;后台线程约每 60 秒清理一次 |
| 唯一 | {email:1}, {unique:true} | 业务唯一 | 冲突时写入失败,不是覆盖 |
| 部分 | {status:1}, {partialFilterExpression:{status:"active"}} | 只索引子集 | 查询必须包含过滤条件才能命中 |
每个集合最多 64 个索引。索引越多,写入越慢,cache 压力越大。
ESR:复合索引的字段顺序
ESR 指 Equal → Sort → Range:
javascript
// 查询:status 等值,createdAt 排序,amount 范围
db.orders.find({ status: "paid", amount: { $gt: 100 } })
.sort({ createdAt: -1 })
// 正确顺序:等值 → 排序 → 范围
db.orders.createIndex({ status: 1, createdAt: -1, amount: 1 })- E(等值)放最前:先把扫描范围缩到最小。
- S(排序)放中间:让索引顺序直接满足
sort,避免内存 SORT。 - R(范围)放最后:范围条件之后的字段无法继续用于排序定位。
$in 接近等值,但值很多时会退化成范围;带 $in 的查询也很难完全依赖索引排序。排序方向要与索引一致({createdAt:-1} 对应 {createdAt:-1})。
覆盖查询
查询所需字段全部在索引里,且不返回 _id(或 _id 也在索引中),就不需要回表:
javascript
db.orders.createIndex({ status: 1, customerId: 1 })
db.orders.find(
{ status: "paid" },
{ _id: 0, status: 1, customerId: 1 }
).explain("executionStats")
// totalDocsExamined: 0 → 覆盖查询注意:多键索引(数组字段)不能覆盖查询,因为索引里存的是数组元素,不是完整数组。
多键索引的边界
- 数组字段上建索引会自动变成多键索引,
explain里显示isMultiKey: true。 - 一个复合索引最多包含一个数组字段;两个数组字段无法建在同一复合索引里。
- 多键索引不能用于「按数组字段排序」,也不能覆盖查询。
- 数组元素很多时,索引条目会膨胀,写入和 cache 成本都会上升;必要时用
$elemMatch或改为引用模型。
explain("executionStats")
javascript
db.orders.find({ status: "paid", amount: { $gt: 100 } })
.sort({ createdAt: -1 })
.explain("executionStats")| 字段 | 看什么 | 判断 |
|---|---|---|
winningPlan.stage | COLLSCAN / IXSCAN / FETCH / SORT | 出现 COLLSCAN 先补索引 |
totalKeysExamined | 扫了多少索引键 | 远大于 nReturned → 选择性差 |
totalDocsExamined | 回表读了多少文档 | 等于 0 → 覆盖查询 |
nReturned | 实际返回数 | 与扫描量越接近越好 |
executionTimeMillis | 耗时 | 结合 SLA 看,不要只看单次 |
典型组合:IXSCAN → FETCH → SORT 说明索引定位了但排序在内存做;IXSCAN 且 totalDocsExamined=0 才是覆盖查询。
最小上手
javascript
// 7.0/8.0
db.orders.createIndex({ status: 1, createdAt: -1, amount: 1 })
db.orders.find({ status: "paid", amount: { $gt: 100 } })
.sort({ createdAt: -1 })
.explain("executionStats")预期:winningPlan 中出现 IXSCAN,totalKeysExamined 接近 nReturned 的量级;若出现 SORT,检查排序字段是否在索引中且方向一致。
生产动作
- 先在从节点建索引:副本集滚动建索引,避免主节点长时间占用;4.2 起索引构建使用优化构建,旧的
background:true不再需要。 - 控制索引数量:用
$indexStats找accesses.ops=0的索引,确认无用后删除;单集合不超过 64 个。 - 看索引大小:
db.collection.stats().indexSizes,索引页同样占 WiredTiger cache。 - TTL 清理:TTL 线程约每 60 秒跑一次,不保证准点删除;不要拿它做精确到期业务。
- 唯一约束:唯一索引冲突会直接报错,应用要处理
E11000,不能假设「后写覆盖」。 - 上线前验证:用真实数据量和真实查询跑
explain("executionStats"),不要只在小数据集上验证。
边界
- 低基数字段单独建索引收益很小:
status只有几个值时,索引扫描可能比 COLLSCAN 还慢。 - 索引不是越多越好:每个索引都增加写放大和 cache 占用,写多读少的集合尤其要克制。
- 文本索引不是搜索引擎:中文分词、相关性、聚合都弱,复杂全文检索交给 Elasticsearch。
- 哈希索引只适合等值和分片:范围查询、排序、唯一约束都不要指望它。
- 数组字段的多键索引有硬边界:一个复合索引只能有一个数组字段,且不能覆盖查询。
讲清楚
MongoDB 索引是 B 树,不是 MySQL 的 B+ 树;官方只承诺 B-tree,不要用「叶子链表」那套 B+ 树模型去推断页访问。设计复合索引按 ESR:等值字段最前,排序字段中间,范围字段最后,顺序反了就会多出内存 SORT。
判断索引好不好,只看
explain("executionStats")的三个数:totalKeysExamined、totalDocsExamined、nReturned。扫描量接近返回量说明选择性好;totalDocsExamined=0说明是覆盖查询。多键索引能加速数组包含查询,但一个复合索引只能有一个数组字段,也不能覆盖查询,更不能按数组排序。生产上索引要滚动建、定期用$indexStats清理无用索引,因为它既拖写入也占 cache。