MongoDB聚合慢查询优化:从COLLSCAN到索引与执行计划实战
2026/9/20 21:49:16 网站建设 项目流程

上个月线上一个用MongoDB做的订单统计接口突然变慢,慢到报表页面转圈十几秒。我第一反应是数据量涨了,可等我把那条聚合查询单独拿出来跑的时候,发现问题其实是整个聚合在做COLLSCAN扫全表。那一刻我很清楚:聚合操作离了索引,就是让几百万条文档在磁盘上裸奔。这次踩坑让我把MongoDB聚合操作和索引的关系重新梳理了一遍,也理解了官方文档为什么反复强调“先看执行计划,再谈优化”。这篇文章把这段经历整理出来,从聚合管道的每个节点怎么工作,到索引到底在哪些环节能加速、哪些环节无能为力,最后用一个完整的订单报表案例走完优化全过程。如果你正在用MongoDB做统计查询、或者正被慢聚合折磨,这篇应该能帮上忙。

1. 从一次线上聚合慢查询说起

1.1 那个“很简单”的统计接口为什么超时

当时线上有一张订单表,每天新增几十万条数据,总数据量到了几百万。业务方提了个很常见的需求:统计某个时间窗口内、已支付订单里,每个渠道分别贡献了多少金额和多少单量。我最初写的聚合长这样:

db.orders.aggregate([ { $match: { status: 'PAID', createTime: { $gte: start, $lt: end } } }, { $group: { _id: '$channel', totalAmount: { $sum: '$amount' }, count: { $sum: 1 } } }, { $sort: { totalAmount: -1 } } ])

看起来逻辑没问题:先用$match过滤已支付订单和时间范围,再按渠道分组,最后按金额排序。问题是orders集合当时没有给status和createTime建索引,结果最前面的$match直接退化成了全集合扫描。几百万文档被完整读一遍,能进到$group里的数据虽然已经小很多,但时间成本全耗在第一步读取上了。

这个例子里的$match写在管道最前面,书写习惯并不差,但还是慢。原因很简单:没有索引的情况下,MongoDB只能把集合里每一份文档都读出来,逐个判断status和createTime条件是否成立。这和MySQL里没有索引的where是一个道理。加了索引之后,引擎才能先通过索引找到符合条件的文档位置,再按需取文档,扫描量从“全表”变成“命中结果集”。

1.2 MongoDB聚合到底是怎么工作的

聚合操作在MongoDB里的正式名称叫Aggregation Pipeline,核心思想是管道:一份文档从入口进来,经过一个阶段处理,输出给下一个阶段。整个过程就像流水线上多个工位,每个工位只干一件简单的事,干完再把半成品交给下一站。

和find()查询不同,find()是一次性返回满足条件的文档,聚合则可以做“加工”,比如字段计算、分组汇总、多集合关联、数组展开。这也决定了两者的性能逻辑有差异:find()优化主要围绕索引扫描,聚合优化不仅要看索引,还要看每个阶段之间转了多大的数据量、有没有临时排序、内存够不够。

所以你会看到两种优化思路是叠加的:索引决定“读得快不快”,管道结构决定“算得省不省”。这一篇的重点,就是把这两件事放在一起讲清楚。后面所有案例我都会基于一个模拟订单表来说明,方便你直接在自己的环境里复现。

2. 聚合管道:把数据处理的每一环拆开看

2.1 管道顺序是逻辑基础,但不是最终执行顺序

聚合管道整体是一个数组,阶段按数组顺序依次执行。仔细理解这一点很重要:你在管道里写了$match、$group、$sort,那就意味着数据会先被过滤,再被分组,再被排序。如果某一步突然把结果集扩大,后面每一步的代价都会跟着涨。

但MongoDB优化器不一定会完全按你写的顺序执行。它能识别出某些$match不依赖前面的阶段,就可能会把它尽量提前,先过滤数据再进入后续计算。所以你在explain里看到的执行计划,和写的管道结构可能有差别。这不代表写错了,而是优化器在尝试做合理搬移。

不过这也带来一个误区:有人以为优化器会兜底,于是把$match写在最后面,或者把$project里的字段计算放在$match前面,结果性能一塌糊涂。优化器能做的是有限的、保守的重排,它不会替你去重新设计整个管道。任何时候,都建议你把最严格的过滤条件放在管道最前面,并尽量保持字段的原始类型和原始命名。

2.2 常用阶段速查:能不能吃到索引,各有各的规矩

我把聚合里最高频的几个阶段整理成了一个表,重点标注它们和索引的关系。

