必须用$dateTrunc标准化时间字段再分组,因MongoDB无原生DATE_TRUNC;按天/周/月需指定timezone和startOfWeek;多粒度统计应使用$facet而非$unionWith;须校验时间字段类型、时区及空值。

按天/周/月分组必须先用 $dateTrunc 或 $dateFromParts 标准化时间字段
MongoDB 不像 SQL 有 DATE_TRUNC 原生语法,直接对 ISODate 字段用 $group + $year/$month 会出错:它只提取年月日数值,不处理时区、跨年周、月末边界等逻辑。真正安全的做法是先用 $dateTrunc(4.0+)把时间截断到目标粒度,再分组。
常见错误现象:$group 里写 { year: { $year: "$created_at" }, month: { $month: "$created_at" } } 看似能分月,但遇到 UTC 和本地时区混用、或 created_at 是字符串类型时,结果错乱且难排查。
- 按天:用
{ unit: "day", date: "$created_at", timezone: "Asia/Shanghai" } - 按周:用
{ unit: "week", date: "$created_at", timezone: "Asia/Shanghai", startOfWeek: "monday" }(注意startOfWeek影响周一/周日为起点) - 按月:用
{ unit: "month", date: "$created_at", timezone: "Asia/Shanghai" }
$unionWith 不是用来替代 $group 的,而是合并多个聚合管道结果
有人看到“按天/周/月分组统计”就下意识想用 $unionWith 把三套分组结果拼一起——这是典型误用。$unionWith 的作用是横向合并不同集合或同一集合的多个聚合输出(类似 SQL 的 UNION ALL),不是做多粒度分组的工具。
真实使用场景只有两种:
– 合并多个集合(如 orders_2023 和 orders_2024)的同结构统计结果
– 对同一集合分别按天、按周跑两套独立 pipeline,再合并在一个响应里返回(前端要同时展示日活+周活)
- 不能在单个 pipeline 里用
$unionWith实现“同一字段按多种粒度分组”,那得靠$facet - 如果真要用
$unionWith,目标集合必须结构一致,字段名、类型都要对齐,否则$group后字段缺失会导致NULL值混入 - 性能影响明显:每个
$unionWith都触发一次完整 pipeline 执行,3 个 union 就是 3 倍扫描开销
多粒度分组推荐用 $facet,而不是拼 $unionWith
想一次性返回按天、按周、按月的统计总数,$facet 是唯一合理选择。它允许你在同一个聚合阶段并发执行多个子 pipeline,共享原始数据流,避免重复扫描。
示例片段(简化):
db.logs.aggregate([
{ $match: { created_at: { $gte: ISODate("2024-01-01") } } },
{
$facet: {
"by_day": [
{ $addFields: { truncated: { $dateTrunc: { unit: "day", date: "$created_at", timezone: "Asia/Shanghai" } } } },
{ $group: { _id: "$truncated", count: { $sum: 1 } } }
],
"by_week": [
{ $addFields: { truncated: { $dateTrunc: { unit: "week", date: "$created_at", timezone: "Asia/Shanghai", startOfWeek: "monday" } } } },
{ $group: { _id: "$truncated", count: { $sum: 1 } } }
],
"by_month": [
{ $addFields: { truncated: { $dateTrunc: { unit: "month", date: "$created_at", timezone: "Asia/Shanghai" } } } },
{ $group: { _id: "$truncated", count: { $sum: 1 } } }
]
}
}
])
-
$facet输出是嵌套文档,应用层需解包by_day/by_week字段,别直接当扁平数组用 - 所有子 pipeline 共享前置
$match过滤,所以千万记得把时间范围条件放在$facet外面,否则每个子 pipeline 都全表扫 - 如果某粒度不需要全部字段(比如按月只要总数不要明细),可在对应子 pipeline 里加
$project减少传输量
时区和字符串时间字段是最大隐形坑
绝大多数线上问题不出在语法,而出在时间字段本身:created_at 是字符串?存的是 UTC 但业务按北京时间算?字段有空值或格式不统一?这些都会让 $dateTrunc 返回 null,导致分组丢失数据。
- 先检查字段类型:
db.logs.findOne({ created_at: { $type: "string" } })—— 如果有结果,必须先用$dateFromString转换 - 用
$dateTrunc时显式指定timezone,哪怕你认为“用默认就够了”,因为 MongoDB 默认是 UTC,而你的业务逻辑大概率不是 - 聚合里加
{ $match: { "created_at": { $exists: true, $ne: null } } }排除空值,避免null被分到同一组干扰统计 - 测试时用
$addFields: { debug: { $dateTrunc: ... } }+$limit: 5看前几条截断是否符合预期,比盲调快得多
时间字段的模糊性会传染整个聚合链路,一旦出错很难定位到源头。宁可多花两分钟验证数据质量,也不要假设“它应该没问题”。










