尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MongoDB聚合慢查询优化:从COLLSCAN到索引与执行计划实战

MongoDB聚合慢查询优化:从COLLSCAN到索引与执行计划实战 上个月线上一个用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原则来设计EEquality等值过滤字段放最前面。精确匹配能把检索范围缩小到某个区间后续字段在这个区间内还能保持有序。SSort排序字段放中间让索引顺序天然匹配排序要求避免额外内存排序。RRange范围字段放最后。范围条件会让字段在索引上不再是精确值放在前面会破坏后续字段的有序性。举个例子。查询条件是status等于PAIDcreateTime按倒序排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 两行命令看到聚合的“体检报告”调试聚合性能我几乎只用explaindb.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是一把双刃剑。如果数据分布发生变化比如statusPAID的文档占了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一看顶层阶段是COLLSCANtotalDocsExamined接近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 keymerge阶段的内存压力会成倍增大。所以分片集群的设计不能只看索引还要看shard key和聚合分组键之间的关系。最后说一个我自己的习惯每次在MongoDB上写新的聚合查询上线前我都会用explain(executionStats)跑一遍把执行计划和时间存档。等哪天线上变慢直接拿出当天的数据对比是索引没吃到、数据量涨了还是执行计划变了一目了然。别小看这个存档动作它帮我在生产环境里避免过好几次潜在事故。聚合和索引的道理说起来不复杂真正难的是在业务压力来之前就想到它们。
返回列表