MongoDB无法仅靠$group+$dateToString实现同比,因分组后各期数据隔离,无法同文档内计算差值或比率;必须用$facet并行提取两期数据再合并,或用$lookup关联自身集合实现动态对比。

MongoDB 本身不提供开箱即用的同比(YoY)或环比(MoM)函数,必须靠时间字段偏移 + 多阶段关联实现;直接用 $dateToString 提取年月再分组只能拿到单期数据,没法自动对比——得自己“拉出两期并排”,再用 $lookup 或 $facet 拼接。
为什么不能只靠 $group + $dateToString 做同比?
因为 $group 是单次聚合,输出的是独立时间桶(如 "2024-06"、"2025-06"),但它们彼此隔离,无法在同一个文档里放在一起做减法或除法。同比需要“当前月”和“去年同期”两个值出现在同一行,才能算增长率。
- 错误做法:只用
$dateToString分组后排序,再靠应用层手动配对——容易漏掉某期无数据的情况(比如去年6月没订单,结果集根本没那条) - 正确思路:把“当前周期”和“对比周期”的数据分别提取出来,再通过字段名对齐或
$lookup关联 - 关键限制:MongoDB 不支持跨文档的算术运算,所有同比计算必须发生在同一个文档内
用 $facet 实现同文档内同比(推荐用于中小数据量)
$facet 允许你在一次聚合中并行执行多个子管道,并把结果合并到一个文档的字段下。这是最干净的同比写法,避免了 $lookup 的集合扫描开销。
- 适用场景:统计维度固定(如按月)、对比周期明确(如今年6月 vs 去年6月)
- 核心技巧:两个子管道分别加
$match过滤不同时间段,再统一$group成单个键(如_id: "2025-06"),最后用$project提取并计算 - 示例(月度销售额同比):
[{
"$facet": {
"current": [{
"$match": { "createdAt": { "$gte": ISODate("2025-06-01"), "$lt": ISODate("2025-07-01") } }
}, {
"$group": { "_id": "2025-06", "amount": { "$sum": "$amount" } }
}],
"lastYear": [{
"$match": { "createdAt": { "$gte": ISODate("2024-06-01"), "$lt": ISODate("2024-07-01") } }
}, {
"$group": { "_id": "2024-06", "amount": { "$sum": "$amount" } }
}]
}
}, {
"$project": {
"_id": "2025-06",
"currentAmount": { "$arrayElemAt": ["$current.amount", 0] },
"lastYearAmount": { "$arrayElemAt": ["$lastYear.amount", 0] },
"yoyRate": {
"$cond": {
"if": { "$eq": [{ "$arrayElemAt": ["$lastYear.amount", 0] }, 0] },
"then": null,
"else": {
"$divide": [
{ "$subtract": [
{ "$arrayElemAt": ["$current.amount", 0] },
{ "$arrayElemAt": ["$lastYear.amount", 0] }
]
},
{ "$arrayElemAt": ["$lastYear.amount", 0] }
]
}
}
}
}
}]
- 注意:
$arrayElemAt是因为$facet输出是数组(即使只有一条),必须取第 0 项 - 时区问题仍存在:确保
createdAt是Date类型,且查询时间范围用ISODate()显式指定,避免字符串隐式转换偏差
用 $lookup 做动态环比(适合按周/月滚动计算)
当你要算“本月 vs 上月”这种动态对比,且月份不固定时,$facet 就得每次改管道。这时更灵活的方式是:先用 $addFields 计算出“对比时间点”,再 $lookup 关联自身集合。
- 关键步骤:用
$dateFromParts或$dateSubtract(MongoDB 5.0+)构造上月日期,再作为localField去查 - 示例(订单表 orders 中每个订单带“上月同期金额”字段):
[{
"$addFields": {
"lastMonthDate": {
"$dateFromParts": {
"year": { "$year": "$createdAt" },
"month": { "$subtract": [{ "$month": "$createdAt" }, 1] },
"day": 1
}
}
}
}, {
"$lookup": {
"from": "orders",
"let": { "refDate": "$lastMonthDate" },
"pipeline": [{
"$match": {
"$expr": {
"$and": [
{ "$gte": ["$createdAt", "$$refDate"] },
{ "$lt": ["$createdAt", { "$dateAdd": { "startDate": "$$refDate", "unit": "month", "amount": 1 } }] }
]
}
}
}, {
"$group": { "_id": null, "total": { "$sum": "$amount" } }
}],
"as": "lastMonth"
}
}, {
"$addFields": {
"lastMonthAmount": { "$ifNull": [{ "$arrayElemAt": ["$lastMonth.total", 0] }, 0] }
}
}]
- 坑点:MongoDB 4.4 不支持
$dateAdd,得用$dateFromParts手动拼;4.4 以下版本甚至不支持$lookuppipeline 中的$expr,必须降级为固定时间范围匹配 - 性能警告:
$lookup对大集合会显著拖慢,务必确保createdAt有索引,且 pipeline 中的$match能命中索引
环比结果补零与空值处理最容易被忽略
真实业务中,“上月没数据”比“本月没数据”更常见,但多数人只处理了除零,没处理缺失关联结果。
-
$lookup返回空数组 →$arrayElemAt取null→ 后续$sum或$divide会出null,不是 0 - 必须显式用
$ifNull或$cond转成 0,否则前端计算增长率时null / 100还是null,而不是 0% - 更隐蔽的问题:时区错位导致“本该匹配的上月数据”实际落在其他月——比如你用
"Asia/Shanghai"存时间,但$dateFromParts默认按 UTC 构造,结果查的是 UTC 上月,而非本地时区上月











