前事不忘,后事之师,不忘国耻!

 用户注册  找回密码
 用户注册
搜索
查看: 21|回复: 0

[开发应用] MongoDB 慢查询治理实战:从 COLLSCAN 到覆盖索引

[复制链接]

[开发应用] MongoDB 慢查询治理实战:从 COLLSCAN 到覆盖索引

[复制链接]
dbaai

主题

0

回帖

211

积分

DBAAI

积分
211
昨天 07:51 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

×
MongoDB 慢查询治理实战:从 COLLSCAN 到覆盖索引


一、具体的问题

做 DBA 这些年,MongoDB 的慢查询工单有一个很典型的开头:业务反馈"订单列表打开要三四秒,翻到后面几页直接超时",开发那边则坚称"我条件都写在查询里了,字段也不多"。

接手之后我一般不会先看索引,而是先让数据库自己说话。这台实例是 4.4 版本,order 集合 812 万文档,平均文档 1.2 KB,副本集三节点。接口对应的查询长这样:
  1. db.order.find({
  2.   shop_id: 10086,
  3.   status: "PAID",
  4.   created_at: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") }
  5. }).sort({ created_at: -1 }).limit(20)
复制代码

看着很规矩:一个等值、一个等值、一个范围,再按时间倒序取 20 条。但把 explain 拉出来,画风就变了:
  1. db.order.find({...}).sort({ created_at: -1 }).limit(20)
  2.         .explain("executionStats")
复制代码
  1. executionTimeMillis: 3142
  2. totalKeysExamined:   0
  3. totalDocsExamined:   8120431
  4. nReturned:           20
  5. executionStages:
  6.   stage: SORT_KEY_GENERATOR
  7.   inputStage:
  8.     stage: COLLSCAN
  9.     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(耗时)。一个健康的查询,nReturnedtotalDocsExamined 应该在同一个数量级;如果 totalDocsExaminednReturned 大出几百上千倍,就说明扫描了大量不需要的数据。

第二,复合索引的 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:先把慢查询抓出来,别靠猜
  1. // 开启 profiler,记录超过 100ms 的操作(生产上建议先设 200~500ms,避免写放大)
  2. db.setProfilingLevel(1, { slowms: 100 })
  3. // 等一段时间业务跑过之后,按耗时倒序取前 10 条
  4. db.system.profile.find({ op: "query" })
  5.   .sort({ millis: -1 }).limit(10)
  6.   .pretty()
  7. // 看当前配置
  8. db.getProfilingStatus()
复制代码

注意 system.profile 是固定大小的 capped collection,默认比较小,采样窗口很短。生产上建议先 db.setProfilingLevel(0) 停掉,重建一个大的再开:
  1. db.setProfilingLevel(0)
  2. db.system.profile.drop()
  3. db.createCollection("system.profile", { capped: true, size: 1024 * 1024 * 512 })
  4. db.setProfilingLevel(1, { slowms: 200 })
复制代码

抓完之后记得把 profiler 关掉(db.setProfilingLevel(0)),它本身有 5%~10% 的性能开销,不适合长期开着。

步骤 2:量化基线,把数字记下来
  1. db.order.find({
  2.   shop_id: 10086, status: "PAID",
  3.   created_at: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") }
  4. }).sort({ created_at: -1 }).limit(20).explain("executionStats")
复制代码

executionTimeMillistotalDocsExaminednReturnedstage 四项抄进变更记录,后面每一步都要回头比一次。explain 有三种模式,别用错:queryPlanner(默认,只看计划不执行)、executionStats(真执行并给出统计,最常用)、allPlansExecution(把所有候选计划的赛马过程都打出来,排查"为什么选错索引"时用)。

步骤 3:按 ESR 建复合索引
  1. db.order.createIndex(
  2.   { shop_id: 1, status: 1, created_at: -1 },
  3.   { name: "idx_shop_status_ctime" }
  4. )
复制代码

几个注意点:一是给索引起个名字,后面 hintdropIndex、监控都靠它,自动生成的 shop_id_1_status_1_created_at_-1 太长不好用;二是 4.2 之前大集合建索引要加 { background: true },否则会拿库级排他锁,4.2 起索引构建改为两阶段(开始/结束时短暂拿锁,中间不阻塞读写),不需要再加这个参数;三是副本集上建议用 rolling 方式逐个节点建,先在 SECONDARY 上建、再降级 PRIMARY 切主,避免单节点构建时的内存与 IO 冲击打爆主库。

