MongoDB 索引优化:复合索引、覆盖查询与慢查询诊断

MongoDB 索引是保障查询性能的核心机制。当集合数据量从万级涨到千万级,没有合理MongoDB 索引的查询会退化为全表扫描(COLLSCAN),响应从毫秒级跌到秒级。本文聚焦 MongoDB 索引的实战优化:复合索引的字段排序、覆盖查询减少回表,以及如何用 explain 与 Profiler 定位慢查询。如果你刚做完MongoDB 文档建模,这篇能帮你把模型真正跑快。

为什么 MongoDB 也需要认真对待索引

MongoDB 是文档数据库,但”文档”不等于”免索引”。默认情况下查询走 COLLSCAN,逐文档比对过滤条件,时间复杂度 O(n)。一旦命中索引(IXSCAN),复杂度降到 O(log n),差距在百万级数据上可达百倍以上。索引本质是一棵额外的 B-tree(或针对地理/全文的特殊结构),以写入时的少量开销,换取查询时的数量级加速。

单字段索引与复合索引

最基础的是单字段索引:createIndex({ status: 1 })。但当查询同时带多个条件(如”按用户查、再按时间排序”),复合索引才能一次吃下。复合索引的字段顺序遵循 ESR 规则:Equality(等值)→ Sort(排序)→ Range(范围),把等值过滤放最前、排序字段居中、范围过滤放最后,索引利用率最高。

复合索引字段顺序(ESR 实战)

// 查询:userId 等值 + 按 createdAt 倒序 + score 范围
db.orders.find({ userId: "u1001", score: { $gt: 60 } }).sort({ createdAt: -1 })

// ✅ 正确顺序:等值(userId) → 排序(createdAt) → 范围(score)
db.orders.createIndex({ userId: 1, createdAt: -1, score: 1 })

// ❌ 错误顺序:把范围放中间会打断后续字段的有序性
db.orders.createIndex({ userId: 1, score: 1, createdAt: -1 })

前缀匹配原则

复合索引 {a:1, b:1, c:1} 能加速含 aa+ba+b+c 的查询,但无法单独加速只查 bc 的查询——索引的前缀才有效。所以设计复合索引时,要把最常被等值过滤、且区分度高的字段放最左。这与关系型数据库的”最左前缀”逻辑一致,可对照MySQL 索引底层原理理解。

覆盖查询:让索引顺带返回字段,避免回表

普通查询拿到索引条目后,还要回磁盘(或 WiredTiger 的文档存储)取完整文档,这一步叫”回表”。如果查询只需少数几个字段,且这些字段都在索引里,MongoDB 就能覆盖查询(Covered Query),只走 IXSCAN 不回表,性能极佳。做法是把要返回的字段也加进索引,并用投影只取它们:

// 把 title、price 纳入索引,使列表查询被完全覆盖
db.products.createIndex({ category: 1, price: 1, title: 1 })

// 只投影索引内字段 → executionStats 中 totalDocsExamined=0
db.products.find(
  { category: "book" },
  { _id: 0, title: 1, price: 1 }
)

判断是否覆盖,看 explain 输出的 totalDocsExamined 是否为 0、stage 是否为 PROJECTION_COVERED。注意 _id 默认总被返回,覆盖查询必须显式投影 { _id: 0 } 排除它。

慢查询诊断:explain 与数据库 Profiler

优化前先量化。MongoDB 提供两条核心诊断路径:explain() 看单条语句的执行计划,db.setProfilingLevel 记录全库慢操作。

explain(“executionStats”) 关键指标

db.orders.find({ userId: "u1001" }).sort({ createdAt: -1 }).explain("executionStats")

// 关注三行:
// winningPlan.stage                  → 期望 IXSCAN,而非 COLLSCAN
// executionStats.totalKeysExamined   → 扫描的索引条目数
// executionStats.totalDocsExamined   → 扫描的文档数(覆盖查询=0)
// executionTimeMillis                → 实际耗时,>100ms 需警惕

数据库 Profiler 抓全库慢查询

// 记录执行超过 100ms 的所有操作(level=1);level=2 记录全部
db.setProfilingLevel(1, { slowms: 100 })

// 从 system.profile 集合捞慢查询,按耗时排序
db.system.profile.find({
  millis: { $gt: 100 }
}).sort({ millis: -1 }).limit(20)

// 重点看:op(操作类型)、ns(集合)、planSummary(COLLSCAN?)、command
// 定位到的 COLLSCAN 语句,就是该建索引的信号

索引陷阱与避坑清单

索引不是越多越好——每个索引都会拖慢写入并占用内存。下面是常见坑:

陷阱症状对策
低区分度字段做前导索引命中但仍扫大量键前导位放高区分度等值字段
范围查询打断了排序字段出现内存 SORT 阶段按 ESR 排:等值→排序→范围
只查字段却未进索引totalDocsExamined 偏高建覆盖索引并显式排除 _id
索引过多写入变慢、内存吃紧用 $indexStats 清理零命中索引
正则 /^abc/ 无锚定无法用索引前缀正则可用索引,中缀不行

生产落地建议

  • 先用 Profiler 找 COLLSCAN:别凭直觉建索引,让慢查询日志告诉你缺哪条。这与 PostgreSQL 慢查询优化的思路完全一致。
  • 复合索引优先于多个单字段:一个 {a,b,c} 往往顶替 {a}、{a,b} 两条,省空间又提速。
  • 定期清理僵尸索引db.collection.aggregate([{ $indexStats: {} }]) 看访问次数,长期为 0 的删掉。
  • 大集合建索引用 background / 滚动:线上大表前台建索引会锁读写,用 { background: true } 或 7.0 的滚动构建策略。
  • 读写分离场景注意索引同步:副本集上从节点也需同样索引,参考MySQL 主从复制与读写分离的同步理念。

总结

MongoDB 索引优化的闭环很简单:用 Profiler / explain 找到 COLLSCAN 与慢语句 → 按 ESR 规则建复合索引 → 用覆盖查询减少回表 → 定期用 $indexStats 清理无用索引。索引是”以空间换时间”的权衡,建在刀刃上才值。文档建模决定了数据结构,而索引决定了它跑得快不快——两者配合,你的 MongoDB 才能在千万级数据下依然稳稳毫秒响应。

上一篇 Nginx 限流防刷实战:漏桶算法与 WAF 基础配置
下一篇 Java Stream API 实战:集合处理与性能要点