在处理设备上报、行情tick等时序集合时,采集端可能因为网络抖动漏发某些指标,导致按时间排序的文档里出现字段为空。传统做法是在应用层用变量缓存上次非空值再赋给当前行,但这种逻辑放到数据库内由聚合引擎完成会更省传输量。MongoDB从5.0版本开始提供$locf操作符,全称last observation carried forward,作用就是按指定排序将最近一次非空观测值向前填充到后续缺失位置。

执行原理与排序依赖
$locf并不是一个独立阶段,而是作为$project、$addFields或$set阶段里的表达式来使用。它的计算严格依赖管道中文档的顺序,因此必须在它之前通过$sort阶段明确指定时间字段升序。如果漏掉排序,分片环境下各chunk返回的顺序不确定,填充结果就会错乱。引擎在扫描排序后的文档时,会维护一个按字段路径划分的“上一次有效值”状态机,遇到当前文档目标字段为null或字段不存在时,直接复用状态机里的值。
需要注意的是,$locf只向前看,不会向后填补。也就是说如果第一条文档的字段就为空,那么这条记录依然保持空,因为前面没有观测值可携带。此外,它对数组类型字段也生效,会整体复制上一条的数组引用。当文档跨过不同deviceId时,如果不加分区控制,状态机会把A设备的值错误地填到B设备,所以实际业务中通常配合$group先分桶,或在应用层按设备分别跑管道。
从存储引擎视角看,$locf属于无窗口滑动计算,时间复杂度是O(n)且不需要额外内存落盘,相比$push加$slice自己写表达式要高效很多。但它要求排序阶段已经把数据拉到单一线程处理,如果数据量极大,$sort本身可能触发磁盘溢写,这是使用时要权衡的成本。
与其他填充方案写法对照
MongoDB 5.0同期还提供了$fill阶段,支持method: "locf"。两者语义相同,但写法差异明显。下面用同一份传感器集合演示,集合结构是{_id, device, ts, temp},其中部分temp为空。
使用$locf表达式的写法如下,它在$set里就地补字段:
db.sensor.aggregate([
{ $match: { device: "A" } },
{ $sort: { ts: 1 } },
{ $set: {
temp: { $locf: "$temp" }
} }
]);
而使用$fill阶段的等价写法需要声明输出字段和路径:
db.sensor.aggregate([
{ $match: { device: "A" } },
{ $sort: { ts: 1 } },
{ $fill: {
output: { temp: { method: "locf" } }
} }
]);
从可读性上说,$fill更适合一次填补多个字段,而$locf在只需要补一个字段且已处在复杂$project链中时更顺手。两者在查询结果上完全一致,但$locf不能被partitionBy参数分区,必须自己先用$group或$match切分,这是选型时容易踩的坑。
生产环境性能边界与避坑
在分片集群上跑$locf前,务必确认$sort阶段能下推到各分片并行执行,否则协调节点要把全量数据拉到单点排序,吞吐会骤降。可以通过explain("executionStats")观察是否出现SHARD_MERGE后的大排序。若设备维度就是片键,那么按设备$match后排序基本能本地完成,$locf开销极小。
另一个常见误区是误以为$locf能处理被$unwind打散的数组。实际上$unwind之后文档顺序虽在,但原数组下标丢失,若数组内元素本身有空值,$locf会跨元素填充,这可能不符合“仅按时间向前填”的业务意图。此时应先用$map在数组内部做局部填充,再考虑是否展开。
对于超长时序,建议给{device:1, ts:1}建复合索引,让$match加$sort走覆盖扫描,避免聚合管道因内存超100MB被中止。若历史数据中有大量连续空值且业务允许,也可在写入时由采集端直接带上次值,从源头降低$locf的计算频率。总之把$locf当作轻量清洗算子而非万能补数框架,才能兼顾正确与效率。