22. MongoDB 时序集合与 IoT 数据实践

MongoDB Time Series Collection 时序集合的创建、桶化与压缩机制、时序聚合查询、降采样管道,以及与 InfluxDB 的架构对比和 IoT 实战案例

物联网设备、APM 指标、金融行情、日志流,几乎每一个现代系统都在以稳定速率产生时间戳驱动的数据。传统文档集合存储这类数据有两个天然痛点:单条文档体积小但数量极大,索引与文档头带来的存储开销占比较高;写入模式是纯追加(append-heavy),却要承担与事务型数据相同的随机写成本。MongoDB 5.0 引入的时序集合(Time Series Collection)正是针对这一场景的专门优化:它在文档模型之上叠加了面向时间的桶化(bucketing)、列式压缩与生命周期管理,让开发者无需引入额外的时序数据库即可支撑大规模 IoT 工作负载。

本文将从时序集合的内部结构出发,结合可运行的 mongo shell 命令,系统讲解时序集合的创建、字段设计、聚合查询与降采样,并与普通集合和 InfluxDB 做横向对比。

1. 为什么需要时序集合

时序数据的典型特征是三高一低:写入速率高、总量增长快、按时间范围读取频繁,而单条数据的修改概率极低。传统集合与专用时序集合在此类负载下的表现差异巨大。

特性普通集合(普通 Collection)时序集合(Time Series Collection)
数据组织每文档独立存储按时间自动桶化,桶内列式存储
写入放大每文档一条 journal + 索引记录桶内批量更新,索引写入显著减少
存储压缩可选块级压缩(zstd/snappy)内置列式压缩,压缩比通常 5~10x
时间范围删除逐条 deleteMany,代价高基于 expireAfterSeconds 自动过期
二级索引全字段可建时间字段/ meta 字段可建,measurement 字段有限支持
适合负载通用读写高频追加 + 时间范围扫描

重要:时序集合不是"另一种数据库",它仍然是普通文档集合之上的专门存储引擎实现。因此 find、aggregate、insert 等 API 完全兼容,学习成本远低于引入独立时序数据库。

对于时序负载,最大的隐藏成本是索引放大。假设每 5 秒写入 1000 台设备的温度数据,普通集合为支持 { deviceId: 1, ts: -1 } 查询需要为每一条测量值维护索引条目,写入放大显著;时序集合将一桶数据作为内部一条记录维护,索引条目按桶计量,写入放大可以降低一到两个数量级。

2. 创建时序集合与内部结构

创建时序集合时必须在 createCollection 中显式指定 timeseries 选项,其中最核心的三个参数是 timeField、metaField 与 granularity。

// 创建时序集合:记录 1000 台设备的温度/湿度/电量
db.createCollection("device_metrics", {
  timeseries: {
    timeField: "ts",           // 必须存在,类型为 Date
    metaField: "deviceId",     // meta 字段,用于桶内分组
    granularity: "seconds"     // 写入间隔预估:seconds | minutes | hours
  },
  expireAfterSeconds: 60 * 60 * 24 * 90  // 90 天后自动删除
})

timeField 是文档中保存时间戳的字段名,它隐式作为集合的第一个排序键,也是 TTL 生效的字段。metaField 是"元数据"字段,用来标记同源数据(如设备 ID、机房、应用名),MongoDB 会以 metaField 值相同的数据尽量聚合到同一桶,从而大幅提升压缩率。

granularity 表达的是写入时间间隔的粗略量级,它决定了桶的最大跨度(bucket max span)的默认值,直接影响内存中的活跃桶数量与压缩效果。

granularity默认最大桶跨度典型场景
seconds15 分钟高频 IoT 遥测、行情 tick
minutes1 小时APM 指标、日志聚合
hours1 天日粒度报表、能源计量
// 查看集合的 timeseries 配置
db.runCommand({ listCollections: 1, filter: { name: "device_metrics" } })
  .cursor.firstBatch[0].options.timeseries
// {
//   "timeField": "ts",
//   "metaField": "deviceId",
//   "granularity": "seconds",
//   "bucketMaxSpanSeconds": 900,
//   "bucketRoundingSeconds": 900
// }

注意:timeField 和 metaField 一经创建不可修改;granularity 可通过 collMod 调粗(seconds→minutes→hours),但不能调细。设计阶段务必先评估写入频率再决定初始值。

3. measurement / meta / timestamp 字段设计

