5.4 数据存储与高效查询
5.4.1 时序数据库:数据模型与写入架构
物联网数据最明显的特征是“以时间为主轴”,而不是天然有序。每条记录通常携带设备源时间,平台还应记录接收时间;断网补传、重试和时钟漂移都可能让到达顺序偏离事件顺序。温度值可以低频上报,GPS 坐标周期回传,振动波形则可能高频成批写入。传统关系数据库并非不能存储这些数据,真正的压力来自持续写入、累积体量、高基数维度、保留策略和时间窗口查询的组合;是否成为瓶颈必须由目标模式和基准测试判断。
数据模型:时间戳、标签与字段
时序数据库的数据模型围绕三个核心概念设计:时间戳(timestamp)、标签(tags)和字段(fields)。
时间戳是数据的时间坐标,常见表达包括 Unix 毫秒、微秒或纳秒。工程上应区分设备源时间与平台接收时间,并记录时钟质量或同步状态。网关补充接收时间只能提供第二个参照,不能修复设备时钟,也不能阻止乱序。数据库可能按其中一个时间列分区,聚合与迟到数据策略则要明确使用哪个时间语义。
标签用键值对描述序列维度——设备 ID、传感器类型、厂房编号、地理区域。许多时序数据库会为标签或序列键建立专门索引,以支持过滤和分组,但具体结构可能是倒排索引、B-tree、哈希、字典或数据跳过索引。风险不只在标签个数,更在取值基数和组合基数;“不超过 10 个”不是通用规则。应以目标产品的基数估算、内存模型和查询计划确定哪些维度可索引。
字段承载测量值——温度、湿度、振动加速度和电流等。字段可以是数值、布尔值或字符串;是否建立索引、采用列式布局或只依靠时间范围裁剪,取决于数据库实现与表设计,不能一概写成“字段不建索引”。高频过滤条件若放错位置,可能造成高基数索引或大范围扫描,因此要用真实查询验证。
表5-2 关系数据库与时序数据库数据模型对比
| 维度 | 关系数据库 | 时序数据库 |
|---|---|---|
| 代表实现 | MySQL、PostgreSQL | InfluxDB、TimescaleDB |
| 键与分区 | 业务主键、唯一约束与显式索引 | 常以时间列配合序列键/标签组织;唯一键和分区键随产品而异 |
| 数据写入方式 | 单条INSERT或批量INSERT | 行协议或二进制批次 |
| 数据更新频率 | 频繁 | 主要追加写入,极少原地更新 |
| 删除策略 | DELETE语句按需删除 | 基于保留策略自动过期删除 |
| 索引机制 | B+tree、Hash、GIN/GiST 等,随数据库而异 | 时间索引、标签/序列索引、分区裁剪或数据跳过索引,随产品而异 |
| 存储侧重 | 数据一致性、事务 | 写入吞吐量、压缩比、降采样效率 |
表中比较的是常见侧重点,不是互斥边界。TimescaleDB 本身建立在 PostgreSQL 之上,关系数据库也可以通过分区、批写和扩展处理时序负载;专用时序数据库则常把保留、压缩、降采样和时间窗口查询做成一等能力。选型应同时验证写入、查询、事务、运维、备份恢复、生态和总成本,而不是仅凭“设备上报”四个字预设答案。
选型视野也不必局限于上面两家。TDengine 的超级表、Apache IoTDB 的层次化路径、GreptimeDB 的分布式架构分别代表不同的数据组织与部署思路,但具体引擎、兼容接口和集群能力会随版本变化。评估时应锁定版本,以官方文档和可复现基准核对写入模型、查询语言、一致性、扩缩容、备份恢复与运维形态;产品标签和市场描述不能代替适配结论。
写入架构:从LSM-Tree到TSM引擎
时序数据库的写入性能取决于存储引擎、分区、索引、压缩、持久化和查询负载。不同产品并不共享一条统一路径:有的采用 LSM/SSTable 类结构,有的建立在 PostgreSQL 页式存储之上,有的使用列式文件和对象存储。下面的三段路径只解释 LSM 派生引擎的典型机制,不能套用到所有 TSDB。
LSM 派生引擎的写入路径大致分三段。
第一段,数据进入内存写缓存(memtable),通常按内部键组织。达到实现设定的阈值后,活跃 memtable 会被冻结并等待刷盘。阈值、排序键和并发模型都是产品实现细节,不存在适用于所有数据库的固定范围。
第二段,冻结的 memtable 被写成不可变的有序文件,常称 SSTable。该路径倾向于把许多小更新转换成较大的顺序写,但 WAL、元数据、索引和文件系统仍会产生其他 I/O,不能描述成“几乎纯追加”。
第三段,后台 compaction 按策略合并文件、回收被覆盖或过期的数据并重写压缩块。它减少读放大,却带来写放大和资源竞争;若后台合并跟不上前台写入,尾延迟和磁盘水位都可能恶化,因此需要监控而不是假定其天然稳定。
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₀+1000 ms、t₀+2000 ms……相邻差值相同,第二次差分便连续为零,可用短码表示。这里的“少量比特”是长序列上的编码效果,不等于每个时间戳固定只占 1—2 bit;乱序、抖动和块头元数据都会改变平均压缩率。
浮点序列也可采用 XOR 类编码:保存前值与当前 IEEE 754 位模式的异或结果,并复用前导零、尾随零等结构。数值接近并不必然产生相同压缩效果,异常值、噪声、采样规律和块大小都会影响结果。不要用“16 字节必然压到 4 字节”作为容量规划;应以目标数据集、目标引擎和压缩设置实测每点字节数。
写入吞吐优化:批量写入与并发设计
物联网场景下,单个设备的上报频率可能很低,但设备数与每报点数会共同放大写入。若十万台设备每分钟各写 1 个点,均值约为 100000 ÷ 60 ≈ 1667 点/秒;一百万台约为 16667 点/秒,而不是自然得到“每秒数十万”。真实入口还要乘以每报点数、突发系数与重试,并计入降采样和副本写放大。批量与并行是常用手段,但容量结论必须来自完整负载模型。
批量写入是所有时序数据库的标配。以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 时间戳;示例数值采用纳秒精度。服务端如何解析并落入内存结构取决于版本。批量大小没有通用的“几百到几千条”答案,应以请求字节数、延迟目标、服务端限制、重试成本和压测结果共同确定。
并行管道可以提高吞吐,但不同数据库按时间、序列键、租户或哈希分片,写入管道也未必与 shard 一一对应。吞吐受热点、锁、WAL、网络、副本、压缩和下游 I/O 限制,不会保证随 CPU 核数线性增长。分片过少可能形成热点,过多则增加元数据、文件和调度开销;应从真实键分布与基准结果确定。
对于采用预写日志(WAL)的引擎,WAL 是崩溃恢复的一环:数据先按实现约定写入日志和内存结构,确认时点由同步刷盘与复制策略决定,随后再写入持久文件。WAL 不能单独保证不丢数据;磁盘损坏、未同步页、错误的确认策略和副本同时故障仍需考虑。是否把 WAL 放在独立 SSD、如何批量刷盘,也应依据产品文档、故障模型和基准测试决定。
理解了时序数据库的核心数据模型与写入机制,下面讨论如何高效地把数据读出来——包含降采样聚合、持续查询与数据生命周期管理。这些是工程实践中每天查数据、看仪表盘时都会遇到的问题。
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)是直接的成本控制手段。不同数据集可以设定不同期限,但“原始 7 天、分钟级 30 天、小时级 12 个月”只能作为示例,实际要由告警追溯、模型训练、法规、合同和事故调查要求确定。若写入率、序列基数和副本数稳定,且过期清理能持续跟上,数据区容量才可能在某个区间趋稳;索引、WAL、压缩临时空间、备份和增长中的设备规模仍会占用额外容量。
当业务需要保留超过三年数据时,仅靠保留策略就不够用了。删除旧数据能节省空间,但一旦删除就无法回溯。冷热分层(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/year/month/day/tenant/data.parquet;若把高基数 device_id 放进目录层级,要先评估小文件与元数据开销。查询能否避免全量扫描取决于分区裁剪、文件统计信息和目录设计。
实施冷热分层时,迁移会占用 I/O、CPU、网络和临时空间,集中执行可能推高在线查询尾延迟。可按时间块或稳定分区渐进迁移,设置速率限制,并对校验、失败重试、重复对象和删除源数据的时点做演练。部分产品或版本提供内置分层,另一些依赖导出任务或查询引擎;“内置”并不天然更优,仍要验证一致性、可观测性、供应商锁定、恢复时间和总体成本。
数据生命周期管理的核心命题很简单:让每一字节数据按它的查询价值来付费。热数据保持快速读取,冷数据安静归档。当存储成本不再成为瓶颈,工程师才有精力把注意力放到数据本身的分析上。