Chapter 02

查询与聚合管道

01 章建立了文档模型和"为访问模式建模"的原则。这章讲怎么把数据读出来——find 查询与聚合管道——并拆开 $lookup,看清它为什么不是关系型那种 JOIN。

本章你将建立的 schema

  • find + 查询操作符 + 投影的心智模型,对照 SQL 的 WHERE / SELECT。
  • 聚合管道是有序的 stage 流水线,而非声明式集合运算。
  • $lookup 本质是嵌套循环,不是 hash / merge JOIN——它为什么贵。
  • 查询如何执行:多计划竞速 + plan cache + SBE / Express。

2.1find 与查询操作符

find 按文档匹配条件返回文档集,投影裁剪字段;对应 SQL 的 WHERE + SELECT。

为什么需要它

读取是数据库最高频的操作。find 是单集合查询的入口——给一个匹配条件(filter),引擎返回所有满足条件的文档;再给一个投影(projection),引擎在返回前裁掉不需要的字段,减少网络与内存开销。两个参数对应关系型 SELECT <projection> FROM coll WHERE <filter> 的两半。

底层机制(比文档深一层):filter 由比较操作符构成——$eq / $gt / $gte / $lt / $in,逻辑组合用 $and / $or。对标量字段这些操作符的语义和 SQL 一致。陷阱出现在数组字段:当一个字段是数组,{results: {$gt: 80, $lt: 90}} 的判定不是"存在一个元素同时满足两条件",而是"数组里分别有元素满足 $gt: 80、分别有元素满足 $lt: 90"。所以 results: [95, 70] 会命中——因为 95 > 80 成立、70 < 90 成立,两个条件各被数组里不同的元素满足。要表达"同一个元素同时落在 (80, 90) 区间",必须用 $elemMatch:{results: {$elemMatch: {$gt: 80, $lt: 90}}} 才会把 [95, 70] 排除掉。这是数组查询最常见的误解。

投影用 1 / 0 表达包含或排除:{title: 1} 只返回 title(外加默认带上的 _id),{body: 0} 返回除 body 外的全部字段。包含与排除不能在同一个投影里混用(_id 是唯一例外,允许 {title: 1, _id: 0} 这样显式排除主键)。

find-operators-and-projection mongosh
// 等值 + 范围 + IN,配投影只取需要的字段
db.students.find(
  { grade: "A", score: { $gte: 80, $lt: 90 }, city: { $in: ["SH", "BJ"] } },
  { name: 1, score: 1, _id: 0 }     // 只返回 name 和 score,排除 _id
)

// 数组字段的陷阱:results 是一个分数数组
// 这条会命中 results:[95,70]——95>80 与 70<90 分别由不同元素满足
db.exams.find({ results: { $gt: 80, $lt: 90 } })

// 要求"同一个元素"落在 (80,90),必须用 $elemMatch——它会排除 [95,70]
db.exams.find({ results: { $elemMatch: { $gt: 80, $lt: 90 } } })
表 2.1 · MongoDB find ↔ SQL 对照
意图MongoDB findSQL
等值{status: "paid"}WHERE status = 'paid'
范围{score: {$gte: 80, $lt: 90}}WHERE score >= 80 AND score < 90
IN{city: {$in: ["SH","BJ"]}}WHERE city IN ('SH','BJ')
投影find(filter, {name: 1, _id: 0})SELECT name
想一想

集合里有文档 {scores: [50, 99]}。查询 db.coll.find({scores: {$gte: 70, $lte: 80}}) 会不会返回它?换成 $elemMatch 包裹同样两个条件呢?

展开答案(先停 10 秒)

不带 $elemMatch 会返回:因为 99 >= 70 成立、50 <= 80 成立,两个条件各由数组里不同的元素满足,文档即命中——尽管没有任何单个元素落在 [70, 80] 区间。换成 {scores: {$elemMatch: {$gte: 70, $lte: 80}}} 后不会返回:$elemMatch 要求同一个元素同时满足全部条件,而 50 和 99 都不在 [70, 80] 内。凡是对数组字段写多条件,先问一句"要的是同一个元素吗"。

2.2聚合管道

聚合管道是一串有序 stage,每个 stage 把文档流变换后喂给下一个。

为什么需要它

find 只能匹配和投影,做不了分组、汇总、跨集合关联、重塑结构。聚合管道(aggregation pipeline)补齐这部分——它是 MongoDB 的分析与变换引擎,能力对标 SQL 的 GROUP BY / JOIN / 窗口与子查询的组合。

底层机制(比文档深一层):管道是一个 stage 数组,文档流从第一个 stage 进、逐个 stage 流过、从最后一个 stage 出。核心 stage 各司其职:

  • $match — 过滤文档,语义同 find 的 filter;尽量前置。
  • $group — 按 _id 表达式分组,配累加器 $sum / $avg / $push(收集进数组)/ $addToSet(去重收集)。
  • $project — 重塑字段:保留、重命名、计算派生值。
  • $unwind — 把一个数组字段炸开成多条文档,数组里每个元素生成一条。
  • $sort — 排序。
  • $lookup — 对右集合做左外连接(§2.3 拆开它)。
  • $facet — 一份输入并行跑多个子管道,各自独立产出。
  • $bucket / $bucketAuto — 按边界把文档分桶,做直方图。

