跳到正文

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.stageCOLLSCAN / 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。

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