阶段作用能否直接利用索引注意事项
$match过滤文档可以,和find很像尽量放管道最前;字段被表达式处理后不保证命中
$sort排序可以,前提是字段有索引且排序字段不被计算基于表达式结果的排序无法利用索引
$group分组聚合不直接使用索引如果前面数据按分组键有序,能节省大量内存和CPU
$lookup关联其他集合foreignField上的索引localField/foreignField方向会对索引使用产生影响
$unwind展开数组展开后文档数膨胀,影响后续阶段
$project字段投影/计算可能被优化器交换位置,但不宜依赖
$facet多维度并行聚合子管道内有限制多面统计很好用,但别塞太重的计算

在不同版本里,这些阶段的实际行为会有细节差异,但大方向不会变。我建议你以这个表作为速查基准,在实际项目里用explain验证。

2.3 三个最常用的累加器,先把分组逻辑练熟

$sum、$avg、$addToSet是$group阶段最常用的三个累加器。$sum可以累加数值或计次数,$avg算平均值,$addToSet会把不同值收集到一个数组里再手动去重。比如统计每个渠道有多少下了单的用户,就可以用 $addToSet: '$userId' ,再通过 $size 拿到数组长度。

但$addToSet有个隐患:它会维护一个不断增长的数组。如果某个渠道的用户数量特别大,这个数组会一直膨胀,很快触及聚合管道的内存上限。我见过有人统计“历史累计用户数”时把整个生产环境聚合内存打爆,就是因为忽略了这一点。

一个小经验:如果统计的精度要求没那么高,或者时间窗口可以切小,就不要让$group吞下全集。比如“近30天用户数”和“历史累计用户数”完全不是一个量级,业务上往往只需要前者。先通过$match把时间窗口切窄,再进$group,内存和CPU压力都会小很多。

3. 索引:聚合查询能快起来的真正原因

3.1 从COLLSCAN到IXSCAN,快在哪里

MongoDB的索引默认是B树类结构,索引中保存着字段值和指向文档的引用。对普通查询来说,有索引就可以从几十万甚至几百万文档中快速定位出少量结果,而不是全部扫一遍。在explain输出里,全表扫描的阶段是COLLSCAN,命中索引后是IXSCAN。

聚合中最容易吃到索引的是开头的$match。你加了索引后会看到totalDocsExamined从几百万降到几千,执行时间立刻降两个数量级。这不是什么魔法,就是磁盘IO量级的差别。几百万文档平摊到磁盘上要连续读很久,而索引扫描只需要沿着B树找到少量叶子节点,再按指针抓取极少数文档。

建议你在学习阶段就养成一个习惯:每写一条聚合,都顺手用explain("executionStats")看一眼,确认最前面的过滤条件是不是真的走了索引。如果发现COLLSCAN,先别怀疑MongoDB,多半是索引还没建对。

3.2 复合索引怎么建:先按ESR原则排字段顺序

单字段索引很好理解,但实际查询往往同时包含等值条件、排序字段、范围条件。这时候复合索引的字段排列顺序就非常关键。我推荐你按ESR原则来设计:

  • E(Equality):等值过滤字段放最前面。精确匹配能把检索范围缩小到某个区间,后续字段在这个区间内还能保持有序。
  • S(Sort):排序字段放中间,让索引顺序天然匹配排序要求,避免额外内存排序。
  • R(Range):范围字段放最后。范围条件会让字段在索引上不再是精确值,放在前面会破坏后续字段的有序性。

举个例子。查询条件是status等于'PAID',createTime按倒序排,amount做范围过滤,那么复合索引建{ status: 1, createTime: -1, amount: 1 }是合理的,而不是把range字段放到最前面。如果把amount放前面,后面createTime的排序就可能无法完全利用索引,导致执行计划里出现SORT stage,性能立刻下降。

当然,ESR不是万能。如果查询形态变化很大,比如status不再是单一等值、范围字段和排序字段互换,索引可能就不适用。我的建议是:为线上常用的两三种查询形态各建索引,别试图搞一个“万能索引”,更不要见字段就建索引,否则写性能会被拖垮。

3.3 聚合阶段里,索引能帮上哪些忙、帮不上哪些忙

聚合阶段不是所有都能吃索引。$match最像find,可以直接使用索引;$sort如果能用上索引顺序,就不会产生内存排序;$lookup在关联其他集合时,foreignField字段的索引可以显著提升匹配速度。但$group、$unwind、$addFields这些计算型阶段本身不走索引,它们主要靠前面的$match把数据量缩小,或者靠前面的$sort让数据变得有序。

