MongoDB 的慢查询,十有八九是索引没用对。但"加个索引"只是入门,真正的进阶在于理解复合索引的 ESR 法则、覆盖查询、索引交集、以及查询计划器的工作方式。本文从执行计划讲起,把索引优化讲到能直接落地。
场景:加了索引还是慢
一条查询 find({status: "active", age: {$gt: 30}}).sort({createdAt: -1}),你给 status、age、createdAt 各建了单字段索引,以为万事大吉,结果 explain 显示它还在做内存排序、扫了几十万文档。问题就出在:索引的字段顺序和查询的访问模式不匹配。要理解这点,先看 MongoDB 怎么选索引。
机制一:查询计划器与 explain
MongoDB 用的是基于经验的计划器(plan cache + 竞争执行),不是纯成本模型。一条新查询第一次执行时,计划器会为每个候选索引各跑一小段(race),用"谁先返回足够多结果 / 扫描的文档少"来选出 winning plan,缓存到 plan cache,后续同 shape 的查询直接复用。
读懂 explain("executionStats") 的几个关键字段:
1 | { |
理想状态是 nReturned ≈ totalKeysExamined ≈ totalDocsExamined。三者差距越大问题越大:
totalDocsExamined >> nReturned:扫了大量文档才筛出少量结果,索引选择性差或没命中。totalKeysExamined >> nReturned:索引扫了很多 key 但大多被过滤,复合索引顺序不对。totalDocsExamined == 0且有IXSCAN:覆盖查询,最优。
看到 stage: "COLLSCAN" 就是全表扫描;看到 SORT stage 说明在内存里排序,可能撞 32MB 排序内存上限直接报错。
机制二:复合索引的 ESR 法则
复合索引是 MongoDB 优化的核心。一个 B-Tree 索引按字段顺序排序,顺序错了等于没建对。记住 ESR 法则——字段排列顺序应为:
1 | E - Equality 等值匹配字段放最前 |
回到开头的例子,正确的索引是:
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 | // 索引 |
注意必须 _id: 0,因为 _id 默认返回但不在索引里,带上它就破坏了覆盖。覆盖查询的代价是索引变宽、写入和内存开销变大,典型的空间换时间。
机制四:索引交集与为什么常常不如复合索引
MongoDB 能用 index intersection——同时用两个单字段索引,各扫一遍取交集。但计划器通常更偏好单个复合索引,因为交集要扫两棵树再做合并,成本往往更高。所以"建一堆单字段索引让数据库自己组合"这个想法,实践中基本不成立。针对核心查询设计专门的复合索引才是正解。
工程权衡与边界
写放大:每个索引都要在写入时同步维护。一个集合 10 个索引,一次插入就是 11 次 B-Tree 更新。索引不是越多越好,无用索引应该删。
1 | # 找出从未被使用的索引(accesses.ops 为 0) |
选择性(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 法则设计复合索引让等值定位、排序免费、范围收尾;能覆盖就覆盖。索引设计是面向查询模式的工程,不是堆字段,删掉无用索引和减少写放大同样重要。