39. MongoDB 慢查询剖析与索引顾问

慢查询剖析与索引顾问:分析器与 system.profile、slowms 阈值、explain executionStats 字段精读、$indexStats 清理未使用索引、currentOp 定位慢操作,以及 hint 验证与反模式清单。

“数据库变慢了"是一句没有信息量的话。真正有用的问题是:哪条语句慢、慢在哪个执行阶段、扫了多少键、回了多少文档、有没有走索引。回答这些问题需要三样东西:分析器记录现场、explain 还原计划、$indexStats 判断索引是否被用。本文把慢查询治理拆成一条可复用的流水线。

1. 数据库分析器

分析器(profiler)把超过阈值的操作记录到 system.profile 集合,是慢查询的第一现场。

1.1 分析级别

// 查看当前级别
db.getProfilingStatus()
// { was: 0, slowms: 100, sampleRate: 1, ok: 1 }

// 级别 0:关闭(默认)
db.setProfilingLevel(0)

// 级别 1:只记录超过 slowms 的操作
db.setProfilingLevel(1, { slowms: 100 })

// 级别 2:记录所有操作(仅调试用,开销大)
db.setProfilingLevel(2)
级别记录范围开销适用
0不记录无常态
1超过 slowms低生产常开
2全部操作高临时调试

1.2 slowms 与 sampleRate

slowms 是慢查询阈值,sampleRate 决定采样比例(0 到 1),用于在高负载下降低分析开销。

// 阈值 50ms,采样 50%
db.setProfilingLevel(1, { slowms: 50, sampleRate: 0.5 })

// 只对特定集合提高阈值
db.setProfilingLevel(1, { slowms: 200, sampleRate: 1 })
// 也可以在启动参数中设置默认阈值
// mongod --slowms 100 --profile 1

注意:sampleRate 小于 1 时,慢查询是抽样的,可能漏掉偶发问题。排查疑难问题时把它设为 1,日常运行可用 0.1 到 0.5 降低开销。

1.3 system.profile 的结构

db.system.profile.find().sort({ ts: -1 }).limit(1).pretty()
{
  op: "query",
  ns: "shop.orders",
  command: { find: "orders", filter: { customerId: "C1001" }, $db: "shop" },
  keysExamined: 0, docsExamined: 128400,   // 全表扫描的信号
  nreturned: 3, planSummary: "COLLSCAN",
  ts: ISODate("2026-10-06T07:00:00.000Z"),
  client: "10.0.0.21:52344", appName: "order-service",
  millis: 412
}
字段含义关注点
planSummary计划摘要COLLSCAN 表示全表扫描
docsExamined检查的文档数远大于 nreturned 即低效
keysExamined检查的索引键数为 0 表示未走索引
nreturned返回文档数与检查数对比评估选择性
millis耗时毫秒慢查询的核心指标
appName来源应用定位是哪个服务

2. system.profile 与慢查询定位

2.1 找出最慢的操作

// 耗时最长的十条
db.system.profile.find().sort({ millis: -1 }).limit(10).forEach(p => {
  print(p.millis, p.ns, JSON.stringify(p.command.filter || p.command))
})
// 按命名空间聚合,找出问题集合
db.system.profile.aggregate([
  { $match: { millis: { $gte: 100 } } },
  { $group: { _id: "$ns", count: { $sum: 1 },
              avgMillis: { $avg: "$millis" },
              maxMillis: { $max: "$millis" } } },
  { $sort: { avgMillis: -1 } }
])

2.2 找出全表扫描

// 检查文档数远超返回数的查询
db.system.profile.find({
  planSummary: "COLLSCAN",
  docsExamined: { $gte: 1000 }
}).sort({ docsExamined: -1 }).limit(10)

2.3 分析器集合的大小控制

system.profile 是 capped 集合,默认约 1MB,高负载下会快速滚动。可临时扩大:

// 关闭分析器后重建更大的 profile 集合
db.setProfilingLevel(0)
db.system.profile.drop()
db.createCollection("system.profile", { capped: true, size: 104857600 })  // 100MB
db.setProfilingLevel(1, { slowms: 100 })
操作命令注意
查看大小db.system.profile.stats()capped 有上限
扩大drop 后 createCollection必须先关分析器
清空db.system.profile.drop()需先关分析器

重要:system.profile 是 capped 集合,写满即覆盖最旧记录。排查问题时先把它扩大,否则高频慢查询会在你分析前把证据冲掉。分析完毕及时调回默认大小。

3. explain executionStats 精读

explain 是慢查询的"CT 片”,executionStats 模式给出最详细的执行统计。

3.1 三种 explain 模式

