MongoDB 的慢查询,十有八九是索引没用对。但"加个索引"只是入门,真正的进阶在于理解复合索引的 ESR 法则、覆盖查询、索引交集、以及查询计划器的工作方式。本文从执行计划讲起,把索引优化讲到能直接落地。

场景:加了索引还是慢

一条查询 find({status: "active", age: {$gt: 30}}).sort({createdAt: -1}),你给 statusagecreatedAt 各建了单字段索引,以为万事大吉,结果 explain 显示它还在做内存排序、扫了几十万文档。问题就出在:索引的字段顺序和查询的访问模式不匹配。要理解这点,先看 MongoDB 怎么选索引。

机制一:查询计划器与 explain

MongoDB 用的是基于经验的计划器(plan cache + 竞争执行),不是纯成本模型。一条新查询第一次执行时,计划器会为每个候选索引各跑一小段(race),用"谁先返回足够多结果 / 扫描的文档少"来选出 winning plan,缓存到 plan cache,后续同 shape 的查询直接复用。

读懂 explain("executionStats") 的几个关键字段:

1
2
3
4
5
6
7
8
{
"executionStats": {
"nReturned": 100,
"totalKeysExamined": 100,
"totalDocsExamined": 100,
"executionTimeMillis": 2
}
}

理想状态是 nReturned ≈ totalKeysExamined ≈ totalDocsExamined。三者差距越大问题越大:

  • totalDocsExamined >> nReturned:扫了大量文档才筛出少量结果,索引选择性差或没命中。
  • totalKeysExamined >> nReturned:索引扫了很多 key 但大多被过滤,复合索引顺序不对。
  • totalDocsExamined == 0 且有 IXSCAN:覆盖查询,最优。

看到 stage: "COLLSCAN" 就是全表扫描;看到 SORT stage 说明在内存里排序,可能撞 32MB 排序内存上限直接报错。

机制二:复合索引的 ESR 法则

复合索引是 MongoDB 优化的核心。一个 B-Tree 索引按字段顺序排序,顺序错了等于没建对。记住 ESR 法则——字段排列顺序应为:

1
2
3
E - Equality   等值匹配字段放最前
S - Sort 排序字段放中间
R - Range 范围查询字段放最后

回到开头的例子,正确的索引是:

1
db.users.createIndex({ status: 1, createdAt: -1, age: 1 })
  • status(等值)在最前,直接定位到一个连续区间。
  • createdAt(排序)紧随其后,索引本身有序,排序免费,消除 SORT stage。
  • age(范围)放最后。

为什么范围要放最后?因为范围查询会"打散"索引的有序性。一旦某个字段是范围匹配,它后面的字段在索引里就不再保持你需要的有序了。如果把 age(范围)放在 createdAt(排序)前面,跨越多个 age 值的结果在 createdAt 上不再有序,排序就废了,只能退回内存 sort。

类比:复合索引像字典里"姓+名"的排序。按姓查(等值)能快速翻到一段,这段里名字是有序的(排序);但如果你要"姓张、名字拼音在 a~m 之间"(范围),再想让结果按出生日期排,就乱了。

机制三:覆盖查询(Covered Query)

如果查询需要的所有字段都在索引里(filter + projection),MongoDB 可以只读索引、完全不碰文档,这叫覆盖查询。totalDocsExamined 为 0,性能极佳。

1
2
3
4
5
6
7
// 索引
db.users.createIndex({ status: 1, age: 1, name: 1 })
// 查询:只投影索引里的字段,且排除 _id
db.users.find(
{ status: "active", age: { $gt: 30 } },
{ _id: 0, name: 1 }
)

注意必须 _id: 0,因为 _id 默认返回但不在索引里,带上它就破坏了覆盖。覆盖查询的代价是索引变宽、写入和内存开销变大,典型的空间换时间。

机制四:索引交集与为什么常常不如复合索引

MongoDB 能用 index intersection——同时用两个单字段索引,各扫一遍取交集。但计划器通常更偏好单个复合索引,因为交集要扫两棵树再做合并,成本往往更高。所以"建一堆单字段索引让数据库自己组合"这个想法,实践中基本不成立。针对核心查询设计专门的复合索引才是正解。

工程权衡与边界

写放大:每个索引都要在写入时同步维护。一个集合 10 个索引,一次插入就是 11 次 B-Tree 更新。索引不是越多越好,无用索引应该删。

1
2
3
4
5
# 找出从未被使用的索引(accesses.ops 为 0)
mongosh --eval '
db.users.aggregate([{$indexStats:{}}]).forEach(i =>
print(i.name, JSON.stringify(i.accesses)))
'

选择性(selectivity):在低基数字段(如 gender 只有两个值)上建索引意义不大,扫一半文档跟全表扫差不多。索引应该建在高选择性字段上。

前缀复用:复合索引 {a:1, b:1, c:1} 自动支持 {a}{a,b}{a,b,c} 的查询(最左前缀),但不支持 {b}{b,c}。设计时把这点算进去能减少索引数量。

常见误区与踩坑

  • 误区:排序字段加单独索引就行。不行,排序必须在复合索引里紧跟等值字段,且方向要么完全一致、要么完全相反(MongoDB 能反向扫索引),否则用不上。
  • 踩坑:$ne$nin$regex 非前缀匹配用不上索引(高效部分)。这些操作即使有索引也往往退化成扫描,要避免在热查询里用。
  • 踩坑:$or 的每个分支都得有索引,否则整条查询退回 COLLSCAN。
  • 误区:以为 plan cache 永远最优。数据分布变化后,缓存的旧 plan 可能不再适合。可以 db.collection.getPlanCache().clear() 或重启清缓存重新竞争。
  • 踩坑:内存排序撞 32MB 上限报错(Sort exceeded memory limit)。要么用索引消除 SORT,要么 allowDiskUse: true(聚合管道),但后者慢很多,优先靠索引解决。

小结

MongoDB 查询优化的方法论是固定的:先 explain("executionStats") 看三个 examined 指标,定位是扫文档多还是扫 key 多;再按 ESR 法则设计复合索引让等值定位、排序免费、范围收尾;能覆盖就覆盖。索引设计是面向查询模式的工程,不是堆字段,删掉无用索引和减少写放大同样重要。