跳到正文

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 }
])

管道优化顺序 ​

  1. $match 前置:能下推到索引的条件放第一段;$group 之后的 $match 是 HAVING,只过滤聚合结果。
  2. $sort 配合索引:排序字段按 ESR 放进复合索引,避免 SORT 阶段;没有索引时大数据量会触发内存排序,必要时用 allowDiskUse:true 兜底。
  3. $limit 靠前:和 $sort 一起做 top-N;但 $limit 放在 $group 前会改变语义,不能乱移。
  4. $project 提前裁剪:减少后续阶段传递的字段,但收益通常小于 $match。
  5. $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 做预聚合,不要硬跑实时聚合。

把一门技术讲透到既懂也会用