有一个常见误解:给$group的_id字段建了索引,$group就会快。实际上,索引不能直接让$group跳过扫描。它只会在前面某个$match里帮上忙,或者让进入$group的数据流有序。如果$group前面没有任何过滤、没有针对分组字段的排序,那索引对$group几乎无感。你真正要做的,是让“进到$group这个阶段的数据”尽量少、尽量有序。

再说$lookup。它本质是在另一个集合上执行一次等值查询,所以被关联集合的foreignField索引非常关键。如果users集合的userId字段没有索引,每次$lookup都会在users集合里做一次全表扫描,这个代价比想象中大很多,尤其是在关联大量订单时。

4. 用explain拆解聚合执行计划

4.1 两行命令,看到聚合的“体检报告”

调试聚合性能,我几乎只用explain:

db.orders.explain("executionStats").aggregate([ { $match: { status: 'PAID', createTime: { $gte: start } } }, { $group: { _id: '$channel', count: { $sum: 1 } } } ])

用executionStats才能看到真实执行统计。重点看这几个指标:

  • winningPlan.stage:如果顶层是COLLSCAN,说明有地方在扫全表;如果在聚合管道里能看到IXSCAN,说明命中了索引。
  • totalDocsExamined:实际扫描的文档数。这个数字越接近命中条件的结果集,说明过滤条件越有效。
  • totalKeysExamined:扫描的索引条目数。如果明显大于结果集数量,说明回表太多或范围太大。
  • executionTimeMillis:总耗时。优化前后最好各存一份,方便对比。

聚合管道的explain输出会按阶段分块展示,比如一个IXSCAN下面跟着FETCH、SORT、GROUP等。看到SORT就要格外关注:排序字段是不是被索引覆盖了。如果$sort之前数据已经有序,explain里通常不会出现SORT stage;一旦出现,就说明MongoDB认为需要额外排序。

4.2 学会看优化器做了哪些“小动作”

MongoDB聚合优化器会在执行前对管道做重排。比如你写的是$group、$match、$sort,但$match条件不依赖分组结果,优化器可能把$match移到$group前面,先过滤再分组。反过来,如果你在$match前用了$project改写了字段,优化器找不到原始字段,可能就不做这个搬移。

这个机制的实际意义是:别把优化器当兜底。你写管道时就应该按“最严格过滤最早发生、尽量少改动原始字段”来设计,这样不管优化器是否调整,结果都更优。很多慢查询不是因为MongoDB不行,而是因为管道书写的顺序和字段变化方式没有把索引利用起来。

4.3 hint:什么时候该强制走索引

如果你想临时验证某个索引是否有效,或者预判优化器选错索引,可以用hint强制某个索引:

db.orders.aggregate(pipeline, { hint: { status: 1, createTime: -1 } })

但要小心,hint是一把双刃剑。如果数据分布发生变化,比如status='PAID'的文档占了80%,那全表扫描可能比索引扫描更快,因为索引扫描还要回表读文档。所以hint更适合临时调试,不建议长期写死在业务代码里。更健康的做法是定期观察执行计划,让优化器自己选索引。

5. 实战:订单报表聚合优化全过程

5.1 业务需求与初始实现

模拟场景里,orders表有这些字段:orderId、userId、channel、status、amount、createTime。业务方要求统计最近30天每个渠道的订单数、总金额、平均金额、下单用户数,按总金额降序。初始聚合写出来是这样的:

db.orders.aggregate([ { $match: { status: 'PAID', createTime: { $gte: start, $lt: end } } }, { $group: { _id: '$channel', count: { $sum: 1 }, totalAmount: { $sum: '$amount' }, avgAmount: { $avg: '$amount' }, userIds: { $addToSet: '$userId' } } }, { $project: { channel: '$_id', count: 1, totalAmount: 1, avgAmount: { $round: ['$avgAmount', 2] }, userCount: { $size: '$userIds' } } }, { $sort: { totalAmount: -1 } } ])

线上orders有约800万文档,这个查询第一次跑要3秒多。虽然凌晨报表还能出,但已经在拖慢整个实例。用explain一看,顶层阶段是COLLSCAN,totalDocsExamined接近800万,问题一目了然:数据访问路径没有索引。

5.2 第一轮优化:从全表扫描改成索引扫描

我先给createTime建了单字段索引:

db.orders.createIndex({ createTime: -1 })

再跑explain,$match不再扫全表,totalDocsExamined从接近800万降到了最近30天的几十万,执行时间降到700毫秒左右。

