云数据库 MongoDB MongoDB 索引跑偏与索引固化实践

业务中常见这样的现象:同一类查询(字段一致、查询形态一致,仅参数值不同),昨天还能在数毫秒内返回,今天却变成数百毫秒甚至秒级。通过 explain 查看发现,原本使用的高效索引被替换为另一个低效索引。这种执行计划在不同索引之间反复切换、查询延迟波动较大的现象,称为索引跑偏(Plan Drift)。跑偏的关键特征。

相关产品
云数据库 MongoDB 产品页,含版本规格、价格与购买入口。

一、一、什么是索引跑偏

·同一类查询,业务代码未发生变更,但查询性能突然劣化

·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 状态。

赞 (0)
上一篇 4天前
下一篇 4天前