// queryPlanner 只看计划不执行
db.orders.find({ customerId: "C1001" }).explain("queryPlanner")
// executionStats 执行并统计(推荐)
db.orders.find({ customerId: "C1001" }).explain("executionStats")
// allPlansExecution 执行所有候选计划(最详细,开销大)
db.orders.find({ customerId: "C1001" }).explain("allPlansExecution")

3.2 关键字段

db.orders.find({ customerId: "C1001", status: "paid" })
  .explain("executionStats")
{
  queryPlanner: {
    winningPlan: {
      stage: "FETCH",
      inputStage: {
        stage: "IXSCAN",
        indexName: "customerId_1_status_1",
        indexBounds: { customerId: ["[\"C1001\", \"C1001\"]"],
                       status: ["[\"paid\", \"paid\"]"] },
        keysExamined: 3, docsExamined: 3
      }
    },
    rejectedPlans: [ { stage: "COLLSCAN" } ]
  },
  executionStats: {
    nReturned: 3, executionTimeMillis: 2,
    totalKeysExamined: 3, totalDocsExamined: 3
  }
}
字段含义健康标准
nReturned返回文档数基准值
totalKeysExamined检查的索引键数接近 nReturned
totalDocsExamined检查的文档数接近 nReturned
executionTimeMillis执行耗时低于业务阈值
indexBounds索引扫描边界精确而非全范围

3.3 执行阶段识别

COLLSCAN       全表扫描,最差
IXSCAN         索引扫描,期望
FETCH          按索引回表取文档
SORT           内存排序(无索引可用)
SORT_KEY_GENERATOR  排序键生成
PROJECTION_SIMPLE   投影裁剪
LIMIT / SKIP   分页
// 出现 SORT 说明排序未走索引
db.orders.find({ status: "paid" }).sort({ createdAt: -1 })
  .explain("executionStats").queryPlanner.winningPlan
// { stage: "SORT", inputStage: { stage: "COLLSCAN" } }

3.4 判断索引是否高效

效率比等于 totalDocsExamined 除以 nReturned。理想值为 1,表示每条返回文档只检查一次。若比值远大于 1,说明索引选择性差或未充分利用。

const es = db.orders.find({ customerId: "C1001" }).explain("executionStats").executionStats
print(`efficiency: ${(es.totalDocsExamined / es.nReturned).toFixed(2)}`)

决策铁律:优化的目标是让 totalKeysExamined 与 totalDocsExamined 都逼近 nReturned。任何一个指标高出数量级,就是索引没建对或没走对的信号。

4. $indexStats 与未使用索引

索引不是免费的,每个索引都要维护、占内存、拖慢写入。$indexStats 告诉你哪些索引从被创建以来从未被使用。

4.1 查看索引使用统计

db.orders.aggregate([{ $indexStats: {} }])
{
  name: "customerId_1_status_1",
  key: { customerId: 1, status: 1 },
  accesses: { ops: Long("184230"), since: ISODate("2026-09-01T00:00:00Z") }
}
{
  name: "legacyFlag_1",
  key: { legacyFlag: 1 },
  accesses: { ops: Long("0"), since: ISODate("2026-09-01T00:00:00Z") }
}

ops: 0 表示该索引自统计起点以来未被使用。

4.2 找出并清理未使用索引

// 列出所有 ops 为 0 的索引
db.orders.aggregate([{ $indexStats: {} }]).forEach(s => {
  if (s.accesses.ops == 0) print("unused:", s.name)
})
// 删除未使用索引(先确认不是唯一索引或特殊用途)
db.orders.dropIndex("legacyFlag_1")
判断依据动作注意
ops 为 0 且非唯一考虑删除观察足够长时间
ops 很低但为唯一索引保留承担唯一性约束
ops 为 0 但支撑 TTL保留TTL 索引访问不计入
刚创建不久观察统计窗口不足

注意:accesses.since 是统计起点的重置时间(如重启或索引重建),若 since 很近,ops: 0 可能只是统计窗口太短,不能据此删除。至少观察一个完整业务周期再决定。

4.3 索引冗余检测

前缀重复的索引是常见冗余。例如 { a: 1 } 与 { a: 1, b: 1 } 并存时,前者可被后者覆盖。

// 列出所有索引键模式,人工比对前缀
db.orders.getIndexes().forEach(i => print(JSON.stringify(i.key)))

5. collStats 与 currentOp

5.1 collStats 看集合全貌

db.orders.stats(1024)   // 单位 KB
{
  ns: "shop.orders",
  count: 13780000,
  size: 4294967296, avgObjSize: 311,
  storageSize: 3865470566, totalIndexSize: 1288490188,
  indexSizes: {
    "_id_": 456340275,
    "customerId_1_status_1": 512000000,
    "createdAt_-1": 320150913
  },
  nindexes: 3
}
// 用聚合形式获取(分片友好),latencyStats 给出读写延迟直方图
db.orders.aggregate([{ $collStats: { storageStats: {}, latencyStats: { histograms: true } } }])

