先说说我在实际工作中经常遇到的几个典型场景:大多数 MongoDB 聚合查询性能低下的原因,往往不是写法的复杂程度,而是索引没有正确命中,或者管道的阶段顺序安排不当。与其凭直觉猜测,不如直接使用 explain("executionStats") 的输出结果,然后根据数据扫描量与内存占用这两个关键指标来精准调优。
用 explain("executionStats") 快速定位瓶颈
不要等到用户投诉了才想起排查,每次修改聚合管道后,都应该顺手执行一次 explain 验证。这个命令不仅显示耗时,更能揭示真实的执行路径。以下是几个典型现象及其判断方法:
totalDocsExamined远大于nReturned,比如返回 200 条却扫描了 50 万条 —— 说明数据过滤太晚或索引未命中,应第一时间检查第一个$match字段上是否建立了索引。totalKeysExamined接近totalDocsExamined但耗时依然偏高 —— 这反而表明索引已经生效,问题出在管道阶段本身,例如$unwind后文档数量急剧膨胀,或者$group之前没有通过$project精简字段。- 某个
stage下出现"memUsage": 98765432这样的数值 —— 内存超限风险已很高,需要添加allowDiskUse: true参数,否则系统会静默降级,性能越来越差。 - 看到
stage: "COLLSCAN"出现在$lookup的from集合里 —— 问题不在主集合,而是被关联的那个集合缺少索引。
$match 必须放最前面,字段顺序也不能乱
索引只对聚合管道中第一个能走索引的 $match 生效。如果后面再写 $match,数据已经变形,索引基本就失效了。以下几个细节需要留意:
- 复合索引的字段顺序必须与
$match中的查询顺序一致。例如创建了db.logs.createIndex({userId: 1, createdAt: 1}),它能高效匹配{$match: {userId: "u1", createdAt: {$gte: ...}}};但如果查询写成了{$match: {createdAt: ..., userId: "u1"}},效果会大打折扣,甚至直接不走索引。 - 数据类型必须保持一致:索引是
ObjectId,查询却传了字符串类型的 ID,索引就会被跳过。 $lookup内部的$match(pipeline 形式)也需要单独建索引,而且索引应建在from集合上,而不是主集合。
什么情况下必须加 allowDiskUse: true
不要等到内存不够了才启用,只要管道中包含 $group、$sort 或 $unwind,并且输入数据量不可控,就应该默认开启。原因很简单:
- MongoDB 默认有 100MB 的内存限制。一旦超限,
$group会自动尝试降级到磁盘,但如果没有配置allowDiskUse,会直接报错,返回类似errmsg: "Exceeded memory limit..."的错误。 - 开启后不会报错,但性能波动会很大——有时候快有时候慢,完全取决于磁盘 I/O。因此必须配合
explain来判断是否真的触发了磁盘落盘操作。 - 生产环境建议始终显式传递
{allowDiskUse: true},不要依赖静默行为。
$project 不只是删字段,更是控内存的关键
很多人以为 $project 只是用来选择返回哪些字段,其实它直接影响中间数据的体积和内存压力。以下几个要点值得注意:
- 在
$match之后立即添加一个$project,只保留后续阶段真正需要的字段。例如$group只用到了userId和amount,那就只投射这两个字段。 - 尽量避免在
$project中执行复杂计算,比如$concat、$dateToString。这些操作会在每条文档上执行,拖慢整个阶段。 _id: 0不要省略。尤其当$group后面紧跟着$sort时,多余的字段会一同参与排序,徒增开销。
真正卡住性能的,往往不是某一行代码写得不对,而是 $match 没有走索引、$unwind 之后没有 $project、或者 allowDiskUse 没开启却指望大数据量能稳定运行。这些细节如果不盯死,调整参数或更换硬件都很难根治问题。
