审计日志过滤必须将$match置于管道最前端,因ts为ISODate类型、atype大小写敏感,且需避免$expr导致索引失效;须显式$project精简字段,防止BSON超限。

审计日志过滤必须用 $match 放在管道最前面
审计日志集合(如 auditLog)通常按时间顺序写入,但不加约束地聚合会导致全表扫描——尤其当日志已存数月、文档超千万时,aggregate() 会卡住甚至超时。关键不是“能不能筛”,而是“在哪筛”。$match 必须是第一个阶段,且字段要对应真实存储类型:审计日志中的 ts 字段是 ISODate,不是字符串;atype 是字符串,但大小写敏感(如 "update" ≠ "Update")。常见错误是把 $match 放在 $project 或 $addFields 后面,结果优化器无法提前剪枝,内存占用飙升。
正确写法示例:
[
{ "$match": {
"ts": { "$gte": { "$dateFromString": { "dateString": "2026-08-01T00:00:00Z" } } },
"atype": { "$in": ["update", "delete", "find"] }
}},
// 后续阶段才开始加工
]- 别用
"$expr"包裹时间比较(如{"$expr": {"$gte": ["$ts", ...]}}),索引失效 - 如果日志中
param字段是嵌套对象(如 find 操作的查询条件),$match仍可直接点取:"param.filter.user_id",但需确认该路径存在 - 6.0+ 版本支持
$dateFromString直接转字符串时间,避免手拼ISODate()字符串出错
修剪字段要用 $project 显式保留,别依赖自动优化
MongoDB 6.0 的聚合优化器虽能自动下推部分投影,但它不会帮你删掉审计日志里那些体积巨大的冗余字段,比如完整的 param 对象(可能含 BSON 文档快照)、remote 的完整 IP 地址结构、或重复的 local 信息。不显式 $project,这些字段会一路传到结果端,拖慢网络传输、撑爆应用内存,还可能触发 16MB BSON 限制。
安全做法是只留必要字段:
[
{ "$match": { /* ... */ } },
{ "$project": {
"_id": 0,
"ts": 1,
"atype": 1,
"ns": 1,
"user": "$params.user",
"filter": "$param.filter",
"nMatched": "$param.nMatched"
}}
]-
"_id": 0必须显式关掉,否则默认带上,而审计日志的_id通常是 ObjectId,无业务意义 - 字段名大小写要和实际日志一致:
"param"和"params"是两个东西;6.0 审计日志字段名是param,不是params - 如果
param可能为null(如某些 authCheck 不带参数),用$ifNull避免整个文档被丢弃:"filter": { "$ifNull": ["$param.filter", {}] }
$facet 适合一次查多维统计,但每个子管道都受 16MB 限制
清理历史审计日志不只是“删旧数据”,常需先看清分布:比如查过去 7 天里,哪个 ns 被修改最多?哪些 user 触发了高频 delete?这时发多个聚合请求既慢又难对齐时间窗口。$facet 是唯一解,但它不是“放大器”,而是“并行沙盒”——每个子管道独立执行,也独立受 16MB 结果大小限制。
典型结构:
[
{ "$match": { "ts": { "$gte": { "$dateFromString": { "dateString": "2026-08-31T00:00:00Z" } } } } },
{ "$facet": {
"top_namespaces": [
{ "$group": { "_id": "$ns", "count": { "$sum": 1 } } },
{ "$sort": { "count": -1 } },
{ "$limit": 10 }
],
"top_users": [
{ "$match": { "user": { "$exists": true } } },
{ "$group": { "_id": "$user", "count": { "$sum": 1 } } },
{ "$sort": { "count": -1 } },
{ "$limit": 10 }
]
}
}
]- 每个子管道末尾必须加
$limit,否则某类统计(如某个ns日志量极大)可能直接爆 16MB 导致整个$facet失败 -
$facet不能替代索引——它只是让多次$match共享一次过滤结果,底层仍靠ts字段索引加速 - 6.0 中
$facet子管道不支持$out或$merge,想落地结果得靠应用层拆包处理
真正容易被忽略的是 TTL 索引和时间字段类型混用
很多人以为“聚合完再 deleteMany 就完事”,但审计日志的清理逻辑往往卡在两处:一是没给 ts 字段建 TTL 索引,导致 deleteMany({ ts: { $lt: ... } }) 变成全表扫;二是聚合里用字符串比时间,比如 "ts": "2026-08-01",而实际字段是 ISODate,结果匹配不到任何文档。MongoDB 不会自动类型转换,$match 里类型错,就等于没写条件。
务必检查:
- 是否存在
db.auditLog.getIndexes()返回的{ ts: 1 }索引,且带expireAfterSeconds(如 2592000 表示 30 天) - 聚合中所有时间比较都用
$dateFromString或ISODate()构造,别用字符串字面量 - 如果审计日志来自分片集群,
mongos不记录,得连到各shard节点单独跑聚合,ts字段在不同节点上可能有毫秒级偏移,用$dateToString统一到分钟粒度再分组更稳


















