在 MongoDB 中处理时间序列数据时,经常会遇到某个时间点没有记录的情况。例如物联网设备可能因为网络抖动每隔几分钟才上报一次,或者业务日志在低峰期完全没有写入。如果直接使用 $group 按固定时间间隔统计,这些缺失的时间桶会直接消失,导致图表出现断裂。聚合管道中的 $densify 阶段正是为了补齐这些缺口而设计的,它可以在指定的数值或日期字段上按照固定步长生成新文档,并把原始文档中没有覆盖到的点补充进来,为后续计算提供连续的时间轴。

$densify 的语法与参数拆解
$densify 是 MongoDB 5.1 版本引入的聚合管道阶段,主要用于处理时间序列或有序数值数据。它的基本语法包含一个必填的 field 字段和一个 range 范围描述对象。field 指定要在哪个字段上执行填充,该字段必须是数值类型或日期类型。常见的做法是传入时间戳字段,例如 ts 或 createdAt。
range 参数决定填充的范围和步长。它通常包含 step 和 unit,前者表示步长大小,后者表示步长单位。对于日期字段,unit 可以取 millisecond、second、minute、hour、day、week、month、quarter、year。对于普通数值字段,可以省略 unit,只指定 step 即可。除了 step 和 unit,range 还支持通过 bounds 指定一个闭区间数组,控制从哪个下界开始填充,到哪个上界结束。如果不显式给 bounds,则可以传入字符串 full 或 partition,前者使用整个字段的最小值和最大值作为边界,后者使用每个分区内的最小值和最大值作为边界。
另一个重要参数是 partitionByFields,它允许按指定字段对文档进行分组,并在每个分组内独立执行填充操作。例如一个集合里同时包含多个传感器的数据,如果不加分区,$densify 会把所有设备的时间戳混在一起处理,导致 A 设备的缺失时间被 B 设备的数据影响。通过设置 partitionByFields: ["sensorId"],每个传感器会各自获得连续的时间轴。
填充缺失的时间序列数据实战
假设有一个 sensor_readings 集合,用来存放温度传感器每分钟上报的数据。某台设备在凌晨因为网络故障漏掉了几分钟记录,原始数据大致如下:只在 00:00、00:03、00:05 三个时间点有温度读数。如果业务需要按分钟统计平均温度,直接聚合会丢掉 00:01、00:02 和 00:04 三个时间桶,前端画出的曲线会出现明显断层。
此时可以在聚合管道中插入 $densify 阶段,指定在 ts 字段上按每分钟生成完整时间序列。下面的代码演示了从 00:00 到 00:05 的填充过程:
db.sensor_readings.aggregate([
{
$densify: {
field: "ts",
range: {
step: 1,
unit: "minute",
bounds: [
ISODate("2024-01-01T00:00:00Z"),
ISODate("2024-01-01T00:05:00Z")
]
}
}
}
])
执行上述管道后,原本只有 00:00、00:03 和 00:05 三条文档的结果集中会新增 00:01、00:02 和 00:04 三个时间点。需要注意的是,这些新生成的文档只包含 ts 字段以及分区字段,其他业务字段并不会自动出现。也就是说,输出中多了几个只有时间戳、没有温度值的文档。这正是 $densify 的核心职责:只负责把时间轴填满,并不负责填充分业务值。
如果原始数据中已经有重复时间戳,$densify 不会自动去重,重复时间点会原样保留。因此对于存在重复记录的场景,建议在填充之前先使用 $group 或其他方式去重,避免后续窗口计算出现歧义。另外,bounds 定义的是一个闭区间,填充从下界开始按 step 递增,直到不超过上界。如果区间长度不是步长的整数倍,最后一个生成点会小于上界。
与 $fill 配合处理空值
仅靠 $densify 生成的时间点,除了时间字段外其他字段基本都是空的。业务上通常需要把这些空值补齐,例如用前一条记录的读数填充,或者根据前后读数进行线性插值。MongoDB 在 5.3 版本引入了 $fill 阶段,可以接着 $densify 使用,形成一个“补齐时间轴 + 填充空值”的常见两段式管道。
下面的示例在 $densify 之后立即使用 $fill,按 ts 升序排列,并对 temp 字段采用 locf 方法,即用最近一次非空值向后填充。这样 00:01 和 00:02 会继承 00:00 的温度值,00:04 会继承 00:03 的温度值。
db.sensor_readings.aggregate([
{ $match: { sensorId: "A" } },
{
$densify: {
field: "ts",
range: {
step: 1,
unit: "minute",
bounds: [
ISODate("2024-01-01T00:00:00Z"),
ISODate("2024-01-01T00:05:00Z")
]
}
}
},
{
$fill: {
sortBy: "ts",
output: {
temp: { method: "locf" }
}
}
}
])
如果希望获得更平滑的曲线,还可以把 method 改为 linear,让 MongoDB 按照时间距离在两个已知读数之间进行线性插值。不过需要注意,线性插值要求每个区间两侧至少各有一个非空值,否则该区间可能仍然保持为空。此外,$fill 中的 sortBy 字段必须与 $densify 使用的字段一致,否则填充顺序会出错。实际项目中也可以使用 value 方式填充固定默认值,例如把缺失温度统一填为 0 或 Null,再由下游系统自行处理。
这种组合方案尤其适合实时监控大盘、设备运维报表以及用户行为漏斗分析等场景。先通过 $densify 保证时间维度的完整性,再通过 $fill 保证业务指标没有空洞,后续的 $group 聚合结果就会更加稳定和连续。
分区填充与性能注意事项
当集合中包含多个传感器或多个租户的数据时,不能简单地对整个数据集执行 $densify。例如设备 A 在 00:00 开始上报,设备 B 在 00:10 才开始上报,如果统一填充,A 和 B 的时间范围会互相干扰,生成大量无意义的时间点。正确做法是使用 partitionByFields 指定分区字段,让每个传感器独立填充自己的时间范围。
db.sensor_readings.aggregate([
{
$densify: {
field: "ts",
partitionByFields: ["sensorId"],
range: {
step: 1,
unit: "minute",
bounds: "partition"
}
}
}
])
上述代码中,bounds 设为 partition,表示每个 sensorId 分区内部使用各自 ts 字段的最小值和最大值作为填充边界。这样设备 A 只会补全自己的时间缺口,不会凭空生成设备 B 所在时间段的内容。如果希望更精细地控制每个设备的填充区间,也可以把 bounds 写成显式数组,或者在 $densify 之前使用 $match 先过滤出目标设备。
性能方面需要特别小心。$densify 会在内存中展开大量文档,如果时间跨度很大而步长很小,输出文档数量会迅速膨胀。例如对一整年的数据按秒级填充,理论上会生成约 3153 万条文档,即使原始数据只有几千条,也可能导致内存吃紧、管道执行缓慢。因此在使用时应尽量缩小 bounds 范围,或者先通过 $match 过滤无关数据。对于月度和年度这种长度不固定的日期单位,MongoDB 会按照日历边界来处理,而不是简单地把每月当成固定天数,使用时需要注意这一点。
另一个常见的坑是时区问题。MongoDB 内部以 UTC 存储日期,如果业务按照本地时间进行天级或月级填充,直接使用 $densify 可能会在边界处产生偏差。建议在填充前先把时间字段转换到业务时区,或者在聚合管道中使用 $dateTrunc 等日期处理阶段统一口径。总之,$densify 是构建连续时间序列的重要工具,但只有在正确设置边界、分区和步长的情况下,才能兼顾正确性与执行效率。