一、什么是索引跑偏
业务中常见这样的现象:同一类查询(字段一致、查询形态一致,仅参数值不同),昨天还能在数毫秒内返回,今天却变成数百毫秒甚至秒级。通过
explain 查看发现,原本使用的高效索引被替换为另一个低效索引。这种执行计划在不同索引之间反复切换、查询延迟波动较大的现象,称为索引跑偏(Plan Drift)。跑偏的关键特征:同一类查询,业务代码未发生变更,但查询性能突然劣化
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:集群级生效、重启后保留、适用于副本集与分片集群 |
实施原则 | 仅对关键业务查询实施固化,非关键查询保留自适应能力 |
参考文档
如需进一步了解本文未展开的查询计划、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 状态。