物联网设备、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 | 默认最大桶跨度 | 典型场景 |
|---|---|---|
| seconds | 15 分钟 | 高频 IoT 遥测、行情 tick |
| minutes | 1 小时 | APM 指标、日志聚合 |
| hours | 1 天 | 日粒度报表、能源计量 |
// 查看集合的 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
}
])
| 角色 | 字段 | 设计原则 | 查询影响 |
|---|---|---|---|
| timestamp | ts | 使用服务器标准时间,避免本地时区偏差;精度统一 | 驱动桶化边界,隐式主排序键 |
| meta | deviceId | 值尽量稳定、基数适中(数千~数十万) | 同值聚合到同一桶,索引与压缩效率高 |
| measurement | temp/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_metrics | 5 秒 | 7 天(expireAfterSeconds) |
| 小时汇总 | device_metrics_hourly | 1 小时 | 90 天 |
| 日汇总 | device_metrics_daily | 1 天 | 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,无需引入独立时序组件。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。