“数据库变慢了"是一句没有信息量的话。真正有用的问题是:哪条语句慢、慢在哪个执行阶段、扫了多少键、回了多少文档、有没有走索引。回答这些问题需要三样东西:分析器记录现场、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,比任何单点技巧都值钱。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。