时序文档由三类字段组成:时间戳字段(timestamp)、元数据字段(meta)与测量值字段(measurement)。这三者的划分决定了桶化与查询的效率。

// 良好的字段划分示例
db.device_metrics.insertMany([
  {
    ts: ISODate("2026-09-27T08:00:00.000Z"),
    deviceId: { sn: "SN-1001", rack: "A-12" },   // meta:高基数但分组稳定
    temp: 36.5,   // measurement:同一设备内的测量值
    humidity: 58.2,
    voltage: 3.72
  },
  {
    ts: ISODate("2026-09-27T08:00:05.000Z"),
    deviceId: { sn: "SN-1001", rack: "A-12" },
    temp: 36.7,
    humidity: 58.0,
    voltage: 3.71
  }
])
角色字段设计原则查询影响
timestampts使用服务器标准时间,避免本地时区偏差;精度统一驱动桶化边界,隐式主排序键
metadeviceId值尽量稳定、基数适中(数千~数十万)同值聚合到同一桶,索引与压缩效率高
measurementtemp/humidity/...同源设备保持字段结构一致列式压缩后按桶读取

meta 字段的"稳定"很重要。如果 meta 值随每条测量变化(例如把随机 requestId 放进去),MongoDB 无法把连续数据聚合到同一桶,退化为每文档一桶,压缩率与查询性能都会明显下降。同理,meta 字段的基数也不宜过高——基数过百万时,活跃桶数量会占用大量内存。

// 反面示例:把时间戳放进 metaField,破坏桶化(绝不这样做)
db.createCollection("bad_metrics", {
  timeseries: { timeField: "ts", metaField: "requestId", granularity: "seconds" }
})

经验法则:metaField 应当承载"查询时最常见的等值过滤条件",measurement 字段承载"需要在时间窗口内聚合的数值"。设计错误后无法就地修改,只能新建集合迁移。

4. 桶化(Bucketing)与压缩机制

时序集合在物理层面会把多份原始测量值打包进一个"桶(bucket)“文档。桶不是用户可见的文档,而是存储引擎内部按 timeField 时间跨度与 metaField 值组织的存储单元。

桶内的存储形式是列式布局:同一个 measurement 字段(如所有 temp 值)连续存放,配合数值差异编码与压缩算法,获得远高于行式存储的压缩率。

// 通过内部视图观察桶的数量与跨度(system.buckets 视图,生产环境慎用)
db.getSiblingDB("admin").command({ listCollections: 1, filter: { type: "timeseries" } })

// 使用 $listSearchIndexes 之外的 explain 观察查询是否命中桶裁剪
db.device_metrics.explain("executionStats").aggregate([
  { $match: { deviceId: { sn: "SN-1001" }, ts: { $gte: ISODate("2026-09-27T08:00:00Z"), $lt: ISODate("2026-09-27T09:00:00Z") } } }
])

MongoDB 6.3 之后还支持通过 bucketMaxSpanSeconds 与 bucketRoundingSeconds 显式控制桶边界:

db.createCollection("wind_speed", {
  timeseries: {
    timeField: "ts",
    metaField: "stationId",
    granularity: "seconds",
    bucketMaxSpanSeconds: 60,     // 每桶最多覆盖 60 秒
    bucketRoundingSeconds: 60     // 桶边界对齐到整分钟
  }
})

桶跨度越小,写入越"即时”(测量值尽快可读),但活跃桶数量更多、压缩率更低;桶跨度越大,压缩与写入放大更优,但最近窗口内的数据可能尚未落盘到稳定桶,极端情况下影响刚写入数据的读取。实际场景应让 bucketMaxSpanSeconds ≥ 平均写入间隔 × 100 以上,避免每个桶只装几条数据。

关注维度桶跨度小桶跨度大
活跃桶内存占用高低
存储压缩率低高
刚写入数据的可读性好稍差
写入放大高低

5. 时序查询与聚合

时序集合支持完整的 find 与 aggregate API。时间范围 + meta 等值过滤是最常见的查询模式,MongoDB 会自动裁剪到相关桶,避免扫描无关数据。

// 查询某设备最近 10 分钟的温度(自动走桶裁剪 + 时间字段索引)
db.device_metrics.find({
  "deviceId.sn": "SN-1001",
  ts: { $gte: new Date(Date.now() - 10 * 60 * 1000) }
}).sort({ ts: 1 }).limit(120)

