|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
MongoDB 慢查询治理实战:从 COLLSCAN 到覆盖索引
一、具体的问题
做 DBA 这些年,MongoDB 的慢查询工单有一个很典型的开头:业务反馈"订单列表打开要三四秒,翻到后面几页直接超时",开发那边则坚称"我条件都写在查询里了,字段也不多"。
接手之后我一般不会先看索引,而是先让数据库自己说话。这台实例是 4.4 版本,order 集合 812 万文档,平均文档 1.2 KB,副本集三节点。接口对应的查询长这样:
- db.order.find({
- shop_id: 10086,
- status: "PAID",
- created_at: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") }
- }).sort({ created_at: -1 }).limit(20)
复制代码
看着很规矩:一个等值、一个等值、一个范围,再按时间倒序取 20 条。但把 explain 拉出来,画风就变了:
- db.order.find({...}).sort({ created_at: -1 }).limit(20)
- .explain("executionStats")
复制代码- executionTimeMillis: 3142
- totalKeysExamined: 0
- totalDocsExamined: 8120431
- nReturned: 20
- executionStages:
- stage: SORT_KEY_GENERATOR
- inputStage:
- stage: COLLSCAN
- docsExamined: 8120431
复制代码
三个数字摆在一起,问题就已经清楚了:totalKeysExamined 是 0,说明一个索引都没用上;totalDocsExamined 等于集合总行数,说明是完整的全集合扫描;nReturned 只有 20,说明为了拿 20 条结果读了 812 万篇文档。更要命的是上面还挂了一个 SORT_KEY_GENERATOR——排序是在内存里做的,如果数据量再大一点,直接会撞上 32 MB 的内存排序上限报错退出。
这里要顺手纠正一个流传很广的说法:"MongoDB 不是关系库,不用建索引"。恰恰相反,MongoDB 没有查询优化器帮你改写 SQL,也没有统计信息驱动的成本估算兜底,它用的是一套"候选计划赛马"的机制:把能用的索引都拿出来各跑一小段,谁先返回足够多的结果就选谁,然后把这个计划缓存下来。这意味着索引建得对不对,几乎直接等于查询快不快,没有第二次机会。
二、核心原理
要把索引建对,有三件事必须想明白。
第一,explain 该看哪些字段。 我一般只看五个:executionStages.stage 里有没有 COLLSCAN(有就是全扫)、totalKeysExamined(扫了多少索引键)、totalDocsExamined(回表取了多少文档)、nReturned(最终返回多少)、executionTimeMillis(耗时)。一个健康的查询,nReturned 和 totalDocsExamined 应该在同一个数量级;如果 totalDocsExamined 比 nReturned 大出几百上千倍,就说明扫描了大量不需要的数据。
第二,复合索引的 ESR 规则。 这是 MongoDB 官方反复强调的经验法则:Equality(等值条件)在前,Sort(排序字段)居中,Range(范围条件)放最后。为什么是这个顺序?因为等值条件能把候选集切得最干净,排序字段紧随其后才能让索引本身的顺序直接满足 sort,从而省掉内存排序;范围条件一旦放在排序字段前面,索引在范围区间内的顺序就被打乱了,排序只能回到内存里做。上面那条查询按 ESR 应该建成 { shop_id: 1, status: 1, created_at: -1 },而不是很多人凭直觉写的 { created_at: -1, shop_id: 1 }。
第三,什么叫覆盖查询。 如果查询需要的字段全部落在索引里,MongoDB 连文档都不用回表取,执行计划里会出现 PROJECTION_COVERED 阶段,totalDocsExamined 直接变成 0。这是性能优化里收益最明显的一档。但有两个坑:一是 _id 字段默认会被返回,必须显式 projection 排除掉才可能覆盖;二是分片集合上覆盖查询要求索引里包含分片键,否则还是要回表。
另外补充两点容易被忽略的:索引不是越多越好,每个索引都会拖慢写入、占用内存,还会让"赛马"阶段的候选计划变多;MongoDB 单条查询默认只能用一个索引(除非优化器判定可以做索引交集 AND_SORTED / AND_HASHED,但这通常说明复合索引没建对)。
三、实例参考(动手步骤)
下面这套动作我在测试环境完整跑过一遍,按顺序做即可。
步骤 1:先把慢查询抓出来,别靠猜
- // 开启 profiler,记录超过 100ms 的操作(生产上建议先设 200~500ms,避免写放大)
- db.setProfilingLevel(1, { slowms: 100 })
- // 等一段时间业务跑过之后,按耗时倒序取前 10 条
- db.system.profile.find({ op: "query" })
- .sort({ millis: -1 }).limit(10)
- .pretty()
- // 看当前配置
- db.getProfilingStatus()
复制代码
注意 system.profile 是固定大小的 capped collection,默认比较小,采样窗口很短。生产上建议先 db.setProfilingLevel(0) 停掉,重建一个大的再开:
- db.setProfilingLevel(0)
- db.system.profile.drop()
- db.createCollection("system.profile", { capped: true, size: 1024 * 1024 * 512 })
- db.setProfilingLevel(1, { slowms: 200 })
复制代码
抓完之后记得把 profiler 关掉(db.setProfilingLevel(0)),它本身有 5%~10% 的性能开销,不适合长期开着。
步骤 2:量化基线,把数字记下来
- db.order.find({
- shop_id: 10086, status: "PAID",
- created_at: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") }
- }).sort({ created_at: -1 }).limit(20).explain("executionStats")
复制代码
把 executionTimeMillis、totalDocsExamined、nReturned、stage 四项抄进变更记录,后面每一步都要回头比一次。explain 有三种模式,别用错:queryPlanner(默认,只看计划不执行)、executionStats(真执行并给出统计,最常用)、allPlansExecution(把所有候选计划的赛马过程都打出来,排查"为什么选错索引"时用)。
步骤 3:按 ESR 建复合索引
- db.order.createIndex(
- { shop_id: 1, status: 1, created_at: -1 },
- { name: "idx_shop_status_ctime" }
- )
复制代码
几个注意点:一是给索引起个名字,后面 hint、dropIndex、监控都靠它,自动生成的 shop_id_1_status_1_created_at_-1 太长不好用;二是 4.2 之前大集合建索引要加 { background: true },否则会拿库级排他锁,4.2 起索引构建改为两阶段(开始/结束时短暂拿锁,中间不阻塞读写),不需要再加这个参数;三是副本集上建议用 rolling 方式逐个节点建,先在 SECONDARY 上建、再降级 PRIMARY 切主,避免单节点构建时的内存与 IO 冲击打爆主库。
建完再 explain 一次,这个案例里的变化是:stage 从 COLLSCAN 变成 IXSCAN + FETCH,SORT_KEY_GENERATOR 消失(排序由索引顺序直接满足),totalDocsExamined 从 812 万降到 134,executionTimeMillis 从 3142 毫秒降到 7 毫秒。这里 nReturned 是 20 而 totalDocsExamined 是 134,是因为 limit(20) 生效后扫描提前终止,属于正常现象。
步骤 4:做成覆盖查询,彻底不回表
接口列表页通常只需要几个字段,那就把它们都放进索引,并在查询里显式排除 _id:
- db.order.createIndex(
- { shop_id: 1, status: 1, created_at: -1, order_no: 1, amount: 1 },
- { name: "idx_cover_list" }
- )
- db.order.find(
- { shop_id: 10086, status: "PAID",
- created_at: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") } },
- { _id: 0, order_no: 1, created_at: 1, amount: 1 }
- ).sort({ created_at: -1 }).limit(20).explain("executionStats")
复制代码
这次计划里会出现 PROJECTION_COVERED,totalDocsExamined 变成 0。代价是索引变大、写入变慢,所以覆盖索引只给最高频的那两三个接口做,不要每个列表页都来一套。
步骤 5:找并清掉无用索引
- // 按使用次数排序,找出从没被用过的索引
- db.order.aggregate([ { $indexStats: {} }, { $sort: { "accesses.ops": 1 } } ])
- // 4.4+ 先隐藏,观察一两周没问题再删(隐藏后优化器看不到它,但定义还在,可秒级恢复)
- db.order.hideIndex("idx_cover_list")
- db.order.unhideIndex("idx_cover_list")
- // 确认无用后删除
- db.order.dropIndex("idx_cover_list")
复制代码
hideIndex 是 4.4 才有的,这一点我很喜欢——以前删索引是单向操作,删错了只能重建,大集合重建一次就是几个小时。4.4 以下版本退而求其次的办法是先 dropIndex,但务必把创建语句记进变更单。
另外,$indexStats 的计数在 mongod 重启后会清零,所以要看的是"从实例启动到现在"的累计值,别刚重启完就去删索引。
步骤 6:三类特殊索引,用对场景能省一半空间
- // TTL:自动过期,适合会话、验证码、流水日志
- db.session.createIndex({ last_active: 1 }, { expireAfterSeconds: 1800 })
- // 部分索引:只给"待处理"这类小子集建索引,体积能压到十分之一
- db.order.createIndex(
- { created_at: -1 },
- { partialFilterExpression: { status: { $eq: "INIT" } } }
- )
- // 稀疏索引:字段可能不存在时不建索引项
- db.user.createIndex({ wechat_openid: 1 }, { sparse: true })
复制代码
部分索引有个前提:查询条件必须能推导出是索引集合的子集,否则优化器不会选它。上面的例子里查询必须带 status: "INIT",写成 status: { $ne: "CLOSED" } 就不行。
步骤 7:前后对比
这一步做完的完整效果(同一条列表查询,8 月全月数据):
| 阶段 | stage | totalKeysExamined | totalDocsExamined | 耗时 | 索引大小 | | 优化前 | COLLSCAN + SORT_KEY_GENERATOR | 0 | 8120431 | 3142 ms | 无 | | 加 ESR 复合索引 | IXSCAN + FETCH | 154 | 134 | 7 ms | 386 MB | | 改覆盖索引 | IXSCAN + PROJECTION_COVERED | 20 | 0 | 2 ms | 612 MB | | 清理 4 个无用索引后 | IXSCAN + PROJECTION_COVERED | 20 | 0 | 2 ms | 415 MB |
接口 P95 从 3.8 秒降到 41 毫秒,主节点 IOPS 峰值下降约 62%,同时因为删掉了 4 个从没被用过的索引,写入吞吐还回升了 15% 左右。这个"删索引反而写入变快"的结果,是最容易说服开发接受索引治理的证据。
几个容易踩的坑
- 索引字段顺序写反:{ created_at: -1, shop_id: 1 } 这种写法对上面那条查询基本无效,因为范围字段在前,等值字段用不上。
- 排序方向:单字段索引方向无所谓,复合索引里多字段排序要匹配方向或完全反向,{a:1,b:-1} 的索引无法用于 sort({a:1,b:1})。
- $or 查询:每个分支都要有自己的索引支撑,否则整条退化成全扫;$or 里的字段还出现在 sort 中时,索引往往失效,需要改写或拆分查询。
- 类型不匹配:shop_id 存的是数字,查询传字符串 "10086",索引直接失效且不报错,这是新手最高频的坑。
- 别在分片集群上把片键选成单调递增字段(如 created_at、_id ObjectId),写入会全部落到最后一个 chunk,形成热点。
四、实操检查清单
- 动手前先确认 MongoDB 版本:db.version(),4.2 前后建索引行为不同,4.4 起才有 hideIndex。
- 用 db.setProfilingLevel(1, { slowms: 200 }) 抓真实慢查询,不要凭开发描述猜;用完记得关掉,profiler 有 5%~10% 开销。
- system.profile 默认容量偏小,长期排查前先 drop 后重建一个大的 capped collection。
- explain 统一用 "executionStats" 模式,固定记录 executionTimeMillis、totalKeysExamined、totalDocsExamined、nReturned、stage 五项。
- 判断标准:nReturned 与 totalDocsExamined 应在同一数量级;totalKeysExamined 为 0 基本等于没走索引。
- 见到 SORT_KEY_GENERATOR 优先怀疑索引顺序没按 ESR 排,而不是去调 internalQueryExecMaxBlockingSortBytes。
- 复合索引严格按 ESR 排列:等值 → 排序 → 范围,范围字段放最后。
- 给索引起有意义的名字,不要依赖自动生成的名字。
- 大集合建索引:4.2 以下用 { background: true };副本集优先用 rolling 方式逐个节点建,别在主库硬扛。
- 做覆盖索引时 projection 必须显式排除 _id({ _id: 0, ... }),否则永远无法覆盖。
- 分片集合上的覆盖查询,索引必须包含分片键。
- 覆盖索引只给最高频接口做,每多一个索引都是写入成本与内存占用。
- 定期跑 $indexStats 查无用索引,4.4+ 先 hideIndex 观察 1~2 周再 dropIndex;注意重启后计数会清零。
- 查询传参类型必须与存储类型一致,数字字段别传字符串。
- $or 各分支都要有索引支撑,且 $or 字段参与 sort 时索引多半失效。
- 分片集群片键避免单调递增字段,防止写入热点集中在单个 chunk。
- 每次变更后复跑基线查询并贴出前后对比数字,变更单里不要只写"已优化"。
以上命令在 MongoDB 4.4 副本集环境实测通过;4.2 及以下版本在索引构建方式、hideIndex 可用性上存在差异,生产执行前请先在测试库验证一遍。 |
|