$match必须放在聚合管道最开头,因其前置才能利用索引加速过滤、避免全量文档进入后续阶段,否则将导致性能陡降、内存溢出或bson超限错误。

为什么 $match 必须放在聚合管道最开头
脏数据清洗的第一步不是改字段名或补默认值,而是快速筛掉根本没资格进后续流程的文档。比如 email 字段为空、status 是 "pending" 或 "draft"、created_at 为 null 的记录,如果先做 $project 或 $addFields,MongoDB 会把它们全加载进内存处理——白占资源,还可能让 $group 报 Exceeded memory limit for $group 错误。
正确做法是用 $match 在第一阶段就卡住入口:
[{"$match": {"email": {"$regex": "@.+"}, "status": {"$ne": "pending"}, "created_at": {"$exists": true, "$type": "date"}}}]
- 这个
$match能直接命中email和status的复合索引,大幅减少后续阶段输入量 -
$type: "date"比只写$exists: true更严格,避免字符串时间被误放行 - 别指望靠后面加
$unset来“清理”——数据一旦进来,就已吃内存、拖速度、干扰统计逻辑
用 $addFields + $unset 安全对齐多源字段
不同来源的数据字段名不统一(如 user_id / uid / id),硬编码 $project 显式列出所有字段容易漏、难维护,且遇到缺失字段直接产出 null,导致下游逻辑断裂。
推荐组合:$addFields 先兜底生成标准字段,再 $unset 清理原始字段:
[{"$addFields": {"user_id": {"$ifNull": ["$uid", {"$ifNull": ["$user_id", "$id"]}]}}},{"$unset": ["uid", "id"]}]
-
$ifNull嵌套可应对三级 fallback,确保user_id字段必有值(哪怕只是null,也比整个字段缺失好处理) -
$unset不影响性能,但能防止字段爆炸——尤其当某来源突然多传一个temp_id时,不会污染最终结构 - 别在
$project里写死{"user_id": "$uid"},这种写法在 B 渠道没uid时会让整条文档的user_id变成null,而你可能根本没意识到
如何标记异常记录而不是直接丢弃
生产环境清洗不能简单 $match 过滤掉脏数据,得保留上下文用于审计和归因。常见错误是把清洗和标记混在一起,结果既没清理干净,又没留下线索。
可行方案是:用 $set 新增 clean_status 字段,分类打标:
[{"$set": {"clean_status": {"$switch": {"branches": [{"case": {"$and": [{"$eq": ["$email", ""]}, {"$eq": ["$status", "pending"]}]}, "then": "empty_email_pending"}, {"case": {"$eq": ["$created_at", null]}, "then": "missing_created_at"}, {"case": {"$not": {"$regexMatch": {"input": "$email", "regex": "@.+"}}}, "then": "invalid_email"}], "default": "clean"}}}}]
-
$switch支持多条件分支,比堆$cond更清晰,也方便后续按clean_status聚合统计各类异常占比 - 标记后不删数据,后续可用
db.collection.find({"clean_status": {"$ne": "clean"}})单独查异常集,或导出给 QA 复核 - 注意:
$regexMatch比$regex更可控,不会意外触发全表扫描(尤其配合索引时)
大集合清洗必须加 allowDiskUse:true
当清洗涉及 $group(比如查重复)、$sort 或复杂 $lookup 时,很容易触发 Exceeded memory limit for $group 错误。这不是代码写错了,是 MongoDB 默认每个 stage 内存上限 100MB,且不自动启用磁盘溢出。
必须显式传参:
db.collection.aggregate(pipeline, {allowDiskUse: true})
- 不加这个参数,
$group遇到百万级数据基本必崩;加了之后,MongoDB 会把溢出数据写入临时文件,代价是慢一点,但能跑通 - 别只在测试环境加——线上同样要加,否则上线后第一次跑清洗任务就失败
- 配合
maxTimeMS使用更稳妥:{allowDiskUse: true, maxTimeMS: 300000},防止单次清洗卡死太久
真正难的不是写出能跑的管道,而是判断哪些字段该进 $match、哪些异常该打标而非过滤、以及什么时候必须开 allowDiskUse ——这些决策点藏在数据分布和业务容忍度里,没法靠模板解决。