紧接着我把索引调整成符合ESR的复合索引:

db.orders.createIndex({ status: 1, createTime: -1 })

因为$match里status是等值条件,createTime是范围条件,把等值字段放前面,可以让MongoDB先定位到所有已支付订单,再在索引里扫描时间范围。这样索引扫描的键数量比单纯按时间扫更少,整体又省下了一截时间。

不过我也要诚实说明:如果业务上根本不需要过滤status,单字段createTime索引就够了。ESR是为了配合实际查询形态,不是为了拼一个好看的长索引。

5.3 第二轮优化:别让聚合去背物化的锅

索引把查询成本压到700毫秒后,我继续看explain,发现$sort totalAmount仍然躲不掉内存排序。原因很关键:totalAmount是$group算出来的结果,不是订单表里的原始字段。索引只能建立在原始文档字段上,所以任何索引都无法直接消除这个排序。

这时候就要跳出来想业务方案了。报表数据其实不需要每次实时从原始订单表算,完全可以物化。我的最终方案是加了一张report_daily_order表,每天凌晨跑一次聚合,把前一天每个渠道的订单数、总金额、平均金额、下单用户数落到小表里。报表查询只需要汇总几十条到几百条记录,毫秒级返回,而且彻底避开了大管道。

这是我在生产环境里比较推崇的思路:聚合优化不能只盯管道本身,很多时候要做业务拆分。高频查询用物化结果,低频实时查询再用原始表,两者结合,性价比最高。

5.4 一个$lookup加速的小例子

另一次查询里,我需要把订单关联用户表取用户昵称,大概是这样:

db.orders.aggregate([ { $match: { createTime: { $gte: start } } }, { $lookup: { from: 'users', localField: 'userId', foreignField: 'userId', as: 'user' } }, { $unwind: '$user' } ])

一开始users表userId没有索引,$lookup每匹配一个订单就去users表扫一遍,最终执行时间非常难看。给users.userId建唯一索引后,$lookup的匹配变成索引查询,整体速度提升非常明显。这里我想强调的是,多集合关联的索引检查经常被遗漏,尤其是首次接入新集合时,一定要关心被关联集合foreignField的索引情况。

6. 日常开发中最容易踩的坑

6.1 字段类型不一致,等于没有索引

这个问题很隐蔽。某个查询日志里userId明明是字符串,但users集合里存的userId是ObjectId。等值查询走索引时,由于类型不匹配,MongoDB可能直接跳过索引,回到全表扫描。排查时可以先检查字段类型分布,用 $group + $type 就能快速看出有没有混存的情况。类似的坑还有数字用字符串存储,范围查询全挂,排序也乱掉。

6.2 在$project改名后再$match,索引命中率骤降

有人习惯先把字段处理成别名,再拿别名过滤。比如先$project把channel改名为ch,再在后面$match { ch: 'app' }。但ch是$project生成的字段,不是orders集合里的原始字段,MongoDB无法用channel字段的索引去优化后面的$match。正确做法是先$match,再$project。从explain能看到,写反之后的执行计划会变长变复杂,扫描量明显变大。

6.3 group内存超限与allowDiskUse

当$group的分组桶超过100MB内存时,聚合会报错,提示Exceeded memory limit for group。一些人会直接加allowDiskUse: true,让它把中间结果落盘。这个办法能跑,但那是用硬盘换内存,慢得离谱。更好的办法还是我前面反复提的那两条路:缩小$match范围,或者物化中间结果。把allowDiskUse当成临时救急手段,而不是默认配置。

6.4 分片集群上,聚合的索引使用会更谨慎

如果orders集合做了分片,聚合在mongos上完成后,需要把各shard的数据汇总。每个shard本地,索引可以帮助定位本shard内的文档,但如果shard key和$match条件不一致,mongos可能要把大量分片数据拉过来做merge。尤其是$group,如果分组键不是shard key,merge阶段的内存压力会成倍增大。所以分片集群的设计不能只看索引,还要看shard key和聚合分组键之间的关系。

最后说一个我自己的习惯:每次在MongoDB上写新的聚合查询,上线前我都会用explain("executionStats")跑一遍,把执行计划和时间存档。等哪天线上变慢,直接拿出当天的数据对比,是索引没吃到、数据量涨了还是执行计划变了,一目了然。别小看这个存档动作,它帮我在生产环境里避免过好几次潜在事故。聚合和索引的道理说起来不复杂,真正难的是在业务压力来之前就想到它们。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询