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} 能加速含 a、a+b、a+b+c 的查询,但无法单独加速只查 b 或 c 的查询——索引的前缀才有效。所以设计复合索引时,要把最常被等值过滤、且区分度高的字段放最左。这与关系型数据库的”最左前缀”逻辑一致,可对照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 才能在千万级数据下依然稳稳毫秒响应。




