5.4 数据存储与高效查询
5.4.1 时序数据库:数据模型与写入架构
物联网数据最明显的特征是“有序”——每条记录都与一个精确的时间戳紧密绑定。以温度传感器为例,数据以固定或变化的间隔上报;GPS坐标周期性回传,振动波形以毫秒级间隔连续写入。这类数据之所以让传统关系型数据库吃力,不是因为数据结构复杂,而是因为写入负载高、累加量大。如果数据库每秒要处理大量单行INSERT,且绝大多数操作都是写入,关系数据库的B+树索引很快会成为瓶颈。
数据模型:时间戳、标签与字段
时序数据库的数据模型围绕三个核心概念设计:时间戳(timestamp)、标签(tags)和字段(fields)。
时间戳是数据的标记点,通常采用Unix毫秒级或纳秒级精度。在物联网场景中,设备上报的原始时间常为UTC,由边缘网关统一打上接收时间戳,避免设备本地时钟不同步导致的时序错乱。时间戳决定了数据在哪个时间分区落地,也驱动了基于时间的聚合和查询。
标签用键值对描述数据的元信息——设备ID、传感器类型、厂房编号、地理区域。标签是有索引的,支持高效的过滤和分组查询。例如,要查“厂房A中所有温度传感器在过去24小时的均值”,时序数据库会利用标签的倒排索引快速定位到相关序列。标签数量需要控制,通常建议10个以内,因为每个标签都会增加索引内存消耗和写入开销。
字段是真正承载测量值的部分——温度读数、湿度百分比、振动加速度、电流大小。字段的值通常是浮点数或整数,字段数量从几个到上百个不等。字段不建索引,查询时按列扫描或通过时间索引缩小范围。
表5-2 关系数据库与时序数据库数据模型对比
| 维度 | 关系数据库 | 时序数据库 |
|---|---|---|
| 代表实现 | MySQL、PostgreSQL | InfluxDB、TimescaleDB |
| 主键设计 | 业务主键(ID、UUID) | 时间戳+标签组合(自动分区) |
| 数据写入方式 | 单条INSERT或批量INSERT | 行协议或二进制批次 |
| 数据更新频率 | 频繁 | 主要追加写入,极少原地更新 |
| 删除策略 | DELETE语句按需删除 | 基于保留策略自动过期删除 |
| 索引机制 | B+树 | 正排索引(时序)+ 倒排索引(标签) |
| 存储侧重 | 数据一致性、事务 | 写入吞吐量、压缩比、降采样效率 |
表中显示,时序数据库从设计之初就放弃了通用性,换来了极高的写入性能和存储效率。工程师在选择数据库时,如果业务主要是设备数据上报和趋势分析,应优先考虑时序数据库。
选型视野也不必局限于上面两家。TDengine以“一个采集点一张表”的数据模型和超级表语法见长,写入去重与压缩策略激进,在国产工业、电力和能源监控语境中装机量很大;Apache IoTDB是Apache基金会孵化的物联网原生时序数据库,树形元数据贴合设备层级组织,端-边-云数据同步对车联网和工业现场比较友好;GreptimeDB则代表云原生路线,存算分离、以对象存储为底座,适合部署在Kubernetes与公有云托管环境。它们与InfluxDB、TimescaleDB的取舍逻辑一致:写入模型、查询语言和运维形态决定适配场景,没有全能选手。
写入架构:从LSM-Tree到TSM引擎
时序数据库的写入性能核心在于存储引擎。大多数现代TSDB采用Log-Structured Merge-Tree(LSM-Tree,日志结构合并树)的变体。LSM-Tree也是Apache Cassandra、HBase这类NoSQL数据库的基础,但时序场景专门做了两个改动:一是按时间分区,二是针对浮点数做列式压缩。
LSM-Tree的写入路径大致分三段。
第一段,数据先写入内存中的写缓存(memtable)。memtable按时间戳和标签排序,形成有序结构。传统B+树在每次写入时都要查找并修改索引页,高并发下产生大量随机写;而memtable只需要在内存中做一次插入,排序成本可控。当memtable大小达到阈值(通常为几兆到几十兆字节)时,会被冻结为不可变的只读结构。
第二段,冻结的memtable作为SSTable(Sorted String Table,有序字符串表)刷入磁盘。SSTable是顺序写的,磁盘I/O几乎是纯追加的,绕开了传统B+树随机写索引页的瓶颈。
第三段,后台的合并线程(compaction)定期将多个小SSTable合并为大SSTable,清理重复数据、删除过期数据,同时压缩数据块。合并操作是时序数据库写入稳定的关键:通过后台资源消耗换取了查询时不必打开大量小文件。
InfluxDB在1.x/2.x中对LSM-Tree做了进一步优化,形成了TSM(Time-Structured Merge Tree)引擎(3.x已转向Parquet存储,见5.1.2节)。TSM引擎的关键改进包括:按时间分区(shard)存放数据,每个shard内部再按列式布局存储字段值,从而获得更好的压缩比。相比通用LSM-Tree,TSM引擎的合并策略更激进,主动将时间相邻的块合并,压缩效率更高。
压缩算法:差分编码与delta-of-delta
时间序列数据有一个显著特征:相邻读数之间的差值通常很小,甚至为零。时序数据库利用这种“缓慢变化”特性,专门设计压缩算法。
时间戳压缩通常采用delta-of-delta(DDD)编码。一个设备每秒上报一次数据,时间戳序列为t₀, t₀+1000ms, t₀+2000ms……DDD先计算相邻时间戳的差值(delta):1000, 1000, 1000……然后计算这些差值的差值(delta of delta):0, 0, 0……如果设备准时上报,DDD值几乎全是0,可以用很少的比特来表示,压缩比非常高。这种算法在实际工程中能把时间戳占用从64位降到1到2位。
浮点数压缩则使用差分编码与XOR结合的框架。该方法只存储浮点数前值与当前值的异或结果:如果相邻读数接近,异或结果的高位全是0,同样可以大幅节省空间。一个时间戳+浮点数的16字节元组,在稳定场景下可以压缩到不足4字节。压缩比受数据波动程度影响:如果传感器数据剧烈变化,压缩比会下降,但总比不压缩好很多。
写入吞吐优化:批量写入与并发设计
物联网场景下,单个设备的写入速率可能很低(每分钟一次),但设备数量却可能达到十万甚至百万级。这意味着数据库每秒要处理数十万次写入。工程中主要靠两条线保证写入吞吐:批量处理与并行管道。
批量写入是所有时序数据库的标配。以InfluxDB的行协议(Line Protocol)为例,客户端将多条数据拼在一个HTTP POST请求体中发送,而非逐条写入。行协议格式如下:
# 示例:向InfluxDB写入两条天气数据
# 格式:<measurement>,<tags> <fields> <timestamp>
weather,location=us-midwest,sensor_id=1234 temperature=82,humidity=75 1700000000000000000
weather,location=us-west,sensor_id=5678 temperature=78,humidity=68 1700000060000000000这个协议用换行分隔系列。标签在前(逗号分隔键值对),字段在后(逗号分隔键值对),最后是纳秒精度的Unix时间戳。服务端按批接收后,再拆解写入memtable。批量大小一般设在几百到几千条之间,过大可能导致单次请求超时,过小则无法充分利用批量优势。
并行管道解决单点瓶颈。大多数时序数据库支持多线程写入,每个shard或分区对应一个独立的写入管道。写入请求先根据标签哈希到特定分区,各分区内的写入互不影响。这种水平扩展模式让时序数据库能随硬件核数线性扩展写入吞吐。实际部署时,shard数量需要根据设备数和数据量动态调整:shard太少会导致写入竞争,太多则增加管理开销。
此外,预写日志(WAL,Write-Ahead Log)是保证数据不丢的第一道防线。所有写入先追加到WAL(顺序写),成功后返回给客户端,然后异步写入memtable和SSTable。即使服务器宕机,重启后也能从WAL恢复数据。WAL写入速度直接影响写入延迟,因此很多时序数据库会将WAL单独放在SSD上,并开启批量flush。
理解了时序数据库的核心数据模型与写入机制,下面讨论如何高效地把数据读出来——包含降采样聚合、持续查询与数据生命周期管理。这些是工程实践中每天查数据、看仪表盘时都会遇到的问题。
5.4.2 高效查询:降采样、聚合与持续查询
时序数据库解决了写入问题后,下一个瓶颈通常出现在查询侧。一个典型现象是:仪表盘加载“过去24小时温度走势”需要十几秒。原因很简单——查询扫描了上千万条原始记录,而业务真正需要的是小时级平均温度。解决思路不是让数据库跑得更快,而是让查询处理的数据量更少。降采样、预聚合和持续查询正是为此设计的三件套。
降采样:精度换时间
降采样(downsampling)将高精度原始数据按固定时间窗口聚合为粗粒度汇总数据。温度传感器每10秒上报一次,查询“过去1小时平均温度”时直接扫描原始记录不仅慢而且没必要。更好的做法是:在写入或后台自动计算每分钟的平均值、最大值、最小值,将多条记录压缩为一条聚合记录,查询读取后者即可。
降采样对存储的影响可以直接估算。以例子为例:一个中等规模的工厂部署了若干设备,每台每10秒上报温度和湿度两个字段。如果按分钟级聚合,数据量可以降至原始记录的约几分之一;若按小时级聚合,数据量可降至更低的占比。降采样不是删除数据,而是建立数据分层:高精度原始数据保留短时间用于故障排查,粗粒度聚合数据保留更长时间用于趋势分析。
持续查询:让聚合自动化
持续查询(Continuous Query, CQ)是时序数据库内置的、以固定时间间隔自动执行聚合操作的机制。用户定义一条类SQL查询,数据库后台按计划周期运行,将结果写入指定表。整个过程无需外部调度器,对应用透明。
以InfluxDB 1.x/2.x为例,创建一个连续查询,每小时自动计算所有传感器的平均温度:
CREATE CONTINUOUS QUERY "cq_1h_avg" ON "iot_platform"
BEGIN
SELECT mean("temperature") AS avg_temp
INTO "hourly_avg"
FROM "sensor_data"
GROUP BY time(1h), "device_id"
END这条语句执行后,InfluxDB每小时整点自动查询过去一小时sensor_data表中的数据,按device_id分组计算平均温度,将结果追加到hourly_avg测量中。仪表盘读取hourly_avg时扫描的是少量聚合记录,而不是大量原始记录。持续查询与降采样天然互补:CQ是实现自动化降采样的标准工具,保留策略(Retention Policy)负责让原始数据在指定时间后过期,形成完整的数据生命周期。需要注明版本口径:上述InfluxQL持续查询语法适用于InfluxDB 1.x/2.x;InfluxDB 3.x为Rust重写版本,不再内置这类CQ,降采样需改由其处理引擎插件或外部任务调度完成。
实时聚合与窗口函数
持续查询的局限在于它的周期性——每小时才刷新一次。对于“最近5分钟平均温度”这类场景,等待CQ刷新不适用。时序数据库提供时间窗口函数,动态地对查询范围内的数据实时计算聚合。在InfluxQL中,GROUP BY time(5m)将数据分为5分钟一个桶,实时计算桶内均值。TimescaleDB中使用time_bucket('5 minutes', time)实现类似功能。以下查询实时计算过去1小时每5分钟的平均温度:
SELECT mean("temperature") AS avg_temp
FROM "sensor_data"
WHERE time > now() - 1h
GROUP BY time(5m), "device_id"实时聚合不需要额外存储,每次查询都在原始数据上执行。但如果仪表盘面板每秒刷新,每次都跑这个查询,很快打满查询线程。工程上的做法是:通过缓存或物化视图对高频查询做裁剪——用户直接请求且频繁访问的仪表盘数据,由CQ或物化视图提供;临时的探索性分析,直接用实时窗口函数查。
工程权衡:CQ vs. 实时聚合
| 特性 | 持续查询(CQ) | 实时窗口聚合 |
|---|---|---|
| 数据来源 | 预计算并存储 | 每次查询实时计算 |
| 查询响应速度 | 毫秒级(直接读聚合表) | 取决于数据量和时间窗口 |
| 额外存储开销 | 有(存储聚合结果) | 无 |
| 适合场景 | 高频访问的仪表盘、报警规则、固定报表 | 临时分析、低频探索、调试 |
如果聚合结果每天被翻看数千次,值得用CQ提前算好;如果分析只在排查问题时使用几次,实时窗口函数更省维护成本。
分层设计实践
实际系统中,降采样很少只做一级。以下是一套分层方案,各层保留时长和数据量比为定性描述,实际项目需根据业务需求和设备规模调整:
- 原始层:保留较短窗口(如用于故障现场回放),高精度原始数据。
- 分钟级聚合层:保留中期窗口(如数周至数月),提供小时内的波动概览。
- 小时级聚合层:保留较长期窗口(如数月),支撑日报和周报。
- 天级聚合层:保留超长期窗口(如一年或更长),用于年度趋势、容量规划等场景。
每一层的数据量相比上一层显著减少。例如,若原始数据为秒级,分钟聚合可降至约几分之一,小时聚合可降至约几百分之一,天级聚合可降至约几千分之一(基于典型场景估算,非精确值)。三层结构下,一年数据中原始数据只占最早的小部分,其余都是聚合后的粗粒度信息。数据链路中的“消息队列→时序数据库→聚合”是这一设计的关键:网关上传的原始数据先经消息队列缓冲,再写入时序数据库的原始层;持续查询在数据库内部将原始层数据聚合并写入聚合层;仪表盘直接读取聚合层。这套管线与第5.1节讨论的“消息队列解耦写入压力”逻辑一致——队列用于解耦写入压力,CQ用于解耦查询压力。
实践检查清单
- 根据业务确定各层保留窗口:原始层通常较短(如用于故障诊断),聚合层按报告周期确定(日报需小时级,年报需天级)。
- 评估CQ执行频率:CQ对写入有额外开销,在高写入负载下应避免设置过短的执行间隔(建议根据写入负载评估,例如不低于1分钟)。
- 验证聚合查询的精度:聚合函数(mean, max, min)需与业务含义一致,注意离群值对统计结果的影响。
- 监控CQ延迟:如果CQ执行时间超过执行间隔,会造成数据堆积,应考虑增加计算资源或调整聚合粒度。
最后交代一句分工:本节给出的降采样、持续查询与分层保留是通用管道能力;时序数据库在工业现场的选型差异——协议适配、数据模型与行业惯例——留到第 10 章 10.3 节展开。
5.4.3 数据生命周期管理:过期删除与冷热分层
高写入吞吐解决了时序数据“存得进”的问题,但新瓶颈很快会浮出水面:磁盘容量告急。查看查询日志会发现,几个月前的数据几乎从未被访问过,却和最新数据一样占据着昂贵的存储资源。
一个工程实情是:不同时间跨度的数据,查询频率差异巨大。实时仪表盘需要毫秒级访问最近几小时的数据;月度报表只需分钟级聚合结果;而一年前的原始读数,可能只在年终回顾时才被调用一两次。把不同价值的数据放在同一层级的存储上,成本上不划算。
保留策略(Retention Policy)是最直接的成本控制手段。几乎所有时序数据库都允许为不同数据集设定独立的保留时长。一个车间部署了温度、振动和电流传感器,原始10秒级数据主要服务于实时告警和故障排查,保留7天就够了;分钟级聚合数据用于周报,保留30天;小时级聚合用于年度趋势分析,保留12个月。保留策略生效后,数据库容量会趋于稳定:新数据持续写入,到期数据被自动删除,磁盘占用不再随运行时间增长。
当业务需要保留超过三年数据时,仅靠保留策略就不够用了。删除旧数据能节省空间,但一旦删除就无法回溯。冷热分层(Cold/Hot Tiering)为更长周期的数据留存提供了另一种路径——将数据按访问频率放在不同性价比的存储介质上。
一个典型的分层方案大致是:热存储放最近7天的数据,使用本地NVMe或SSD,响应仪表盘毫秒级查询;温存储放8天到3个月的数据,迁移到普通HDD或SSD,用于月度报表;冷存储放超过3个月的数据,归档到对象存储(如MinIO、公有云S3兼容服务),用于季度回顾或算法模型训练。分层存储的核心收益在于:绝大部分查询集中在热存储上,而体积最大的冷数据存储成本可以压得很低。
表5-3 热存储与冷存储对比
| 维度 | 热存储 | 冷存储 |
|---|---|---|
| 存储介质 | 本地NVMe / SSD | 对象存储(S3兼容)或HDD |
| 查询速度 | 毫秒级 | 秒到分钟级 |
| 单位成本 | 相对较高 | 相对较低 |
| 数据格式 | 时序数据库原生格式 | Parquet / Avro |
| 典型保留窗口 | 最近7–30天 | 三个月至数年 |
| 访问模式 | 实时大盘、告警触发 | 历史分析、批量模型训练 |
| 访问频度 | 频繁 | 极少 |
冷数据的存储格式也很关键。原始时序数据导出后,通常会转换为 Parquet 或 Avro 这类列式存储格式。按时间分区存放,目录结构类似 bucket/device_id/year/month/day/data.parquet。需要回溯某台设备某一天的数据时,查询引擎只需加载对应分区文件,不用全量扫描。
实施冷热分层时有一个常见陷阱:数据迁移本身会占用I/O和CPU。如果每天凌晨把前一天的数据从热存储迁到冷存储,在设备规模上万甚至更大时,一次性迁移很可能拖慢数据库响应。一种改进方法是分片迁移:把数据按设备号或时间段拆成小块,分批在低峰时段执行,并设置迁移速率限制。部分时序数据库产品已经支持自动冷热分层功能,用户只需配置保留窗口和存储位置,系统自行完成迁移。对于新立项的系统,优先选择这类内置分层能力的版本,能节省不少后期运维精力。
数据生命周期管理的核心命题很简单:让每一字节数据按它的查询价值来付费。热数据保持快速读取,冷数据安静归档。当存储成本不再成为瓶颈,工程师才有精力把注意力放到数据本身的分析上。