// 按 5 分钟粒度计算平均温度与最大湿度
db.device_metrics.aggregate([
  { $match: { "deviceId.sn": "SN-1001", ts: { $gte: ISODate("2026-09-27T08:00:00Z") } } },
  { $group: {
      _id: { $dateTrunc: { date: "$ts", unit: "minute", binSize: 5 } },
      avgTemp: { $avg: "$temp" },
      maxHumidity: { $max: "$humidity" },
      count: { $sum: 1 }
  }},
  { $sort: { _id: 1 } }
])

$dateTrunc 是时序聚合中最常用的分组算子,它按指定单位与 binSize 把时间戳对齐到桶边界。相比逐条 $group 时间戳,它天然产生规整的时间序列,便于下游图表绘制。

对稀疏、缺失的时序数据,可以使用 $densify 补齐时间轴,再用 $fill 填充空值:

db.device_metrics.aggregate([
  { $match: { "deviceId.sn": "SN-1001", ts: { $gte: ISODate("2026-09-27T08:00:00Z"), $lt: ISODate("2026-09-27T09:00:00Z") } } },
  { $densify: { field: "ts", range: { step: 60 * 1000, unit: "millisecond", bounds: [ISODate("2026-09-27T08:00:00Z"), ISODate("2026-09-27T09:00:00Z")] } } },
  { $fill: { output: { temp: { method: "linear" } }, sortBy: { ts: 1 } } }
])

$densify 会在缺失的时间点上插入文档,$fill 提供 linear(线性插值)与 last(向前填充)等方法,两者配合即可生成无断点的趋势图数据。

6. 降采样(Downsampling)与数据生命周期

时序数据的价值随时间递减:最近的数据需要秒级粒度,数月前的数据只需要小时级或日级粒度。降采样(downsampling)就是把细粒度数据聚合成粗粒度汇总,从而控制存储成本。降采样在 MongoDB 中就是普通的聚合管道 + 结果落库:

