主题
MongoDB 聚合管道与查询优化
一句话:聚合管道是一条文档流水线,用
$match尽早缩小数据、用索引支撑$sort、用$lookup做有限关联;优化的本质是让尽可能少的文档进入后面的阶段。
适用版本:MongoDB 7.0 / 8.0。$lookup 的 pipeline 形式自 3.6 起,不相关子查询形式自 5.0 起。
为什么需要
find() 只能做过滤和投影;一旦要分组、关联、多维度统计,就得把数据拉到应用层循环,网络和内存都浪费。聚合管道把过滤、分组、排序、关联下推到数据库,并允许优化器把 $match 前置、利用索引。
但管道写错顺序,性能会差几个数量级:先 $group 再 $match 会让全量数据参与聚合;$lookup 的外键没索引会退化成集合扫描;$sort 没有索引支撑会触发内存排序。
核心机制
常用阶段
| 阶段 | 作用 | 类比 SQL |
|---|---|---|
$match | 过滤文档 | WHERE |
$group | 分组聚合 | GROUP BY |
$sort | 排序 | ORDER BY |
$project | 投影/重命名/计算 | SELECT |
$lookup | 左外关联 | LEFT JOIN |
$unwind | 展开数组 | 行转列 |
$addFields | 新增计算字段 | 计算列 |
$count | 计数 | COUNT(*) |
$skip / $limit | 分页 | OFFSET / LIMIT |
$facet | 同一输入多路聚合 | 多个 UNION |
$merge | 把结果写回集合 | INSERT ... SELECT |
javascript
db.orders.aggregate([
{ $match: { status: "completed", createdAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: "$category", total: { $sum: "$amount" }, count: { $sum: 1 } } },
{ $match: { total: { $gt: 1000 } } },
{ $sort: { total: -1 } },
{ $limit: 10 }
])管道优化顺序
$match前置:能下推到索引的条件放第一段;$group之后的$match是HAVING,只过滤聚合结果。$sort配合索引:排序字段按 ESR 放进复合索引,避免SORT阶段;没有索引时大数据量会触发内存排序,必要时用allowDiskUse:true兜底。$limit靠前:和$sort一起做 top-N;但$limit放在$group前会改变语义,不能乱移。$project提前裁剪:减少后续阶段传递的字段,但收益通常小于$match。$facet共享前置结果:多个统计维度共用同一段$match,避免重复扫描。
$lookup 的三种形式
javascript
// 1) 等值关联(最常用):foreignField 必须有索引
db.orders.aggregate([
{ $lookup: { from: "users", localField: "userId",
foreignField: "_id", as: "user" } },
{ $unwind: "$user" }
])
// 2) pipeline 形式:可在子管道内过滤、投影
db.orders.aggregate([
{ $lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$uid"] } } },
{ $project: { name: 1, _id: 0 } }
],
as: "user"
} }
])
// 3) 不相关子查询(5.0+):不依赖当前文档,适合字典表
db.orders.aggregate([
{ $lookup: { from: "categories",
pipeline: [{ $match: { active: true } }],
as: "activeCategories" } }
])$lookup 的性能要点:foreignField 必须有索引;localField 分布不均时,某些文档会关联出大量结果;pipeline 形式尽量把 $match 放进子管道;$lookup 默认是左外连接,需要内连接时加 $unwind + $match。
explain("executionStats")
javascript
db.orders.find({ status: "paid", amount: { $gt: 100 } })
.sort({ createdAt: -1 })
.explain("executionStats")| 字段/阶段 | 看什么 | 判断 |
|---|---|---|
COLLSCAN | 集合扫描 | 缺索引 |
IXSCAN | 索引扫描 | totalKeysExamined 越接近 nReturned 越好 |
FETCH | 回表 | 覆盖查询时为 0 |
SORT | 内存排序 | 排序字段没进索引 |
totalDocsExamined | 扫描文档数 | 远大于 nReturned → 选择性差 |
executionTimeMillis | 耗时 | 结合 SLA,不只看单次 |
聚合也用 db.collection.explain("executionStats").aggregate([...]),重点看第一段 $match 是否走 IXSCAN。
最小上手
javascript
// 7.0/8.0:按状态分组,统计金额和 P95 近似值
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: "$status",
total: { $sum: "$amount" },
avg: { $avg: "$amount" },
count: { $sum: 1 } } },
{ $sort: { total: -1 } }
])
// 验证:把 aggregate 换成 explain
db.orders.explain("executionStats").aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$category", n: { $sum: 1 } } }
])预期:$match 阶段出现 IXSCAN;如果出现 COLLSCAN,先补 {status:1} 或对应复合索引。
生产动作
- 慢查询开关:
db.setProfilingLevel(1, { slowms: 100 }),slowms默认 100ms;7.0+ 可加sampleRate采样。级别 0 时 mongod 仍会把超过阈值的操作写进日志文件。 system.profile容量:这是 capped collection,默认 1MB,不是 1GB;容量创建时确定,要放大先setProfilingLevel(0),删掉后按{capped:true, size:...}重建。- 排查顺序:先看
COLLSCAN,再看totalDocsExamined/nReturned,再看SORT,最后看$lookup外键索引。 - 大数据量:实时性要求不高时用
$merge写汇总集合,查询只读汇总表;$bucket只是分桶,不是预聚合。 - 分片:
$match带分片键才能路由到单个 shard;否则广播到所有 shard。
边界
$lookup不是关系库 JOIN:没有成熟的连接优化器,数据量大时先改模型(内嵌/扩展引用)。$group的_id基数很高时,内存和网络开销都大;分片场景注意allowDiskUse。- 聚合结果不保证顺序,除非显式
$sort。 - 深分页用
$skip很贵;改成「上一页最后一条的排序值」做范围条件。 $facet不是并行魔法:它共享输入,但每个子管道仍然各自消耗内存。
讲清楚
聚合管道就是一条文档流水线:
$match过滤、$group聚合、$sort排序、$lookup关联。优化只有一句话:让尽可能少的文档进入后面的阶段。所以$match要前置并命中索引,$sort要由索引顺序满足,$lookup的外键要有索引,$limit要尽量靠近$sort。判断好坏用
explain("executionStats"):先看有没有 COLLSCAN,再看扫描/返回比,最后看有没有内存 SORT。慢查询用 profiler,slowms默认 100ms,system.profile默认只有 1MB,写满会覆盖旧记录;要放大得先关 profiling 再重建。数据量到十亿级、实时性又不高时,用$merge做预聚合,不要硬跑实时聚合。