症状
executionStats 用数字讲故事:nReturned 50、docsExamined 421 万、totalKeysExamined 0——选择性差距就是全部成本,零键检查证明没有索引参与。COLLSCAN 意味着每次检查都要反序列化文档,成本随集合大小线性增长,与谓词选择性和 limit 无关。修好访问路径后还要会读形状:IXSCAN 下挂着 SCAN_AND_SORT 说明排序在内存里跑(阻塞式、32MB 上限),因为索引没承载排序顺序。
诊断思路
先读 winning plan 的阶段树与 executionStats 的 docsExamined/nReturned 差距;再对照 getIndexes() 列出的字段顺序与查询形状——等值、排序、范围的 ESR 顺序是否吻合;最后按正确顺序重建索引并用 explain 验证 docsExamined 坍缩到接近 nReturned。前缀不匹配(前导字段与等值谓词不符)、索引服务不了的运算符(无前缀的不区分大小写正则、$where)、复合索引字段顺序错误,是三大入口。
根因
没有前导字段服务于查询谓词的索引时,MongoDB 必须检查集合里的每个文档来应用过滤。每次检查都反序列化文档,成本随集合大小线性增长。
修复与验证
按 ESR 顺序为该查询形状建复合索引:等值(customerId、status)→ 排序(createdAt)→ 范围;投影只取索引内字段实现 PROJECTION_COVERED、彻底消除 FETCH;为 profiler 实际观察到的每个热形状建索引,删除与他人前缀重复的索引。验证时重跑 explain:IXSCAN 无阻塞排序、docsExamined 在 nReturned 的小倍数内、profiler 不再标记该查询。每个索引都在向写入与存储收税——为观测到的形状建,不为想象中的建。
代码示例
> db.orders.find({customerId: 42, status: "paid"})
.sort({createdAt: -1}).limit(50).explain("executionStats")
winningPlan: { stage: "COLLSCAN", filter: {...}, direction: "forward" }
executionStats: { nReturned: 50, docsExamined: 4210773, totalKeysExamined: 0 }
// Equality (customerId, status) -> Sort (createdAt) -> Range (none)
db.orders.createIndex({ customerId: 1, status: 1, createdAt: -1 })
db.orders.find(
{ customerId: 42, status: "paid" },
{ _id: 0, createdAt: 1, total: 1 } // fields present in the index
).sort({ createdAt: -1 })
// plan: PROJECTION_COVERED over IXSCAN — zero document fetches