从两年前开始,我只要一听到“MongoDB 查询慢”这句话,第一反应已经不是“加索引”了。最初接手那套订单系统时,业务量一上来,一个列表页接口从几百毫秒直接飙到四五秒,DBA 让我看日志,满屏都是 COLLSCAN。后来排查的坑踩得多了,才发现慢查询这件事,十个里有八个根本不是单靠加索引能解决的:有的索引建了但没生效,有的是查询写法把索引废掉了,有的是数据分布把缓存命中了打穿,也有的纯粹是硬件等待把时间吞掉了。这篇就从我实际排查过的案例出发,把 Mongo 查询慢的常见原因、判断方法、排查工具和避坑经验系统地捋一遍,适合正在被慢查询困扰的开发者、DBA,以及刚上手 Mongo 想少走弯路的人。
1. 从一条4.7秒的查询开始:现象、误判与第一反应
1.1 第一现场:一条经营报表接口的慢查询
某天下午,业务方反馈后台经营报表页面加载非常慢,点一次要等好几秒。我先抓了慢日志,定位到一条看起来非常普通的查询:
javascript复制db.orders.find({ status: "PAID", amount: { $gt: 1000 } })
.sort({ createTime: -1 })
.limit(10)
这条语句本身没有任何“高级”写法,没有正则、没有 $where,按常理来说,一条分页查询不至于慢到这个程度。真正让它从普通查询变成慢查询的原因,藏在执行计划里:COLLSCAN,也就是全表扫描。我当时一看 docsExamined: 7345210,只返回了 10 条,却扫了 700 多万条文档,不慢才奇怪。
1.2 第一时间应该问的三个问题
很多人拿到一条慢查询,第一反应是马上写索引、改代码,但这样往往容易漏掉真正的问题。我在排查时习惯先问自己三个问题:这条查询是偶发还是持续性的?是只有这个接口慢,还是整个实例都慢?慢的时候实例的 CPU、磁盘、缓存是什么状态?
这三个问题决定了排查方向。持续性的慢查询,大概率是查询计划、索引或数据分布的问题;偶发性的慢查询,往往和资源竞争、锁等待、磁盘 IO 抖动有关。整个实例都慢,说明问题不在某一条 SQL 上,而是环境层面的瓶颈;只有个别请求慢,那才值得逐条去挖执行计划。很多人在第一步就跳进去了,结果索引建了一堆,慢查询依然存在,就是因为没有先判断慢的性质。
1.3 慢查询的本质:做了太多“无用功”
从数据库的角度看,慢查询的本质就一句话:为了拿到很少的结果,却被逼着看了大量不相关的数据。无论哪个数据库,这个底层逻辑都一样。MySQL 里这叫全表扫描,Mongo 里叫 COLLSCAN;索引扫描和回表的代价,在 Mongo 里对应的是 IXSCAN 加上 FETCH。只要理解了“扫描量”和“返回量”的巨大落差,慢查询的原因就已经找到了一半。
所以整个排查思路都很明确:想办法减少扫描量,让查询路径尽可能短。具体怎么发现扫描量,怎么缩短路径,就是接下来几节要展开的核心内容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引没那么神奇:COLLSCAN背后的代价与索引失效案例
2.1 为什么全表扫描会这么慢
MongoDB 的默认存储引擎是 WiredTiger,数据以 B+ 树的形式组织在磁盘上。没有索引的时候,find 查询只能从集合的第一个文档开始,逐个读入内存做条件匹配,直到把整个集合看完。集合里有 700 万条文档,它就读 700 万条,一条都不会少。更麻烦的是,Mongo 文档是变长的,并不是按紧凑的定长行存储,扫描过程中还要解析 BSON,这里面的 CPU 开销比很多人想象的大得多。
这里有个特别容易忽略的细节:COLLSCAN 虽然把全表都看了一遍,但 totalKeysExamined 是 0。因为根本没走索引,自然没有 key 可检查。很多初学者会把 keysExamined 和 docsExamined 搞混,其实前者是索引条目的扫描数,后者是文档的扫描数。判断一个查询是否高效,核心就是看这两个数字与返回结果数量之间的差距。
2.2 复合索引的字段顺序:等值条件放前面
我给当时的场景建的索引是:
javascript复制db.orders.createIndex({ status: 1, createTime: -1, amount: 1 })
为什么这么建,而不是 { amount: 1, status: 1, createTime: -1 }?这里涉及复合索引最核心的一个原则:等值条件的字段放在最前面,范围条件(如 $gt、$lt)和排序字段放在等值字段后面。因为 B+ 树索引是按字段顺序逐级组织前缀的,第一个字段决定了索引的第一层分支。如果第一个字段是范围查询,那等值字段就无法高效定位,索引的优势就大打折扣。
status 的基数是极低的,无非就几种状态,用它当第一字段似乎不够“有选择性”。但正因为查询里对 status 是等值匹配,它才能把扫描范围快速收敛到某个状态码的子集,然后再按 createTime 的倒序读取,恰好也满足了 sort。如果反过来把 amount 放前面,amount 是一个范围条件,那 status 条件就无法参与索引定位,性能反而下降。
2.3 索引失效的六种常见情况
建了索引但不生效,是我见过最多的“隐形坑”。整理几个高频场景:
- 查询条件里对索引字段做了表达式运算,例如
{ $where: "this.amount * 1.1 > 1000" },索引直接失效。 - 索引字段是数组类型,Mongo 会为数组元素建多键索引,但一旦数组特别长,索引条目会爆炸式增长,扫描起来并不比全表快多少。
- 正则查询如果写成不锚定的形式,比如
{ name: /张三/ },即使 name 有索引也无法使用前缀匹配。 $or子句里部分分支无法用索引时,整个查询可能会退化为并集扫描,速度成倍下降。- 查询字段类型不一致,比如某个字段在某些文档里是 string,在另一些文档里是 number,即使有索引,类型转换也会干扰索引选择。
- 对索引字段取反或使用
$ne、$nin,优化器通常只能选择全扫。
这些情况用 explain 一眼就能看出来,关键在于你有没有真的去看。我见过不少同事甩出一句“我建了索引还是慢”,结果 explain 一看,winningPlan.stage 依然是 COLLSCAN,等于索引完全没参与。
3. 打开Profiler和慢查询日志,让MongoDB自述“哪里慢”
3.1 Profiling 的三种级别与开启姿势
MongoDB 自带的 Profiler 就是慢查询定位的利器,它能把超过阈值的操作记录到 system.profile 集合中,相当于给数据库装了个行车记录仪。设置方式很简单:
javascript复制// 0: 关闭,1: 记录慢操作,2: 记录所有操作
db.setProfilingLevel(1, { slowms: 100 })
生产环境我一般建议设成 1,阈值 100 毫秒起步,如果慢查询非常多,可以先从 300 毫秒或 500 毫秒开始,逐步降阈值,避免 system.profile 被大量日志写爆。还要注意,system.profile 本身是一个 capped collection,默认大小只有 1MB,如果长时间开着高阈值记录,老日志会被覆盖。需要更完整的审计记录,可以通过 mongod 配置 operationProfiling 参数:
yaml复制operationProfiling:
mode: slowOp
slowOpThresholdMs: 100
这种方式会把慢操作同步输出到 mongod 的日志文件,持久性和可回溯性都比 system.profile 更好,也更适合在无法直接连到 admin 库做配置的场景下使用。
3.2 日志里一行关键字段的完整解读
真实环境下,开启 Profiler 后,mongod 日志里会看到类似这样一行:
code复制2024-06-12T10:24:38.231+0800 I COMMAND [conn123] command orders command: find { find: "orders", filter: { status: "PAID", amount: { $gt: 1000 } }, sort: { createTime: -1 }, limit: 10 } planSummary: COLLSCAN keysExamined:0 docsExamined:7345210 cursorExhausted:1 numYields:1801 nreturned:10 reslen:1532 protocol:op_msg 4721ms
这一段信息量非常大,我逐个拆开讲:
planSummary: COLLSCAN直接告诉你了执行计划是 COLLSCAN 还是 IXSCAN,这是第一排查线索。keysExamined: 0说明索引条目没扫;docsExamined: 7345210说明扫了 734 万条文档。这两个数字是判断查询是否高效的核心指标。numYields: 1801表示这条查询在执行过程中主动让出了 1801 次 CPU。这个值看起来不起眼,其实是查询做了大量扫描的信号,因为 Mongo 在扫描期间会定期让出资源,避免长时间占用锁。nreturned: 10是最终返回条数。docsExamined和nreturned之间相差 70 万倍,这种极度不对称的输出,基本可以断定没有可用的索引路径。4721ms是总耗时。
如果你在日志里看到 numYields 特别大,通常意味着这个操作持有读锁的时间很长,对同集合的写入会产生可见的阻塞影响。表面上是慢查询,实际上它还在拖累其他请求。
3.3 从日志到定位:一次真实的日志分析示例
有次我排查线上的“间歇性卡顿”,单看日志发现多条查询单次耗时并不高,都是 150ms 上下,但 numYields 都非常大。进一步看 serverStatus 里的全局锁指标,发现 write lock 的等待时间占比明显偏高。顺着这个线索,才找到罪魁祸首是一条大范围 update 操作,它扫描了上百万条文档去更新一个字段,长时间持有写锁,导致其他普通查询排队等待。这种问题不是靠给 find 加索引能解决的,而是要优化 update 的扫描范围,或者改成批量小批次更新。
所以排查时千万不要只看一条日志,要把同一时间窗口内的所有慢操作放在一起对照,锁等待和资源竞争才会暴露出来。
4. explain不只是“走没走索引”:关键字段与量化判断
4.1 explain 的三种模式和正确用法
Mongo 的 explain 有三种模式:queryPlanner 只返回查询计划,executionStats 会实际执行查询并返回执行统计,allPlansExecution 会分析所有候选计划并输出每个计划的执行统计。实际排查慢查询时,我基本只用 executionStats 模式,因为它会真正跑一遍查询,给你最真实的消耗数据。
javascript复制db.orders.find({ status: "PAID", amount: { $gt: 1000 } })
.sort({ createTime: -1 })
.limit(10)
.explain("executionStats")
输出里会有大量的嵌套 JSON,看着头大,但真正需要关注的字段其实就那几个。下面我把高频出现的 stage 类型和含义整理成一个表格,方便对照。
| stage | 含义 | 性能表现 |
|---|---|---|
| COLLSCAN | 全表扫描 | 差,扫描量等于集合文档数 |
| IXSCAN | 索引键扫描 | 好,说明索引被使用 |
| FETCH | 回表取文档 | 正常后续步骤,需关注总量 |
| SORT | 内存/磁盘排序 | 如果数据量大,代价高 |
| SHARD_MERGE | 分片结果合并 | 需要检查各片扫描量是否均衡 |
| OR | 多个索引或集合的交并集 | 需要关注各分支扫描量 |
| SUBPLAN | 无法用单一索引完成,走子计划 | 需要关注是否有多段扫描 |
4.2 关键字段逐项拆解
executionStats 输出里有几个字段是必看的:
nReturned:实际返回的文档数。totalKeysExamined:扫描的索引条目总数。totalDocsExamined:扫描的文档总数。executionTimeMillis:实际执行耗时。executionStages:每个 stage 的执行细节,包括具体扫描量。
极端目标下,一条查询如果完全命中索引,totalKeysExamined 应该约等于 nReturned,totalDocsExamined 即为 0(覆盖查询)或等于 nReturned(需要回表)。一旦这三个数字出现比率失衡,比如 totalDocsExamined 是 nReturned 的上万倍,恭喜你,找到慢查询的根源了。
4.3 判断查询健康状况的量化指标
我给自己定了一个简单的“体检标准”:totalDocsExamined / nReturned 大于 100 就要警惕,大于 1000 基本要立刻优化。executionTimeMillis 是结果而不是原因,真正要盯的是扫描量。不要只看耗时,耗时可能是资源竞争导致的假象,但扫描量是 100% 的执行事实。
另外还要注意 executionStages 里可能出现的 SORT stage。如果在 winningPlan 里看到 SORT,说明排序没有完全走索引,Mongo 会把所有结果集放到内存里排序。当内存超过 32MB 限制时,会把部分排序数据临时写到磁盘,这种排序引发的磁盘 IO 是最容易被忽略的隐形杀手。遇到 SORT stage,优先考虑把排序字段加进复合索引的末位,让 B+ 树直接按序返回。
5. 资源层瓶颈:缓存、磁盘IO、锁等待与连接数
5.1 WiredTiger 缓存命中率:从 99% 掉到 90% 意味着什么
WiredTiger 默认把内存的一半作为文件系统缓存,用来缓存热数据和索引页。当缓存命中率接近 100% 时,查询几乎不走磁盘,速度自然快。但当数据量超过缓存容量,或者某次全表扫描把缓存里的热数据全部冲掉后,后续查询就要频繁访问磁盘,响应时间成倍增加。
查看缓存状态的命令是:
javascript复制db.serverStatus().wiredTiger.cache
重点关注 "bytes currently in the cache" 和 "application threads page read from disk" 这两个指标,后者反映了从磁盘读页的次数,数值越大说明缓存命中率越低。全表扫描最大的危害不只是慢,而是它会污染缓存,把那些高频访问的热数据挤出内存。一次 COLLSCAN 可能会拖慢接下来整整几分钟的所有查询,这种“连带效应”在排查时要特别注意。
5.2 磁盘 IO:随机读才是真凶
MongoDB 的 WiredTiger 使用 B+ 树和 LSM 类似的刷盘机制,但查询时如果缓存没命中,还是要走磁盘。SSD 和 HDD 的随机读写性能天差地别,这很好理解。但是更多人忽略的是:磁盘 IO 的等待时间不会只体现在某一条查询上,它会变成全局性的请求堆积。
排查磁盘 IO,用 iostat -x 1 看 %util 和 await,如果 await 明显高于正常水平,说明磁盘响应本身就有问题。还有一种常见情况:开启了 db.collection.createIndex() 建索引时,后台建索引虽然允许读写,但占用的磁盘 IO 和 CPU 依然会造成其他查询性能下降。所以大集合建索引、重建索引尽量放在业务低峰期执行,这个习惯一定要养成。
5.3 锁等待与全局并发:查询慢可能在排队
MongoDB 的锁机制在 3.0 之后细粒度了很多,默认 WiredTiger 下大部分读写操作是文档级别的并发控制。但一些特殊操作仍然会造成全局或数据库级别的锁等待,例如 dropDatabase、reIndex、collection.validate、大范围 update 等。查看锁的情况:
javascript复制db.serverStatus().locks
这条命令会输出各个锁类型的等待队列、获取等待时间等指标。如果发现某个 database 锁的 acquireWait 和 timeAcquiringMicros 持续走高,说明有长事务或大扫描在独占资源。另一个常被忽略的指标是连接数:
javascript复制db.serverStatus().connections
当 current 接近 available 上限时,新连接会排队等待,表现为所有接口都慢,而不是某一条查询慢。这种情况要先看应用侧连接池配置,Mongo 本身的长连接机制是可靠的,反倒是应用层反复创建新连接会打满连接数。
6. 那些反复出现的查询反模式与排查清单
6.1 大分页:skip 100000 不是免费的
分页查询是慢查询的高发区。很多前端同学直接传一个页码,后端就 skip((page-1)*size).limit(size) 跑出去了。问题是,当跳到第 10000 页时,skip(100000).limit(10) 意味着 Mongo 必须从头扫描、丢弃前 10 万条文档,然后才返回后面的 10 条。这里扫描的量会随着页码递增,越翻越慢。
优化方式有几种:
- 如果排序字段是唯一的(如
_id或 createTime),用条件查询代替 skip:{ createTime: { $lt: lastCreateTime } }。 - 如果实在要用 skip,尽量限定在一个较短的页码范围内。
- 配合 MongoDB 的索引,让排序直接走索引,减少排序和扫描的额外开销。
6.2 正则、$where 与类型不一致:三个隐蔽杀手
正则查询 { name: /abc/ } 如果没做前缀锚定,比如 { name: /^abc/ } 还能用索引,但一旦写成包含匹配,优化器只能放弃索引遍历整个集合。$where 是另一个大坑,它允许在查询里写 JavaScript 表达式,但代价是每一条文档都要在 JS 引擎里执行一遍,性能开销极高,几乎必然引发全表扫描。
类型不一致是最隐蔽的。比如一个 order_no 字段,早期数据是字符串,后来某次导入写成了数字,同一个字段出现两种 BSON 类型。建立索引时,Mongo 会按类型分组排序,查询 { order_no: "123" } 时,索引里类型为 number 的那部分数据根本不会被扫描,但优化器如果预估失误,仍然可能直接 COLLSCAN。排查时可以用:
javascript复制db.orders.aggregate([
{ $group: { _id: { $type: "$order_no" }, count: { $sum: 1 } } }
])
检查字段的类型分布,类型不统一及时修数据,否则索引建了也白建。
6.3 聚合管道的几个“贵”操作
聚合框架里,$lookup、$unwind、$group、$sort 都是开销大户。$lookup 类似 SQL 的 left join,如果关联集合没有索引,它会退化成类似嵌套循环的扫描,非常慢。$unwind 会把数组字段展开成多行,一旦数组长度膨胀,中间结果集会被放大很多倍。$group 和 $sort 超过 100MB 内存限制时,会把数据溢写到磁盘,性能断崖式下跌。
优化方向是:给 $lookup 的关联字段建索引;在 $match 阶段尽早过滤数据,减少进入后续阶段的文档数;能用 $project 提前裁剪字段,就不要带着一堆大字段进入 $group。执行计划可以用:
javascript复制db.orders.explain("executionStats").aggregate([...])
查看每个 stage 的扫描量,找出是哪个阶段放大或拖慢了整体性能。
6.4 一个可以照抄的排查行动清单
最后给一份我平时执行的排查清单,按顺序走,大部分慢查询问题都能定位到:
- 第一步:从慢日志里捞出一到两条代表性慢查询,记录
planSummary、docsExamined、nreturned、numYields。 - 第二步:用
explain("executionStats")复现执行计划,确认 stage 是 COLLSCAN 还是 IXSCAN。 - 第三步:如果走了索引但依然慢,检查
totalKeysExamined与nReturned的比值,确认是否扫了大量索引条目,可能需要调整复合索引字段顺序。 - 第四步:看
serverStatus的缓存命中率、锁等待、连接数,排除资源层的干扰。 - 第五步:检查集合的碎片和文档大小分布,删除大量文档后记得跑一次
compact或reIndex,避免空间碎片拖慢扫描。 - 第六步:如果查询本身已经优化到极致但还是慢,考虑从架构层面解决,比如分片、读写分离、上 Redis 做热点缓存,市面上也经常能看到“分页查询慢用 redis 优化”的方案,其实本质就是把高频热数据从 Mongo 的重复计算中解放出来。
以上每一条,我都在线上环境踩过或实测过。Mongo 的慢查询排查不是什么玄学,核心就两个词:扫描量、执行计划。只要把这两件事摸透,绝大多数慢查询都能在三十分钟内定位清楚。我最深的体会是,建索引之前一定要先做 explain,索引是给查询路径服务的,不是越多越好。多花两分钟看执行计划,能省下后面好几个小时的排查时间。
