一、一、什么是索引跑偏
·同一类查询,业务代码未发生变更,但查询性能突然劣化
·explain 显示当前使用的索引与历史不同
·常见于实例重启、主备切换、新建或删除索引等事件之后的一段时间内
·部分查询可能在 MongoDB 内部 replan 机制触发的重新评估后自动恢复,但后续仍可能再次跑偏
二、二、为什么索引会跑偏
为了便于理解 MongoDB 的索引选择机制,可借助以下类比:
MongoDB 首次执行某类查询时,会让所有候选执行计划(通常对应不同索引)进入一段试运行。试运行在候选计划返回足够结果(默认101条)或到达数据末尾后结束,MongoDB 根据试运行期间的返回结果数和执行开销计算得分,得分最高的计划会写入 Plan Cache。后续同形态查询优先复用这份缓存。跑偏问题源自这套"首次评估 + 后续复用"机制。下列四类场景都可能导致缓存的索引并非长期最优。
三、2.1 首次执行的参数值不具备代表性
这是生产中较常见的跑偏成因。例如订单表 orders 上有两个索引:
·{ status: 1 }
·{ uid: 1, createTime: -1 }
业务查询 find({ status, uid }).sort({ createTime: -1 }) 查询形态一致,参数不同时表现差异显著:
| 参数 | status 在全表占比 | 更适合的索引 |
| status="cancelled" | 0.1% | { status: 1 }(已取消订单占比较低,按 status 索引扫描成本较小) |
| status="paid" | 80% | { uid: 1, createTime: -1 }(按 status 扫描需要遍历大部分数据,按 uid 才能精确定位) |
若首次执行恰好查询 cancelled 数据,MongoDB 会将缓存为该形态查询的执行计划。后续查询 status=paid 数据的请求都使用该索引,查询延迟将明显上升。
四、2.2 数据分布随时间变化
业务运行一段时间后,字段取值分布会发生变化:
·字段取值分布迁移:例如某字段早期取值集中于 pending,随业务发展逐步迁移到以 paid 为主
·集合规模变化:某用户的订单量从十万级增长至亿级
·历史数据归档:归档后集合规模骤减
Plan Cache 记录的是评估当时较优的执行计划。数据分布发生变化后,原有计划可能不再适用。
五、2.3 候选计划得分接近
MongoDB 在选择索引时,按"试跑期生产力(单次操作可推进的结果数)"进行打分。当两个索引的生产力接近时,最终胜出的计划容易受试运行期间返回数据的分布影响——某次评估中 A 计划胜出,下次重启后再评估可能由 B 计划胜出。此类场景在缓存被清空后较容易出现跑偏。
六、2.4 缓存清空后的"重评估窗口"
常见的 Plan Cache 清空或重新评估触发事件包括:
·mongod 进程重启
·集合上新增或删除任意索引(即使该索引与目标查询无关)
·显式调用 db.collection.getPlanCache().clear()
缓存清空后,新到达实例的同形态查询将参与重新评估,并决定后续缓存的执行计划。若参与评估的查询恰好是运维巡检、监控探测或参数不具代表性的查询,后续正常业务查询都可能受到影响。
小结:跑偏的根本原因在于 Plan Cache 按"查询形态"缓存,而非按"参数值"缓存;而同一形态下,不同参数对应的更优索引可能不同。
七、三、解决方案:索引固化
对于已观察到跑偏现象、或对查询计划稳定性有强要求的关键业务查询,可绕开 MongoDB 的自动选择机制,通过人工指定使用的索引。这一做法称为索引固化(Index Pinning)。
MongoDB 8.0提供了四种固化能力,对比如下:
| 方式 | 作用范围 | 集群级生效 | 重启后保留 | 适用场景 |
| 命令上附加 hint | 单条查询 | 否 | 否 | 临时排障 |
| 应用代码中固定 hint | 业务指定的查询 | 否 | 是(随代码) | 关键业务链路 |
| planCacheSetFilter(已废弃) | 同形态全部查询 | 否(每节点单独设置) | 否 | 不推荐 |
| setQuerySettings(8.0起推荐) | 同形态全部查询 | 是 | 是 | 推荐方案 |
注意:说明:
注意:MongoDB 8.0起,planCacheSetFilter(Index Filter)已被标记为废弃。该命令存在两点固有限制——重启后丢失、需在每个节点单独设置,导致集群环境运维成本较高。推荐使用 setQuerySettings 替代,该命令支持集群级生效和重启后保留,适用于副本集与分片集群。
八、3.1 命令上附加 hint
db.orders.find({ uid: 1001, status: "paid" }) .hint({ uid: 1, createTime: -1 }) .sort({ createTime: -1 })
仅对当前查询生效,适用于灰度试验或线上问题的临时处理。
九、3.2 应用代码中固定 hint
# Python 驱动示例cursor = collection.find( {"uid": uid, "status": status}, hint=[("uid", 1), ("createTime", -1)],).sort([("createTime", -1)])
将索引选择写入业务代码,可随代码版本管理、支持代码评审、便于回滚。适用于上线流程严格的核心业务查询。
十、3.3 setQuerySettings(8.0起推荐)
执行一次 setQuerySettings 后,设置会应用于整个集群上的匹配查询结构(适用于副本集和分片集群),且重启后保留:
db.adminCommand({ setQuerySettings: { find: "orders", filter: { uid: 1, status: "paid" }, sort: { createTime: -1 }, $db: "shop" }, settings: { indexHints: [{ ns: { db: "shop", coll: "orders" }, allowedIndexes: [ { uid: 1, createTime: -1 } ] }] }})
查看已设置的规则:
db.aggregate([{ $querySettings: {} }])
移除规则:
db.adminCommand({ removeQuerySettings: { find: "orders", filter: { uid: 1, status: "paid" }, sort: { createTime: -1 }, $db: "shop" }})
十一、3.4 索引跑偏选型建议
| 场景 | 推荐方案 |
| 8.0及以上版本,需集群级、长期持久化的固化 | setQuerySettings(推荐方案) |
| 关键交易链路、需随代码版本管理 | 应用代码中固定 hint |
| 临时排障、灰度试验 | 命令上附加 hint |
| 非关键查询,可接受偶发跑偏 | 不固化,保留自适应能力 |
不建议对集合内的全部查询实施固化。MongoDB 的自适应能力可对数据分布变化做出响应,过度固化会失去这一能力。建议仅对一旦跑偏将引发线上事故的查询实施固化。
十三、步骤1:发现跑偏
定位跑偏最直接的指标是 replan 计数器。每次跑偏后 MongoDB 会触发一次重新评估,该计数器累加:
// Classic 引擎 replan 次数db.serverStatus().metrics.query.planCache.classic.replanned// SBE 引擎 replan 次数db.serverStatus().metrics.query.planCache.sbe.replanned
数值持续增长表明实例内可能存在反复跑偏的查询。慢日志中输出中若出现 replanReason 字段,说明该查询历史上发生过 replan:
"replanReason": "cached plan was less efficient than expected: …"
十四、步骤2:确认目标索引
通过 explain("allPlansExecution") 查看所有候选计划的执行情况,比较 executionTimeMillis、totalKeysExamined、totalDocsExamined,结合具有代表性的业务参数值,确认在主要业务场景下表现稳定的索引。该索引即为固化目标。
十五、步骤3:设置固化规则
以 MongoDB 8.0 推荐方式 setQuerySettings 为例:
db.adminCommand({ setQuerySettings: { find: "orders", filter: { uid: 1, status: "paid" }, sort: { createTime: -1 }, $db: "shop" }, settings: { indexHints: [{ ns: { db: "shop", coll: "orders" }, allowedIndexes: [ { uid: 1, createTime: -1 } ] }] }})
十六、步骤4:验证
db.orders.find({ uid: 1001, status: "paid" }) .sort({ createTime: -1 }) .explain("queryPlanner")
确认输出中:
·queryPlanner.querySettings:显示已应用的设置内容
·queryPlanner.winningPlan:使用的索引为目标索引
十七、步骤5:持续观察
固化后建议观察7天以上:
·metrics.query.planCache.classic.replanned 与 metrics.query.planCache.sbe.replanned:整体增长趋势应下降,并结合慢查询日志确认该形态查询不再频繁触发 replan
·该形态查询的 P99 延迟:稳定在固化前的基线水平或更优
十八、步骤6:必要时解除固化
当业务模式或数据分布发生根本性变化时,原固化索引可能不再适用,按下列流程解除:
// 1. 采集当前所有候选计划的表现,确认新数据下更适合的索引db.orders.find({ uid: 1001, status: "paid" }) .sort({ createTime: -1 }) .explain("allPlansExecution")// 2. 移除查询设置db.adminCommand({ removeQuerySettings: { find: "orders", filter: { uid: 1, status: "paid" }, sort: { createTime: -1 }, $db: "shop" }})// 3. 清理对应的 Plan Cache,使 MongoDB 基于新数据分布重新评估并选择执行计划db.orders.getPlanCache().clear()
十九、五、关键要点回顾
| 关注点 | 结论 |
| 跑偏的本质 | Plan Cache 按"查询形态"缓存,但同一形态下不同参数对应的更优索引可能不同 |
| 高发时段 | 实例重启、新增或删除索引等事件之后的一段时间内 |
| 发现手段 | 通过 metrics.query.planCache.classic.replanned、metrics.query.planCache.sbe.replanned 以及日志中的 replanReason 进行定位 |
| MongoDB 8.0推荐方案 | setQuerySettings:集群级生效、重启后保留、适用于副本集与分片集群 |
| 实施原则 | 仅对关键业务查询实施固化,非关键查询保留自适应能力 |
20、参考文档
如需进一步了解这篇未展开的查询计划、Plan Cache、查询形态和执行引擎细节,可参考以下 MongoDB 产品文档:
·MongoDB 8.0 查询计划:介绍查询规划器、候选计划评估、Plan Cache 状态、缓存清理和索引筛选器等机制。
·MongoDB 8.0 查询结构:介绍 Query Shape、Plan Cache Query Shape、planCacheShapeHash 等概念,可用于理解为什么同一形态查询会复用执行计划。
·MongoDB 8.0 explain 输出说明:介绍 queryPlanner、winningPlan、executionStats、totalKeysExamined、totalDocsExamined 等字段,可用于排查慢查询和索引使用情况。
·MongoDB 8.0 SBE 查询执行引擎:介绍经典查询引擎与 SBE(Slot-Based Execution)查询执行引擎,以及如何通过 explain 和慢查询日志识别查询使用的执行引擎。
·MongoDB 8.0 setQuerySettings 命令文档:介绍集群级查询设置、indexHints、queryFramework 和 reject 等配置项。
·MongoDB 8.0 planCacheSetFilter 命令文档(已废弃):介绍 Index Filter 的使用方式和 MongoDB 8.0 起废弃的说明。
·MongoDB 8.0 $planCacheStats 聚合阶段文档:介绍如何查看集合上的 Plan Cache 状态。