文档中心>云数据库 MongoDB>实践教程>MongoDB 索引跑偏与索引固化实践

MongoDB 索引跑偏与索引固化实践

最近更新时间:2026-07-03 17:54:58

我的收藏

一、什么是索引跑偏

业务中常见这样的现象:同一类查询(字段一致、查询形态一致,仅参数值不同),昨天还能在数毫秒内返回,今天却变成数百毫秒甚至秒级。通过 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") 查看所有候选计划的执行情况,比较 executionTimeMillistotalKeysExaminedtotalDocsExamined,结合具有代表性的业务参数值,确认在主要业务场景下表现稳定的索引。该索引即为固化目标。

步骤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.replannedmetrics.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.replannedmetrics.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 输出说明:介绍 queryPlannerwinningPlanexecutionStatstotalKeysExaminedtotalDocsExamined 等字段,可用于排查慢查询和索引使用情况。
MongoDB 8.0 SBE 查询执行引擎:介绍经典查询引擎与 SBE(Slot-Based Execution)查询执行引擎,以及如何通过 explain 和慢查询日志识别查询使用的执行引擎。
MongoDB 8.0 setQuerySettings 命令文档:介绍集群级查询设置、indexHintsqueryFrameworkreject 等配置项。
MongoDB 8.0 planCacheSetFilter 命令文档(已废弃):介绍 Index Filter 的使用方式和 MongoDB 8.0 起废弃的说明。
MongoDB 8.0 $planCacheStats 聚合阶段文档:介绍如何查看集合上的 Plan Cache 状态。