$match和$project应前置以提升性能:先用$match快速过滤无效文档并利用索引,再用$addfields+$unset安全对齐多源字段,$lookup前须确保关联集合已清洗完毕,且务必用explain验证真实执行顺序。

$match 和 $project 阶段要前置,否则清洗逻辑可能白做
清洗多来源数据的第一步不是格式转换,而是快速筛掉无效文档。比如你从爬虫、API、日志三个渠道写入的用户数据,字段名不统一(user_id / uid / id),还混着大量 status: "pending" 或空 email 的脏记录。这时候如果先用 $project 做字段重命名,再 $match 过滤,MongoDB 会把所有文档都加载进内存做投影——包括那些根本该被丢弃的测试数据或占位符。
正确做法是:在管道最开头就用 $match 锁定有效范围:
[{"$match": {"email": {"$regex": "@.+"}, "status": {"$ne": "pending"}}}]
这个阶段能直接利用索引(比如在 email 和 status 上建复合索引),大幅减少后续阶段处理量。别指望靠后面加 $unset 或 $addFields 来“补救”——数据一旦进来,就占内存、拖速度、还可能让 $group 出错。
$addFields + $unset 组合比硬编码字段列表更安全
多来源数据字段对齐的核心难点不是“怎么改名”,而是“怎么留活口”。比如你发现 A 渠道用 full_name,B 渠道用 first_name + last_name,C 渠道只传了 nickname。如果直接在 $project 里写死 {"name": "$full_name"},遇到 B 渠道数据就会产出 null。
推荐用两步走:
-
$addFields先统一生成标准字段(用$cond或$ifNull做 fallback) -
$unset再清理原始冗余字段(避免字段爆炸)
示例:
[{"$addFields": {"name": {"$ifNull": ["$full_name", {"$concat": ["$first_name", " ", "$last_name"]}]}}},{"$unset": ["full_name", "first_name", "last_name", "nickname"]}]
这样既保证 name 字段必有值,又不会因为某个来源缺失字段导致整个文档被跳过。注意:$unset 不影响性能,但 $project 显式列出所有要保留字段反而容易漏字段、难维护。
$lookup 关联前必须确保被关联集合已清洗完毕
清洗完主数据后,常需用 $lookup 补充维度信息(比如订单表关联用户表补城市、等级)。但如果你的用户集合本身还带着 status: "draft" 或 created_at: null 的脏数据,$lookup 会把它们也拉进来,污染最终结果——而且这种污染很难在后续阶段识别和剔除。
关键原则:关联操作永远发生在两个「已清洗完成」的数据集之间。这意味着:
- 不要在一个聚合管道里一边清洗用户,一边
$lookup订单;拆成两个独立 pipeline - 被
$lookup的集合,其_id或关联字段必须有索引(否则全表扫描) - 如果关联结果可能为空,用
$unwind: {path: "$user_info", preserveNullAndEmptyArrays: true}控制行为,别依赖默认false
explain 查看实际执行顺序,别信写的顺序
你写了 $project → $match,MongoDB 优化器大概率会把 $match 挪到 $project 前面去——只要过滤条件不依赖投影出的新字段。这本是好事,但如果你在 $project 里做了字段标准化(比如把字符串 "2025-01-01" 转成 Date),然后想用 $match 筛日期,那这个优化就会失败,甚至报错 invalid date。
验证方法只有 1 个:db.collection.aggregate(pipeline, {explain: true})。重点看 executionStages 下的 stage 字段,它显示的是真实执行流,不是你写的顺序。容易被忽略的是:当管道含多个 $match 时,优化器可能拆成三四个新 $match 阶段,分别插在不同投影前后——这时你得检查每个 $match 的过滤键是否真的可提前。











