有人问过我一个问题MongoDB 里过滤数据不是 find() 加条件就行了吗为什么聚合里还要专门搞一个 $match如果只是把查询条件换个地方写那有什么好讲的我当时没直接回答反手给他看了一段生产环境的聚合管道$project 先投影$unwind 拆数组$group 分组最后才跟了一个 $match 去筛掉多余数据。结果就是几千条文档被拆成了十几万条过滤却放在最后一步执行数据库忙活了半天全是在处理后面要被扔掉的数据。这就是聚合里过滤最容易踩的误区不是在过滤而是在做无用功。这篇文章想认真聊聊 MongoDB 聚合Aggregation里的过滤$match 阶段怎么用、条件怎么写、放在哪个位置性能最好以及我实际项目里遇到过哪些坑。无论你刚学 MongoDB还是已经写过一堆聚合管道应该都能从中捡到一点东西。1. 聚合管道里的过滤到底是谁的活儿1.1 $match 不是普通的查询$match 是聚合管道的一个阶段语法长这样{ $match: { 查询条件 } }内部的查询条件语法和 find() 基本一样等值、比较操作符、逻辑操作符都能用。但要注意$match 和 find() 有两个关键区别。第一个区别是定位不同。find() 是 MongoDB 里独立的查询入口查完就返回结果$match 则是聚合管道aggregation pipeline中的一道工序。管道就像一条流水线文档从一端进来经过 $match、$project、$group、$sort 这些阶段最后从另一端出去。$match 负责在流水线的某个位置拦下符合条件的文档把不符合条件的直接剔除。你可以把它想象成流水线上的安检门货物从传送带过来安检门把不合格的挑出来扔到旁边合格的继续往前走。第二个区别是能力范围。find() 只做过滤加简单投影$match 虽然是过滤但它后面还能接别的阶段。比如你先 $match 过滤出订单金额大于 100 的文档再 $group 按客户分组统计这种组合是 find() 做不到的。一句话$match 是把 find() 的查询条件搬进了聚合管道但它不是简单地搬家因为它在管道里的位置、它前后挂着的阶段都会影响结果和效率。理解这一点你才算真正开始用聚合。1.2 什么时候该想起聚合过滤实际开发中哪些场景会用到聚合里的过滤我总结了三个高频场景。第一数据量大的集合需要先过滤再聚合。比如用户表有几千万条记录想统计 90 后用户的数量如果不对 age 字段先做过滤再做 $groupMongoDB 就得把全部文档读一遍来分组先 $match 掉 age 不在范围内的文档省下来的时间和内存非常可观。尤其在有索引的情况下过滤掉的文档越多收益越大。第二在 $lookup 关联查询时做子管道过滤。比如订单关联用户你只关心有效订单对应的用户信息如果不先在订单表上过滤也不在 $lookup 子管道里过滤$lookup 会把所有订单和所有用户关联一遍数据量直接爆炸。关联查询本来就是聚合里最费资源的操作过滤放前面几乎是必须的。第三分组之后还要按汇总结果过滤。SQL 里的 HAVING 语义在 MongoDB 聚合里就是 $group 后面再放一个 $match。比如只返回月销量超过 1 万的月份需要先按月份分组算出销量再过滤掉没达标的组。这三个场景是聚合过滤存在的理由也是后面几节我要重点展开的内容。2. $match 条件速查不同字段形态的写法不一样2.1 标量字段的过滤写法$match 里最常见的写法就是等值匹配{ $match: { status: PAID } }这个不用多说。需要留意的是 MongoDB 里可以放进 $match 的各种条件操作符。我把常用的整理成一个表格方便你直接抄需求写法说明等值匹配{ $match: { status: PAID } }最基础查状态等于已支付大于某个值{ $match: { amount: { $gt: 100 } } }金额超过 100区间范围{ $match: { amount: { $gte: 100, $lte: 500 } } }金额在 100 到 500 之间闭区间枚举匹配{ $match: { status: { $in: [PAID, PENDING] } } }状态是已支付或待处理模糊查询{ $match: { title: { $regex: /手机/ } } }标题包含手机两个字字段存在{ $match: { remark: { $exists: true } } }备注字段不为空存在这些都是对文档里的普通标量字段做过滤。另外$match 内部会把同一个文档里的多个字段条件自动用 AND 连接所以{ $match: { status: PAID, amount: { $gte: 100 } } }的意思就是状态等于 PAID 且金额大于等于 100不需要额外写 $and。这里插一句我的体会为什么建议把常用条件背下来因为聚合管道的调试成本比 find() 高一个 $match 写错后面 $group、$sort、$project 全白算排查起来相当痛苦。条件语法不熟每次都要翻文档非常打断思路。2.2 数组、嵌套对象与跨字段条件标量字段简单但真实数据里数组和嵌套对象才是常态。嵌套对象比如订单里有 shipping 字段结构是{ address: { city: NORTH } }。想按城市过滤要这样写{ $match: { shipping.address.city: NORTH } }注意这里必须用点号路径而且点号路径整体要用字符串形式不能把 shipping 先 $project 出来再比较那样既麻烦又影响性能。数组字段比嵌套对象更容易踩坑。比如用户集合里有个 tags 字段值为[vip, new_user]不同需求写法不同想找包含 vip 标签的用户{ $match: { tags: vip } }。MongoDB 会自动匹配数组里任意一个元素。想找同时包含 vip 和 new_user 两个标签的用户{ $match: { tags: { $all: [vip, new_user] } } }。注意 $all 的参数仍然是一个数组。想找数组元素满足一组对象条件比如订单 items 里至少有一个商品 name 是手机且数量 qty 大于等于 1{ $match: { items: { $elemMatch: { name: 手机, qty: { $gte: 1 } } } } }。提示数组字段不带 $elemMatch 时多个条件会被拆开独立判断可能出现两个条件分别在不同元素上命中的误匹配务必留意。还有一种更隐蔽的情况是跨字段比较。比如销售集合里有 salesAmount 和 targetAmount 两个字段你想找出销售额超过目标值的记录。$match 本身不支持直接写字段对比必须借助 $expr{ $match: { $expr: { $gt: [ $salesAmount, $targetAmount ] } } }注意 $expr 里引用字段时要加 $ 前缀写成 $salesAmount不然 Mongo 会把它当成普通字符串常量。2.3 一个综合案例我把上面这些规则揉成一个实际例子。假设 orders 集合里存放订单我们要找2024 年 3 月 1 日之后下单、金额在 100 到 1000 之间、收货地址是 NORTH 开头、状态为已支付或已退款的订单。管道写出来是这样db.orders.aggregate([ { $match: { status: { $in: [PAID, REFUNDED] }, shipping.address.city: { $regex: /^NORTH/ }, amount: { $gte: 100, $lte: 1000 }, createTime: { $gte: ISODate(2024-03-01T00:00:00Z) } } } ])所有条件同时满足一个都不能少。如果还想加一条订单里至少有一件商品是手机且数量不低于 2 件只需要在这个 $match 里再加一个 items 的 $elemMatch 条件即可。写综合条件时有个心得能写进一个 $match 的就不要拆成多个 $match。因为每个阶段都有开销而且拆开后如果中间插了 $project 之类的阶段后面那个 $match 可能拿不到原始字段。尽量在管道前段用一个 $match 把原始文档层面的条件一次性定死后面再考虑投影、分组、排序。3. 过滤放对位置性能差一个数量级3.1 能提前就提前管道顺序决定处理量很多人写聚合管道习惯性把 $match 放最后问原因说想把结果先算出来再筛。这个想法在 SQL 里可能没什么大问题但聚合管道不一样——管道是流式的每个阶段处理完一批文档把结果交给下一个阶段。后面每个阶段的输入都是前面所有阶段处理过的结果。举个例子。orders 集合有 10 万条订单管道这样写$project → $unwind → $group → $match。$unwind 会把 items 数组拆开每个商品元素变成一条独立文档10 万订单可能拆出 80 万条。然后 $group 对这 80 万条做分组最后才用 $match 去过滤。MongoDB 白白处理了 80 万条里大部分要被丢弃的数据。如果把这个 $match 放到最前面先筛出符合条件的 1 万条订单$unwind 只会拆出 2 万条$group 处理的数据量直接缩小四五十倍。这就是能提前就提前的意思过滤得越早后续阶段要处理的文档就越少内存占用和响应时间都会明显改善。我的习惯是只要过滤条件不依赖后续阶段计算出来的字段就把它放在管道最前面或尽可能靠前的位置。判断标准很简单——你写的条件字段原始文档里有没有如果有就能提前。只有像 totalAmount 这种在 $group 里用 $sum 算出来的字段才必须放到 $group 之后过滤。3.2 第一个 $match 与索引的关系$match 放在最前面还有一个隐藏好处它可能走索引。MongoDB 聚合优化器在执行前会尝试重排管道如果第一个阶段是 $match它往往会把管道头部的等价过滤条件下推成一次查询走跟 find() 一样的索引选择逻辑。比如你在 createTime 字段上建了索引管道第一个阶段是{ $match: { createTime: { $gte: ... } } }这个 $match 十有八九会走索引扫描的数据量会大大减少。反之如果 $match 被放在 $project、$addFields、$unwind 等阶段之后文档已经被改过、字段已经被投影掉了MongoDB 没法把这个 $match 再下推到索引只能对新生成的临时文档逐个做判断。这就是为什么同样一个查询条件放在管道不同位置性能能差出一个数量级。有个同事的案例让我印象很深一个聚合任务每天跑在几百万条日志上最近越跑越慢。我让他把管道发过来一眼就看到 $match 被写在 $addFields 和 $unwind 之后。当时 $addFields 先给每条日志算了一个解析后的字段$unwind 又拆了数组最后的 $match 等于在索引完全失效的变形数据上做全量判断。后来把 $match 提到最前面只对原有字段做范围过滤任务从十几分钟降到几秒。判断口诀很简单第一个阶段是 $match且条件字段有索引性能就好$match 越靠后离索引越远性能越差。3.3 分组后过滤相当于 HAVINGSQL 里 WHERE 是分组前过滤HAVING 是分组后过滤。MongoDB 聚合里这两个语义都叫 $match唯一区别是放的位置。$group 之前的 $match过滤原始文档对应 WHERE。$group 之后的 $match过滤分组结果对应 HAVING。这个区分特别重要因为很多人在写统计每个城市订单总额只保留总额超过 1 万的城市时会习惯性地把过滤器放在 $group 前面写成{ $match: { totalAmount: { $gt: 10000 } } }。但 totalAmount 是 $group 阶段用 $sum 算出来的新字段原始订单里根本没有。你把这个 $match 放前面MongoDB 找不到 totalAmount结果要么匹配不到要么把条件默默忽略掉。正确写法db.orders.aggregate([ { $group: { _id: $shipping.address.city, totalAmount: { $sum: $amount } } }, { $match: { totalAmount: { $gt: 10000 } } } ])$group 先按城市分组算出总金额后面的 $match 再对这个汇总字段做过滤。注意$group 之后的 $match 基本不可能走索引因为 $group 已经生成了全新的文档结构。所以在设计聚合管道时如果数据量很大你应该在 $group 之前的原始数据上用 $match 尽量缩小范围这样 $group 本身处理的数据就少后面的 HAVING 式过滤只是对已经很小的小集合做二次筛选成本很低。3.4 关联查询子管道里的过滤$lookup 是聚合里最容易产生性能瓶颈的阶段尤其是关联大表。标准的 $lookup 写法是 from localField foreignField但它还能用子管道pipeline做更精细的控制子管道里的 $match 就是在关联之前先过滤右表。比如订单关联用户常规写法是db.orders.aggregate([ { $lookup: { from: users, localField: customerId, foreignField: _id, as: userInfo } } ])这样做的问题是users 表里所有状态为删除、冻结的用户也会被关联进来数据面太大。如果只想关联状态为 ACTIVE 的用户可以用子管道db.orders.aggregate([ { $lookup: { from: users, let: { cid: $customerId }, pipeline: [ { $match: { $expr: { $and: [ { $eq: [ $_id, $$cid ] }, { $eq: [ $status, ACTIVE ] } ] } } } ], as: userInfo } } ])这里 let 定义一个变量 cid值为当前订单的 customerId子管道里用 $$cid 引用。$expr 让管道可以在 $match 里做基于字段的等值比较。最终效果是在 users 集合中先过滤掉非 ACTIVE 用户再完成外键匹配。$lookup 的子管道还支持 $limit、$sort、$project 等阶段等于把一次关联查询做成了一段微型聚合过滤能力相当强。实际项目中我经常把子管道用在这种场景右表很大但只有最新的一条记录有意义或者右表记录需要按时间倒序取最近一条。这些都能在 $lookup 内部解决避免关联出全量数据。4. 四个可直接复用的聚合过滤场景4.1 场景一先过滤再排序分页第一个场景是日志查询很多项目都有类似需求查最近 7 天 level 为 ERROR 的日志按时间倒序取前 100 条。用聚合写是db.logs.aggregate([ { $match: { level: ERROR, ts: { $gte: ISODate(2024-05-01T00:00:00Z) } } }, { $sort: { ts: -1 } }, { $limit: 100 } ])关键点在于 $match 在最前ts 字段上有索引时能快速定位时间范围$sort 在已经缩小的数据上执行压力小$limit 最后截断输出。如果把这个管道反着写先 $sort 再 $match甚至最后才 $limit那排序面对的是整个 logs 集合内存和 CPU 开销都会变大。在数据量大的集合上管道顺序错了查询就从毫秒级变成秒级甚至更糟。4.2 场景二关联之前先缩小数据面第二个场景来自订单系统和用户系统的关联查询。需求是查 2024 年 4 月支付成功的订单并且带出对应的用户昵称。实现思路是先在 orders 表用 $match 过滤掉无关订单再做 $lookupdb.orders.aggregate([ { $match: { status: PAID, createTime: { $gte: ISODate(2024-04-01T00:00:00Z), $lt: ISODate(2024-05-01T00:00:00Z) } } }, { $lookup: { from: users, localField: customerId, foreignField: _id, as: userInfo } }, { $unwind: { path: $userInfo, preserveNullAndEmptyArrays: true } } ])这里 $lookup 的效率跟左侧订单集合的数据量直接相关左侧小关联的数据面就小。$unwind 加上 preserveNullAndEmptyArrays: true 的作用是如果某个订单没匹配到用户订单本身保留userInfo 为空数组展开后变成一个空对象。如果业务上要求必须匹配到用户把 preserveNullAndEmptyArrays 改成 false 即可这样没匹配上的订单会被直接丢弃。4.3 场景三分组后只要满足阈值的组第三个场景是报表统计。需求统计每个城市的销售额只保留月销售额超过 50 万的城市按销售额倒序输出。db.orders.aggregate([ { $match: { createTime: { $gte: ISODate(2024-04-01T00:00:00Z), $lt: ISODate(2024-05-01T00:00:00Z) } } }, { $group: { _id: $shipping.address.city, totalSales: { $sum: $amount } } }, { $match: { totalSales: { $gt: 500000 } } }, { $sort: { totalSales: -1 } } ])注意这里出现了两个 $match位置不同语义完全不同。第一个限定四月份的原始订单属于预处理过滤第二个过滤分组后的 totalSales 字段相当于 HAVING。这种管道在报表场景里几乎是模板级的存在建议直接收藏。还有一个小细节$sort 放在 $match 后面说明排序只针对满足阈值的城市如果城市数量少排序代价很小。如果想把所有城市都排完序再取前 N 名也可以把 $sort 放 $match 前面但那样会多排一批注定被丢弃的数据没必要。4.4 场景四同一条文档内字段之间比较第四个场景是同一文档内字段之间的比较。比如销售集合里有 salesAmount 和 targetAmount找出销售额低于目标值的记录db.sales.aggregate([ { $match: { $expr: { $lt: [ $salesAmount, $targetAmount ] } } } ])$expr 能理解聚合表达式所以你可以做复杂的计算再比较。比如找出金额打八折后仍然超过 1000 的订单db.orders.aggregate([ { $match: { $expr: { $gt: [ { $multiply: [ $amount, 0.8 ] }, 1000 ] } } } ])用 $expr 要有一个心理准备它可能会让索引失效。如果只是普通字段和常量比较最好直接写普通条件只有真正涉及字段计算、字段之间比较时才套 $expr。换句话说能用普通条件表达的过滤就老老实实用普通条件让 Mongo 有机会走索引。5. 我在实际项目中踩过的过滤坑5.1 类型不一致导致查不到有一次业务方通过 CSV 导入一批订单到 orders 集合amount 字段在这个批次里被当成字符串写进了数据库。应用查询用的是{ $match: { amount: { $gte: 100 } } }结果那批订单怎么都查不出来。原因很简单字符串 100 和数字 100 永远不会相等$gte 比较时两个不同类型也没法比这条记录就被过滤掉了。排查方法很简单用一次聚合把字段类型分布打出来db.orders.aggregate([ { $group: { _id: { $type: $amount }, count: { $sum: 1 } } } ])结果会显示 string 和 double 两种类型各有多少条问题一目了然。临时修复可以这样做db.orders.aggregate([ { $addFields: { amount: { $toDouble: $amount } } }, { $match: { amount: { $gte: 100 } } } ])但注意$match 放在 $addFields 之后索引基本失效。所以更彻底的办法是从源头修正数据把集合里这些字符串类型的 amount 统一转成数字类型而不是每次查询都靠 $addFields 硬转。5.2 $match 用不了的操作符$match 的查询条件虽然和 find() 很像但不是 find() 里所有东西都能搬进 $match。最常见的限制是 $where 和 $text。$where 这种 JavaScript 表达式在 $match 阶段会被拒绝至少在默认服务端配置下会报错$text 全文检索虽然可以用但只能放在管道第一个阶段而且必须先建文本索引后面跟着它再放别的阶段才行。另外$match 里不能直接写聚合表达式比如{ $match: { $subtract: [ $end, $start ] } }这种写法是错的必须包在 $expr 里MongoDB 才能按聚合表达式的规则去解析计算。如果你看到某个条件里带 $ 前缀的字段名但又没在 $expr 里那八成是把这个坑踩上了。5.3 排序后再过滤的顺序陷阱有个报表需求是这样的按时间倒序取每个用户最近一条订单。第一反应往往是 $match 用户过滤 → $sort 时间倒序 → $group 取第一条。这个逻辑本身没问题但有两个细节要注意。第一个细节是 $sort 必须发生在 $group 之前。如果你先 $group 再 $sort那只能对分组结果排序得到的是每个用户最上面那条但不一定是时间上最新的那条。正确做法是把 $sort 放在 $group 前面让 $group 对已经排好序的文档取第一条。第二个细节是复合索引。如果你建了{ userId: 1, createTime: -1 }这样的索引并且管道里 $match 在前、$sort 在后MongoDB 优化器可能会调整执行顺序把 $sort 合并进 $match 的查询计划直接利用索引排序。这时候你写的管道顺序跟实际执行顺序不一致属正常现象。但如果 $match 的条件字段和 $sort 字段不在同一个索引里排序可能被放到内存里执行数据量大时会触及内存限制报错。5.4 空值、缺失字段与 $exists 的判断$match 按字段过滤时如果字段缺失文档不会被匹配。举个例子{ $match: { remark: VIP } }只能匹配到 remark 字段存在且值等于 VIP 的文档remark 字段缺失的文档不会因为没有这个字段而参与比较。如果业务上需要字段存在参与过滤用 $existsdb.orders.aggregate([ { $match: { remark: { $exists: true, $ne: null } } } ])这句查的是所有 remark 字段存在且值不是 null 的订单语义非常明确。有个经典误区写{ $match: { remark: null } }想找没有备注的文档结果它会把remark 值为 null和remark 字段不存在两种文档都匹配出来。要精确区分必须配合 $exists。比如只想找字段缺失的文档写成{ $match: { remark: { $exists: false } } }。5.5 正则与索引锚定决定生死最后说一个正则的坑。做模糊查询时{ $match: { name: /测试/ } }这种没有锚定的正则无法利用普通索引只能全表扫描。如果集合数据量大这一个过滤点就能拖垮整个管道。改成前缀锚定正则比如{ $match: { name: /^测试/ } }MongoDB 就能利用字符串索引搜索效率高很多。如果业务上一定要做包含式模糊匹配可以考虑文本索引或者把搜索关键词拆出来单独维护成字段再做等值查询。另外用户输入的内容如果直接拼接进正则要记得转义特殊字符。比如用户输入了一个点号 .它在正则里是通配符不转义就会造成误匹配。尤其做搜索类功能时这一步经常被忽略。写到这里我想说一个长期养成的习惯每次写完聚合管道我会用 explain(executionStats) 看一眼每个阶段的耗时和 docsExamined 数量。重点不是看总耗时而是看有没有阶段处理了大量最后被丢弃的文档。如果 docsExamined 远大于返回结果八成是过滤放太晚了。把过滤尽量往前提这大概是聚合调优里性价比最高的一件事比折腾一堆复杂索引还管用。