// 每小时降采样:将原始 5 秒数据汇总为小时均值
db.device_metrics.aggregate([
  { $match: { ts: { $gte: ISODate("2026-09-01T00:00:00Z"), $lt: ISODate("2026-09-07T00:00:00Z") } } },
  { $group: {
      _id: { device: "$deviceId", hour: { $dateTrunc: { date: "$ts", unit: "hour" } } },
      avgTemp: { $avg: "$temp" },
      minTemp: { $min: "$temp" },
      maxTemp: { $max: "$temp" },
      samples: { $sum: 1 }
  }},
  { $merge: { into: "device_metrics_hourly", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
])

降采样结合 expireAfterSeconds 可以构建经典的"多级保留"策略:

数据层级集合粒度保留周期
原始数据device_metrics5 秒7 天(expireAfterSeconds)
小时汇总device_metrics_hourly1 小时90 天
日汇总device_metrics_daily1 天5 年

时序集合的过期删除基于 timeField 自动进行,不需要手动 deleteMany。需要特别说明的是,MongoDB 6.0 起时序集合支持设置 expireAfterSeconds 并通过 collMod 调整:

db.runCommand({
  collMod: "device_metrics",
  timeseries: { expireAfterSeconds: 60 * 60 * 24 * 7 }  // 改为保留 7 天
})

注意:不要对原始时序集合做频繁的大范围 deleteMany,删除操作会破坏桶结构并放大写入成本。统一用 TTL 到期删除,或用 $out/$merge 落降采样结果到独立集合。

7. 索引与性能调优

时序集合的索引策略与普通集合不同。timeField 永远被隐式索引;开发者主要需要为 meta 字段建立二级索引,以便等值过滤快速裁剪桶。

// 为 meta 字段创建二级索引(复合:meta + 时间)
db.device_metrics.createIndex({ "deviceId.sn": 1, ts: -1 })

// 查看索引
db.device_metrics.getIndexes()
// [ { v: 2, key: { "deviceId.sn": 1, ts: -1 }, name: "deviceId.sn_1_ts_-1" }, ... ]

关于 measurement 字段的索引:时序集合对测量值字段的二级索引支持随版本逐步完善,较新版本(6.x 系列起)已允许对测量值字段创建部分索引。但设计上更推荐依赖桶裁剪 + 聚合来读取测量值,而不是为每个测量值字段建索引——后者会显著抵消列式压缩带来的存储收益。

// 对 measurement 字段建部分索引(示例:仅对关键测量值字段,慎用)
db.device_metrics.createIndex(
  { "deviceId.sn": 1, temp: -1 },
  { partialFilterExpression: { temp: { $type: "number" } } }
)
# mongod.conf:时序集合为主时的引擎建议
storage:
  wiredTiger:
    engineConfig:
      cacheSizeGB: 8
    collectionConfig:
      blockCompressor: zstd   # 列式压缩之外的块级压缩兜底

时序写入是高吞吐追加型负载,建议关注 db.serverStatus().wiredTiger.cache 的 tracked dirty bytes 与驱逐率;若写入吞吐受限,优先检查 writeConcern 是否过强(majority 会放大提交延迟)以及磁盘是否为 SSD。

调优点配置影响
提交确认writeConcern: { w: 1 }降低 IoT 高吞吐写入延迟
压缩算法blockCompressor: zstd压缩率优于 snappy,CPU 成本略高
桶跨度bucketMaxSpanSeconds权衡活跃桶内存与压缩率
缓存cacheSizeGB时序扫描对缓存命中率敏感

8. 与普通集合 / InfluxDB 的对比

在选型时,时序集合并非唯一答案。需要横向对比的是:改造普通集合、采用 MongoDB 时序集合、以及引入 InfluxDB 等专用时序数据库。

维度普通集合MongoDB 时序集合InfluxDB
写入放大高(逐文档索引)低(桶化批量)极低(LSM + 列存)
查询语言Mongo 聚合Mongo 聚合Flux/InfluxQL
与业务库统一天然统一天然统一需双库双写
压缩率中高最高
运维复杂度低低额外组件
事务/关联查询支持有限不支持
生态成熟度高中(5.0+)高

选择时序集合最现实的理由是"不多引入一个数据库"。当团队已经在 MongoDB 中保存业务数据,把遥测指标也放进 MongoDB,意味着运维体系、备份、安全策略全部复用。只有当数据规模达到 TB 级、压缩率要求极高、或需要专门的下推聚合(如 InfluxDB 的连续查询)时,才值得引入独立时序数据库。

9. IoT 实战案例:设备遥测平台

以一个智能楼宇的 2000 台传感器为例,展示从建集合到日常运维的完整闭环。设备每 10 秒上报温湿度、能耗与电压,需要保留原始数据 30 天,并提供 5 分钟与 1 小时两档聚合报表。

// 步骤 1:创建原始时序集合
db.createCollection("sensor_raw", {
  timeseries: {
    timeField: "ts",
    metaField: "sensorId",
    granularity: "seconds"
  },
  expireAfterSeconds: 60 * 60 * 24 * 30
})

// 步骤 2:为查询热点建索引(楼层 + 时间)
db.sensor_raw.createIndex({ "sensorId.floor": 1, ts: -1 })

// 步骤 3:写入遥测
function report(sensorId, floor, temp, watt) {
  db.sensor_raw.insertOne({
    ts: new Date(),
    sensorId: { id: sensorId, floor: floor },
    temp: temp,
    power: watt
  });
}

// 步骤 4:每 5 分钟聚合一次(可放入定时任务)
db.sensor_raw.aggregate([
  { $match: { ts: { $gte: new Date(Date.now() - 5 * 60 * 1000) } } },
  { $group: {
      _id: { floor: "$sensorId.floor", slot: { $dateTrunc: { date: "$ts", unit: "minute", binSize: 5 } } },
      avgTemp: { $avg: "$temp" },
      avgPower: { $avg: "$power" }
  }},
  { $merge: { into: "sensor_5min", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
])

// 步骤 5:能耗告警——某楼层近 5 分钟平均功率超过阈值
db.sensor_5min.find({
  "_id.floor": 3,
  "_id.slot": { $gte: new Date(Date.now() - 5 * 60 * 1000) },
  avgPower: { $gt: 8000 }
}).sort({ "_id.slot": -1 }).limit(1)

这个案例的关键决策是:把楼层放在 sensorId 这个 meta 对象的子字段里,使同楼层数据尽量进入同一组桶,压缩率更高;power、temp 作为 measurement 字段参与聚合而不建索引;生命周期用 TTL 自动回收,30 天以上的分析全部走 sensor_5min 等聚合层。整个平台只需一台 MongoDB,无需引入独立时序组件。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 27. MongoDB 多租户与隔离架构设计
  2. 26. MongoDB 监控与可观测性实践
  3. 25. MongoDB WiredTiger 存储引擎深入