工业边缘会持续产生电压、电流、功率、温度、频率、告警状态等时序数据。一个几百个测点的站点,很容易达到每秒数千甚至数万个采样点;再叠加断网缓存、历史回补和资产分析,原始数据会快速占满边缘存储和上行带宽。
但工业场景不能只追求"压得小"。边缘设备的 CPU、内存、存储写入寿命和供电余量都有限,压缩还会影响写入延迟、查询延迟和故障恢复时间。
本文按工程落地视角梳理时序压缩的常用方法:Delta、Delta-of-Delta、Gorilla XOR、降采样、分层保留、Parquet 列存和专用时序数据库,并给出选型与监控建议。
一、先算清数据规模
做压缩前,先估算原始数据量,避免凭感觉选方案。
假设一个站点有 1000 个测点,每秒采集一次,每条记录包含:
- 8 字节时间戳;
- 8 字节设备 ID 或测点 ID;
- 8 字节数值;
- 少量状态与质量位。
粗略按 32 字节计算:
text
1000 points/s × 32 B × 86400 s ≈ 2.76 GB/天
这还没有计入索引、WAL、重复上传、断网缓存和文件系统放大。由此可见,工业边缘的数据压力通常来自三个方向:
| 压力 | 表现 | 常见约束 |
|---|---|---|
| 存储压力 | eMMC/SSD 被写满,写入被阻塞 | 磁盘容量、写入寿命 |
| 带宽压力 | 上行队列积压,历史数据回补慢 | 4G/5G 流量、网络抖动 |
| 查询压力 | 历史曲线查询慢,影响诊断 | CPU、内存、存储读放大 |
压缩的目标不是单纯减少字节数,而是在存储、带宽、CPU、延迟、可恢复性之间找到平衡。
二、时序数据为什么适合压缩
工业时序数据通常有三个特性。
1. 时间戳近似单调递增
大多数采集周期固定,例如 1 秒、100 毫秒或 50 毫秒。相邻时间戳的差值往往相同或非常接近,适合 Delta 与 Delta-of-Delta。
但要注意两个例外:
- 网关重传、批量补传会造成时间戳回跳;
- 设备时钟异常会造成时间戳跳变。
因此,入库前应区分设备时间、采集时间、入库时间,并为乱序数据设置允许窗口。
2. 数值相邻相关性高
温度、电压、电流、功率在系统稳定时变化平滑,相邻两个值的 IEEE-754 位表示往往只有少数几位不同。这正是 Gorilla XOR 压缩的基础。
不过异常值和工况切换时压缩率会下降,这是正常现象,不能据此判断压缩算法失效。
3. 存在周期性与重复模式
负载曲线、设备启停、昼夜温度变化都有周期性。周期性有利于通用压缩,也适合做降采样和特征归档。
但重复模式不等于可随意丢弃原始点。越限瞬间的波形、故障前后的原始序列、控制过程中的状态变化,通常必须保留更长时间。
三、Delta 编码:存储差值
Delta 编码保留第一个值,后续只保存相邻值的差值。
例如原始序列为:
text
1000, 1002, 1005, 1009
Delta 后为:
text
1000, +2, +3, +4
如果差值比原始值占用更少位宽,整体空间就会下降。
python
def delta_encode(values):
if not values:
return []
encoded = [values[0]]
for prev, curr in zip(values, values[1:]):
encoded.append(curr - prev)
return encoded
def delta_decode(encoded):
if not encoded:
return []
values = [encoded[0]]
for delta in encoded[1:]:
values.append(values[-1] + delta)
return values
适用场景
| 数据类型 | 适合 Delta | 说明 |
|---|---|---|
| 时间戳 | 高 | 采样间隔稳定时差值很小 |
| 递增计数器 | 高 | 需处理回绕和重置 |
| 缓慢变化数值 | 中 | 可配合 Gorilla XOR |
| 状态枚举 | 低 | 更适合字典或游程编码 |
| 随机跳变数值 | 低 | 差值可能仍很大 |
对设备累计运行时长、累计发电量等单调递增指标,Delta 通常有效;但设备重启导致计数器归零时,必须显式记录重置事件,否则会把负差值当成异常。
四、Delta-of-Delta:压缩固定间隔时间戳
如果时间戳本身是均匀递增的,例如每秒一个点,那么一阶差值几乎是常数。二阶差值会变成 0,更容易压缩。
text
原始时间戳: 10:00:00, 10:00:01, 10:00:02, 10:00:03
一阶差值: 1s, 1s, 1s
二阶差值: 0, 0
Python 示例
python
from collections.abc import Sequence
def dod_encode(timestamps: Sequence[int]):
if not timestamps:
return None, [], []
first = timestamps[0]
deltas = [
curr - prev
for prev, curr in zip(timestamps, timestamps[1:])
]
if not deltas:
return first, None, []
first_delta = deltas[0]
delta_of_deltas = [
curr - prev
for prev, curr in zip(deltas, deltas[1:])
]
return first, first_delta, delta_of_deltas
def dod_decode(
first: int | None,
first_delta: int | None,
delta_of_deltas: list[int],
) -> list[int]:
if first is None:
return []
timestamps = [first]
if first_delta is None:
return timestamps
current_delta = first_delta
timestamps.append(first + current_delta)
for dod in delta_of_deltas:
current_delta += dod
timestamps.append(timestamps[-1] + current_delta)
return timestamps
上面是教学版实现。生产系统还会把 Delta-of-Delta 按取值范围分桶,再用更短的控制位和位宽编码,而不是直接存 Python 整数。
乱序数据的处理
Delta-of-Delta 要求时间序列基本有序。工业边缘常见做法是:
- 采集后按设备时间做有限范围排序;
- 设置乱序容忍窗口,例如允许迟到 5 分钟;
- 超过窗口的数据单独入库并打质量标记;
- 查询层区分实时流和历史修正数据。
不要简单把时间戳排序后覆盖原值,否则会丢失采集顺序和网络异常信息。
五、Gorilla XOR:利用浮点数位变化
Facebook 在 2015 年论文《Gorilla: A Fast, Scalable, In-Memory Time Series Database》中提出了一套时序压缩思路:
- 时间戳使用 Delta-of-Delta;
- 浮点值将当前值的 IEEE-754 位表示与前一个值异或;
- 值不变时只写一个"相同"标记;
- 值变化时只写变化的有效位。
核心思想
相邻两个浮点数如果数值接近,其二进制表示的高位往往大量相同。异或后,只有变化位为 1:
text
prev bits: 0101100000000000
curr bits: 0101100001000000
xor : 0000000001000000
因此,不需要保存完整 64 位,只需要保存:
- 是否变化;
- 前导零数量;
- 有效位长度;
- 变化后的有效位。
教学版实现
python
import struct
def float_to_bits(value: float) -> int:
return struct.unpack(">Q", struct.pack(">d", value))[0]
def bits_to_float(bits: int) -> float:
return struct.unpack(">d", struct.pack(">Q", bits))[0]
def count_trailing_zeros(value: int) -> int:
if value == 0:
return 64
count = 0
while value & 1 == 0:
value >>= 1
count += 1
return count
def xor_encode(values: list[float]):
encoded = []
prev_bits = None
for value in values:
bits = float_to_bits(value)
if prev_bits is None:
encoded.append(("first", bits))
else:
xor = bits ^ prev_bits
if xor == 0:
encoded.append(("same",))
else:
leading = 64 - xor.bit_length()
trailing = count_trailing_zeros(xor)
meaningful = 64 - leading - trailing
payload = (xor >> trailing) & ((1 << meaningful) - 1)
encoded.append(("changed", leading, meaningful, payload))
prev_bits = bits
return encoded
def xor_decode(encoded):
values = []
prev_bits = None
for item in encoded:
tag = item[0]
if tag == "first":
prev_bits = item[1]
elif tag == "same":
pass
elif tag == "changed":
_, leading, meaningful, payload = item
trailing = 64 - leading - meaningful
xor = payload << trailing
prev_bits = prev_bits ^ xor
else:
raise ValueError(f"unknown tag: {tag}")
values.append(bits_to_float(prev_bits))
return values
与真正 Gorilla 格式的差异
这个例子演示的是核心异或逻辑,还不是 Gorilla 的完整线上格式。完整实现还要考虑:
- 控制位编码;
- 复用上一条记录的前导零和有效位窗口;
- 时间戳分桶;
- 位流对齐;
- 数据块索引;
- NaN、正负零、异常值的处理;
- 块级边界和随机读取。
实际项目里,除非有特殊约束,不建议自己在业务层完整重写 Gorilla。更常见的选择是:
- 本地写入支持时序压缩的 TSDB;
- 归档导出 Parquet;
- 只有上传协议或缓存格式有特殊要求时,才做自定义位压缩。
六、降采样:不是无损压缩
严格来说,降采样不是压缩,而是有损聚合。它把原始点转换成分钟、小时、天级统计值,用于长期趋势查询。
常见输出包括:
avg:平均值;min/max:极值;sum:累计量;count:样本数;p95/p99:分位数;quality:数据完整度或质量标记。
分桶聚合示例
python
import time
from collections import defaultdict
def bucket_start(ts: int, granularity: int) -> int:
return ts - ts % granularity
class MinuteDownsampler:
def __init__(self):
self.state = defaultdict(lambda: {"sum": 0.0, "count": 0, "min": None, "max": None})
def add(self, device_id: str, value: float, ts: int):
bucket = self.state[(device_id, bucket_start(ts, 60))]
bucket["sum"] += value
bucket["count"] += 1
bucket["min"] = value if bucket["min"] is None else min(bucket["min"], value)
bucket["max"] = value if bucket["max"] is None else max(bucket["max"], value)
def flush_completed(self, now: int):
current_bucket = bucket_start(now, 60)
results = []
for (device_id, bucket_start_ts), agg in list(self.state.items()):
if bucket_start_ts >= current_bucket:
continue
if agg["count"] == 0:
continue
results.append({
"device_id": device_id,
"window_start": bucket_start_ts,
"window_end": bucket_start_ts + 60,
"avg": agg["sum"] / agg["count"],
"min": agg["min"],
"max": agg["max"],
"count": agg["count"],
})
del self.state[(device_id, bucket_start_ts)]
return results
不能删除原始数据的几种情况
降采样后的曲线可以用于报表,但以下数据不能只保留聚合值:
- 故障发生前后的原始波形;
- 越限瞬间和持续时间;
- 控制指令执行过程;
- 事故追溯相关的电量与功率数据;
- 审计、计量、交易结算数据;
- 尚未完成质量校验的历史窗口。
删除原始数据前,建议满足三个条件:
- 聚合结果已持久化成功;
- 聚合窗口已经关闭,且迟到数据处理完成;
- 删除任务可重试、可审计,不会在故障时造成不可恢复丢失。
七、分层保留策略
工业边缘更适合多级保留,而不是把所有数据都保存同样久。
| 数据层级 | 典型精度 | 保留周期 | 主要用途 |
|---|---|---|---|
| 实时原始数据 | 原始采样 | 1 小时到 7 天 | 实时监控、告警、近端诊断 |
| 短期归档 | 1 分钟 | 30 天 | 运行分析、班组织效、异常回看 |
| 中期归档 | 5 到 15 分钟 | 1 年 | 资产绩效、维护策略 |
| 长期归档 | 1 小时或 1 天 | 3 到 5 年 | 生命周期分析、报表、审计 |
| 事件片段 | 原始采样 | 按合规要求 | 故障追溯、质量争议、计量 |
示例配置:
python
retention_policies = [
{"stream": "raw", "granularity": None, "retention": "7d"},
{"stream": "1m", "granularity": "1m", "retention": "30d"},
{"stream": "5m", "granularity": "5m", "retention": "365d"},
{"stream": "1h", "granularity": "1h", "retention": "5y"},
{"stream": "incident_raw", "granularity": None, "retention": "by_policy"},
]
关键点是为事件片段单独定义规则。普通趋势数据可以降采样,故障与审计片段往往必须保留原始精度。
八、Parquet:列存与通用压缩结合
Parquet 是列式存储格式,zstd、snappy 等是具体压缩算法。两者不是同一个概念。
列存的优势在于:
- 同列数据类型一致,编码空间小;
- 设备 ID、测点类型等重复值适合字典编码;
- 时间戳和数值可按列独立编码;
- 统计信息可加速时间范围过滤;
- 文件适合批量归档和对象存储上传。
写入示例
python
from datetime import datetime, timezone
import pyarrow as pa
import pyarrow.parquet as pq
def write_metrics_parquet(path, timestamps, device_ids, voltages):
table = pa.table(
{
"ts": pa.array(timestamps, type=pa.timestamp("ms", tz="UTC")),
"device_id": pa.array(device_ids, type=pa.string()),
"voltage": pa.array(voltages, type=pa.float64()),
}
)
pq.write_table(
table,
path,
compression="zstd",
compression_level=3,
use_dictionary=["device_id"],
write_statistics=True,
row_group_size=128 * 1024,
)
工程建议
- 写入前按
device_id + ts排序,可显著提升压缩率; - 时间统一使用 UTC 存储,展示层再转本地时区;
- 明确字段类型,避免字符串存数值和时间;
row_group_size需要结合查询范围测试,不是越大越好;- 小文件过多会降低查询和上传效率,应做定期合并;
- 压缩等级越高,CPU 消耗越高,边缘设备要实测写入耗时。
九、TDengine 集成:优先使用数据库能力
如果本地存储选用 TDengine,不需要在应用层重复实现 Gorilla。TDengine 会针对时序场景做存储组织和文件压缩。
需要注意:TDengine 的压缩参数是库级参数 COMP,不是表级 COMPRESS lz4。以官方文档为例,COMP 取值为:
0:不压缩;1:一阶段压缩;2:两阶段压缩,默认值。
建库与超级表示例
python
import taos
conn = taos.connect(host="127.0.0.1")
# KEEP 是保留时长;DURATION 是数据文件的时间跨度。
# KEEP 必须大于等于 DURATION 的 3 倍,具体取值应结合版本和磁盘策略测试。
conn.execute(
"""
CREATE DATABASE IF NOT EXISTS edge
PRECISION 'ms'
DURATION 10d
KEEP 30d
COMP 2
"""
)
conn.execute("USE edge")
conn.execute(
"""
CREATE STABLE IF NOT EXISTS metrics (
ts TIMESTAMP,
voltage FLOAT
) TAGS (
device_id NCHAR(32)
)
"""
)
说明
KEEP 30d表示数据库内数据保留约 30 天;DURATION 10d表示数据文件按 10 天跨度组织;COMP 2是文件压缩标志,不等价于指定某种名为 LZ4 的算法;- TDengine 不同版本参数和行为可能调整,上线前应以所用版本文档和
SHOW DATABASES结果为准; - REST 连接方式下,
USE database语法行为与原生连接不同,建议显式使用edge.metrics或按连接配置指定数据库。
应用层更应该关注:
- 一批数据内按时间戳和子表排序;
- 控制单批写入条数;
- 断线时使用本地缓冲与幂等写入;
- 为关键查询建立合理的数据保留粒度;
- 监控磁盘、写入延迟、压缩率和丢弃量。
十、边缘侧参考架构
一个较稳妥的数据链路如下:
text
设备协议接入
↓
数据清洗与质量标记
↓
本地 TSDB / WAL 缓存
↓
实时告警与边缘分析
↓
批量压缩上传
↓
云端时序库 / 对象存储归档
各层职责要分清:
| 层级 | 职责 | 不应承担的职责 |
|---|---|---|
| 协议接入 | 解析设备报号、点位、质量位 | 复杂业务预测 |
| 边缘缓存 | 断网续传、排序、幂等去重 | 长期原始数据仓库 |
| 边缘分析 | 越限判断、短期趋势、简单预测 | 大规模模型训练 |
| 上传链路 | 批量压缩、重试、限速 | 静默丢弃数据 |
| 云端归档 | 长期查询、报表、跨站点分析 | 强实时控制 |
十一、监控指标
压缩系统上线后,至少监控以下指标:
| 指标 | 建议口径 |
|---|---|
| 压缩率 | 原始字节数 / 存储字节数 |
| 每点存储成本 | 存储字节数 / 样本点数 |
| 上行流量 | 每小时成功上传字节数 |
| 压缩耗时 | 每万点编码耗时 |
| 解压耗时 | 历史查询首包耗时 |
| 队列积压 | 待写入、待上传、待归档条数 |
| 数据完整率 | 收到点数 / 预期点数 |
| 丢弃量 | 按原因分类统计 |
| 磁盘水位 | 已用空间 / 总容量 |
| 归档成功率 | 成功归档批次 / 总批次 |
只看压缩率是不够的。如果压缩率从 10 倍提升到 12 倍,但 CPU 占用翻倍、查询延迟明显变差,可能并不划算。
十二、常见坑与应对
坑 1:把降采样当成无损压缩
降采样会丢失原始细节。故障波形、越限过程和审计数据不能只保留分钟级聚合。
应对:普通趋势降采样,关键事件保留原始片段。
坑 2:时间乱序未处理
回补、重传、时钟跳变会破坏 Delta-of-Delta 的前提。
应对:有限窗口排序,乱序数据单独标记。
坑 3:压缩导致 CPU 抖动
高压缩等级可能抢占采集和告警任务的 CPU。
应对:分批压缩、限制并发、设置任务优先级和退避策略。
坑 4:小文件过多
频繁导出小 Parquet 文件会降低查询效率,也会增加对象存储成本。
应对:按大小或时间定期合并归档文件。
坑 5:压缩后无法验证数据
缺少校验时,磁盘损坏或版本变更可能导致历史数据不可读。
应对:写入 checksum、元数据版本、样本数和可选抽样校验。
坑 6:只压不监控
压缩率随工况变化,队列积压可能在弱网时突然放大。
应对:持续监控压缩率、延迟、积压、完整率和磁盘水位。
十三、实践建议
- 先明确哪些数据必须无损,哪些可以降采样。
- 时间戳和数值分开设计编码策略。
- 本地 TSDB 优先,避免业务层重复造轮子。
- Parquet 用于批量归档和上传,不用于强实时控制。
- 关键事件保留原始片段。
- 删除原始数据前确认聚合已持久化且窗口已关闭。
- 用实测数据评估压缩率、CPU、延迟和磁盘写入量。
- 为压缩格式和归档文件保留版本信息。
在 Zenova EdgeOS 的边缘数据链路中,这类能力通常被放在协议运行时和边缘存储层统一处理,让项目侧不必为不同设备、不同数据库和不同上传协议重复实现时序压缩、断网缓存和归档策略。
TL;DR
工业边缘时序压缩不能只看压缩率,要同时看 CPU、延迟、带宽、磁盘寿命和可恢复性。时间戳适合 Delta-of-Delta,浮点值适合 Gorilla XOR 思路,长期趋势用多级降采样和分层保留,批量归档用 Parquet + zstd,本地热存储优先交给 TDengine、InfluxDB 等时序数据库。关键是区分无损压缩、有损降采样和事件级原始保留,并用完整监控证明方案稳定。