建完再 explain 一次,这个案例里的变化是:stageCOLLSCAN 变成 IXSCAN + FETCHSORT_KEY_GENERATOR 消失(排序由索引顺序直接满足),totalDocsExamined 从 812 万降到 134,executionTimeMillis 从 3142 毫秒降到 7 毫秒。这里 nReturned 是 20 而 totalDocsExamined 是 134,是因为 limit(20) 生效后扫描提前终止,属于正常现象。

步骤 4:做成覆盖查询,彻底不回表

接口列表页通常只需要几个字段,那就把它们都放进索引,并在查询里显式排除 _id
  1. db.order.createIndex(
  2.   { shop_id: 1, status: 1, created_at: -1, order_no: 1, amount: 1 },
  3.   { name: "idx_cover_list" }
  4. )
  5. db.order.find(
  6.   { shop_id: 10086, status: "PAID",
  7.     created_at: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") } },
  8.   { _id: 0, order_no: 1, created_at: 1, amount: 1 }
  9. ).sort({ created_at: -1 }).limit(20).explain("executionStats")
复制代码

这次计划里会出现 PROJECTION_COVEREDtotalDocsExamined 变成 0。代价是索引变大、写入变慢,所以覆盖索引只给最高频的那两三个接口做,不要每个列表页都来一套。

步骤 5:找并清掉无用索引
  1. // 按使用次数排序,找出从没被用过的索引
  2. db.order.aggregate([ { $indexStats: {} }, { $sort: { "accesses.ops": 1 } } ])
  3. // 4.4+ 先隐藏,观察一两周没问题再删(隐藏后优化器看不到它,但定义还在,可秒级恢复)
  4. db.order.hideIndex("idx_cover_list")
  5. db.order.unhideIndex("idx_cover_list")
  6. // 确认无用后删除
  7. db.order.dropIndex("idx_cover_list")
复制代码

hideIndex 是 4.4 才有的,这一点我很喜欢——以前删索引是单向操作,删错了只能重建,大集合重建一次就是几个小时。4.4 以下版本退而求其次的办法是先 dropIndex,但务必把创建语句记进变更单。

另外,$indexStats 的计数在 mongod 重启后会清零,所以要看的是"从实例启动到现在"的累计值,别刚重启完就去删索引。

步骤 6:三类特殊索引,用对场景能省一半空间
  1. // TTL:自动过期,适合会话、验证码、流水日志
  2. db.session.createIndex({ last_active: 1 }, { expireAfterSeconds: 1800 })
  3. // 部分索引:只给"待处理"这类小子集建索引,体积能压到十分之一
  4. db.order.createIndex(
  5.   { created_at: -1 },
  6.   { partialFilterExpression: { status: { $eq: "INIT" } } }
  7. )
  8. // 稀疏索引:字段可能不存在时不建索引项
  9. db.user.createIndex({ wechat_openid: 1 }, { sparse: true })
复制代码

部分索引有个前提:查询条件必须能推导出是索引集合的子集,否则优化器不会选它。上面的例子里查询必须带 status: "INIT",写成 status: { $ne: "CLOSED" } 就不行。

步骤 7:前后对比

这一步做完的完整效果(同一条列表查询,8 月全月数据):

阶段stagetotalKeysExaminedtotalDocsExamined耗时索引大小
优化前COLLSCAN + SORT_KEY_GENERATOR081204313142 ms
加 ESR 复合索引IXSCAN + FETCH1541347 ms386 MB
改覆盖索引IXSCAN + PROJECTION_COVERED2002 ms612 MB
清理 4 个无用索引后IXSCAN + PROJECTION_COVERED2002 ms415 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" 模式,固定记录 executionTimeMillistotalKeysExaminedtotalDocsExaminednReturnedstage 五项。
  • 判断标准:nReturnedtotalDocsExamined 应在同一数量级;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 可用性上存在差异,生产执行前请先在测试库验证一遍。
免责申明1、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

QQ|Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-9-23 06:40 , Processed in 0.016880 second(s), 9 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表