关键机制对照 SQL:SQL 是声明式的,写 WHERE / GROUP BY / ORDER BY 不规定执行顺序,由查询优化器决定怎么跑。管道是过程式的,stage 顺序由编写者显式写出,引擎原则上按写定的顺序执行——但优化器仍会做有限重排,最典型的是把 $match 向前下推到尽量靠前的位置,以减少后续 stage 需要处理的文档数。把 $match 和 $project 写在尽早的位置,等于亲手缩小下游每个 stage 的输入——这是管道性能的第一直觉。

aggregate-group-by-tier mongosh
// 已支付订单,按客户等级汇总总额,再按总额降序
db.orders.aggregate([
  { $match: { status: "paid" } },                    // 先过滤,缩小下游输入
  { $group: {
      _id: "$customer.tier",                          // 按嵌套字段分组
      revenue: { $sum: "$total" },                    // 累加订单金额
      orders:  { $sum: 1 }                            // 计数
  } },
  { $sort: { revenue: -1 } }                          // 总额从高到低
])
文档流逐 stage 变换,流出数递减 documents $match $group $sort $project 结果 10⁶ 10⁴ 50 50 50 50 流出文档数:$match 砍掉 99%,下游每个 stage 都受益
图 2.1聚合管道是横向流水线,文档逐 stage 变换。注意:$match 越早,后面 stage 处理的文档越少——这是管道唯一最重要的优化直觉。

2.3$lookup 的真相:嵌套循环

$lookup 是对左侧每个输入文档去右集合查一次的嵌套循环,不是 hash / merge JOIN。

为什么需要它

引用式建模(01 章)把数据拆到多个集合,读取时需要关联。$lookup 是聚合管道里做跨集合关联的 stage,语义是左外连接:左侧每个文档保留,匹配到的右侧文档作为一个数组字段附加上去。能力像 SQL 的 LEFT JOIN,但执行机制完全不同——这个不同决定了它的代价。

底层机制(比文档深一层):对左侧管道流入的每一个文档,$lookup 取出 localField 的值,到右集合按 foreignField 查找匹配。这是一个嵌套循环:外层遍历左侧 N 个文档,内层每次去右集合查一遍。如果 foreignField 没有索引,内层每次查找都是一次右集合全扫描,整体复杂度 O(N × M)(N 是左侧文档数,M 是右集合大小)。对比关系型——优化器会按数据量和统计信息按代价选择 hash join 或 merge join,复杂度通常是 O(N + M) 量级,而不是乘积。

所以 $lookup 要快,只有三条路:(a) 给 foreignField 建索引,把内层每次全扫描降为索引查找;(b) 在 $lookup 之前用 $match 把左侧 N 缩小,直接砍掉外层循环次数;(c) 多数时候——回到 01 章那条主线(为访问模式建模),干脆把数据嵌入避免 JOIN。这正是为什么 MongoDB 的文档建模反复劝你"别把关系型范式直接搬过来":范式化在一个没有廉价 JOIN 的系统里,等于把代价从写入期推到了每一次读取期。

左侧输入 · N 个文档 右集合 doc 1 · localField doc 2 · localField doc 3 · localField … 共 N 个 foreignField 无索引 每次查找 = 一次全扫描 M 条文档 N 次扫描 → 复杂度 O(N × M)
图 2.2$lookup 的嵌套循环:左侧 N 个文档,每个都对右集合发起一次查找。注意:左侧 N 翻倍,右侧扫描就翻倍——$lookup 的代价随数据量超线性,这是它和 SQL JOIN 最本质的差别。
想一想

同一个 $lookup,把 $match 放在它之前和放在它之后,性能差别有多大?

展开答案

差别可以是数量级。放之前:先把左侧输入从百万缩到几十,嵌套循环的外层次数同比下降,右集合查找只发生几十次。放之后:引擎先对全部百万左文档做完整的嵌套循环 JOIN,再把结果过滤掉绝大部分——白白为最终不要的文档付了 JOIN 代价。结论:能下推到 $lookup 之前的过滤,一定下推;这也是优化器会自动尝试把 $match 前移的原因,但跨 $lookup 的重排并非总能自动完成,手写时就把它放对位置。

2.4查询如何执行:多计划竞速、plan cache、SBE

第一次见到某查询形状,优化器并行试跑多个候选计划选最优,缓存它供后续复用。

为什么需要它

同一个查询往往有多个可用索引、多种执行方式,代价天差地别。关系型靠代价模型 + 统计信息估算选计划;MongoDB 选了一条不同的路——实测。它不光估算,还真跑一小段来比较,这影响你怎么读 explain、怎么理解第一次慢、之后快的现象。