5.2 currentOp 定位正在执行的慢操作

// 找出运行超过 3 秒的操作
db.currentOp({ "secs_running": { $gte: 3 }, "active": true })
{
  inprog: [
    {
      opid: 84213, op: "query", ns: "shop.orders",
      secs_running: 12, numYields: 421, planSummary: "COLLSCAN",
      client: "10.0.0.21:52344", appName: "report-service",
      command: { find: "orders", filter: { note: { $regex: "urgent" } } }
    }
  ]
}
// 终止问题操作
db.killOp(84213)
手段用途注意
currentOp看正在执行的操作关注 secs_running 与 planSummary
killOp终止操作只杀业务可中断的操作
$currentOp 聚合分片集群统一查看mongos 上执行

重要:killOp 只对可中断的操作生效(如查询、索引构建可中断),事务内的写操作会等到语句边界才中断。生产上杀操作前先确认影响范围,避免中断关键写入。

5.3 Atlas Performance Advisor 的索引建议

Atlas 托管集群内置 Performance Advisor,会自动分析慢查询并给出索引建议与效果预估。自建集群可复刻其思路:从 system.profile 提取 COLLSCAN 或 docsExamined 偏高的查询,抽出过滤与排序字段,按 ESR 规则(等值、排序、范围)生成候选复合索引,再用 hint() 验证。

6. 索引建议验证流程与反模式

6.1 用 hint 对比验证

索引建议不能凭感觉,要用 hint() 强制走不同索引,对比 executionStats:

// 现状:走 customerId 索引
db.orders.find({ customerId: "C1001", status: "paid" })
  .hint({ customerId: 1 }).explain("executionStats").executionStats
// totalDocsExamined: 8420, nReturned: 3

// 候选:新建复合索引后对比
db.orders.createIndex({ customerId: 1, status: 1 })

db.orders.find({ customerId: "C1001", status: "paid" })
  .hint({ customerId: 1, status: 1 }).explain("executionStats").executionStats
// totalDocsExamined: 3, nReturned: 3   ← 显著改善

对比结论明确后再决定是否保留新索引。若改善不明显,删除候选索引避免写入负担。

6.2 索引建议的验证清单

  • 新索引能否让 totalDocsExamined 逼近 nReturned
  • 是否与现有索引前缀冗余
  • 是否为覆盖查询(能否 totalDocsExamined: 0)
  • 写入路径的额外开销是否可接受
  • 在真实数据分布(含热点)上是否仍有效

6.3 常见反模式清单

反模式表现修正
无索引过滤COLLSCAN为过滤字段建索引
低选择性索引docsExamined 巨大用复合索引提升选择性
前缀冗余索引多个索引共享前缀删除被覆盖的短索引
排序无索引计划出现 SORT索引顺序匹配排序
正则前置通配$regex 以 .* 开头改为前缀匹配或文本索引
否定条件$ne、$nin 不走索引改用正向条件
数组字段非首键多键索引使用受限调整复合索引顺序
无节制 $or分支各自扫描拆查询或改建模
// 反例:前置通配正则无法用索引
db.orders.find({ note: { $regex: ".*urgent.*" } })   // COLLSCAN

// 正例:前缀匹配可用索引
db.orders.find({ note: { $regex: "^urgent" } })      // IXSCAN

决策铁律:索引不是越多越好。每个索引都会拖慢写入并占用内存。加索引前先用 hint 验证收益,加完后用 $indexStats 复查是否真被使用。宁可少而精,不要多而杂。

7. 总结与最佳实践

  • 分析器:生产常开级别 1 加合理 slowms,排查时把 system.profile 扩大避免证据被覆盖
  • 现场还原:system.profile 找慢操作,currentOp 抓正在跑的慢查询
  • 执行计划:explain("executionStats") 看 totalDocsExamined 与 nReturned 的比值,出现 COLLSCAN 与 SORT 即告警
  • 索引审计:$indexStats 定期清理 ops: 0 的索引,注意统计窗口足够长
  • 验证流程:先用 hint 对比新旧索引的执行统计,再决定增删
  • 反模式:前置通配正则、否定条件、排序无索引、前缀冗余是最高频的四类坑

决策铁律:慢查询治理是闭环——分析器发现、explain 定位、hint 验证、$indexStats 复盘。缺了最后一步复盘,索引只会越加越多,最终写入性能被拖垮。把这条流水线固化成 SOP,比任何单点技巧都值钱。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践