底层机制(比文档深一层):对一个新的查询形状(query shape,filter 结构相同、具体值不同算同一形状),multi-planner 会为每个候选索引各建一个计划,把它们并行试跑一小段 trial period,按"产出最多结果、做最少工作"的标准评出胜者,存入 plan cache。之后相同形状的查询直接复用缓存里的胜出计划,跳过竞速。当集合统计变化、或缓存计划的实际表现退化到阈值之下,会触发 replan,重新竞速。这和关系型的 plan cache / prepared statement plan 扮演同一个角色——把"选计划"的成本摊销到多次执行上。

计划选定后由执行引擎跑。SBE(slot-based execution engine,5.1 引入)把执行计划编译成基于 slot 的算子树,stage 之间通过 slot 传值,避免每个 stage 都物化一份中间 BSON 文档,从而降低 CPU 与内存开销;经典的 classic engine 则在 stage 之间反复物化文档。8.0 还加了 Express 快路径,专门加速最简单的单集合点查(按 _id 或单字段等值),绕过完整的计划流程。对照关系型,这一层等价于把 prepared statement 编译成更紧凑的执行代码。

现状 · 版本陷阱

SBE 的默认状态随版本反复变过:5.1 引入并默认开启,但在 7.0 的某些补丁里因稳定性被默认关闭,直到 8.0(2024-10)才作为稳定默认重新开启。所以"在 5.x 学过 SBE"不代表它当时真在跑你的查询——判断某条查询走没走 SBE,看 explain 输出里的引擎标识,别凭版本号假定。

新查询形状 未命中缓存 候选计划 A · 索引 候选计划 B · 索引 候选计划 C · 全扫描 并行试跑 plan cache 存胜者 · 后续复用 选胜者 统计变化或表现退化 → 触发 replan 重新竞速
图 2.3multi-planner 竞速:新查询形状并行试跑候选计划,胜者进 plan cache。注意:第一次执行多付了竞速代价,之后复用缓存才快——这就是"首次慢、随后快"的来源。
洞见 · plan cache 会过期

plan cache 里的胜出计划是按当时的数据分布选出来的。数据漂移后(比如某个原本高选择性的字段值变得普遍),旧计划不再是最优,却仍被复用,直到表现退化触发 replan。排查"同一条查询昨天快今天慢"时,把 plan cache 失效 / replan 列为头号嫌疑,用 explain 看现在实际走的是哪个计划,而不是假定它还在用你上次看到的那个。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 对一个数组字段写多条件(如 {a: {$gt: 1, $lt: 9}}),它和 $elemMatch 包裹同样条件的判定差别是什么?各自会/不会命中怎样的数组?
  2. 管道里为什么要把 $match 尽量前置?它影响的是下游的什么?
  3. $lookup 在 foreignField 无索引时的复杂度是多少?给出两种把它降下来的具体做法。
  4. (设计题)需求"按月统计每个商品类目的销售额"。描述你的管道 stage 顺序,并说明为什么这样排。
答案(先做完再展开)
  1. 不带 $elemMatch 时,每个条件可由数组里不同元素分别满足——[10, 0] 会命中 {a: {$gt: 1, $lt: 9}}(10>1、0<9 各由一个元素满足),尽管没有元素落在 (1, 9)。$elemMatch 要求同一个元素满足全部条件,[10, 0] 不命中;只有像 [5] 这样含单个落区间元素的才命中。
  2. 因为管道是过程式、按顺序处理文档流,$match 越早,后面每个 stage 要处理的文档数越少——直接决定 $group / $sort / $lookup 的工作量。优化器也会主动尝试把 $match 前移。
  3. O(N × M)(左侧 N 文档 × 右集合 M,每个左文档触发一次右集合全扫描)。降法:(a) 给 foreignField 建索引,把内层全扫描变索引查找;(b) 在 $lookup 之前用 $match 缩小左侧 N。终极做法是建模时嵌入、根本不 JOIN。
  4. 顺序:$match(限定时间窗,先砍输入) → $group(_id 为 {类目, 月份},$sum 销售额) → $sort(按月或销售额排序) → 可选 $project 重塑输出。先 $match 是为了让 $group 只扫该时间窗的文档;分组键放 {类目, 月} 一次成型,避免后续再二次聚合。
进阶挑战 · 刚好够不着

用聚合管道做一个三步漏斗分析

有一个用户事件集合 events,每条形如 {userId, type, ts},type 取值 view / add_cart / purchase。求某时间窗内 view → add_cart → purchase 三步的转化率(每一步相对上一步的留存比例)。先不查资料,写出你的管道 stage 思路。

提示(卡住再展开)

一条主线:$match 限定时间窗(先砍输入)→ $group 按 userId 收集该用户出现过的事件类型($addToSet: "$type")→ $project 用 $in 算出每个用户是否达成各步(三个布尔字段)→ $group(_id: null)对三个布尔分别 $sum 求达成人数 → 末尾 $project 计算 add_cart/view、purchase/add_cart 两个比率。另一种写法:用一个 $facet 同时跑三个计数子管道(各自 $match 一种 type 再 $group 去重计数),一次输入出三个数字,最后在应用层或追加 stage 里相除。两种都对——前者按用户判定更贴"同一用户走完三步"的漏斗语义,后者更简单但算的是各步独立人数。