工业边缘时序压缩:从 Delta 到 Gorilla 的工程实战

工业边缘会持续产生电压、电流、功率、温度、频率、告警状态等时序数据。一个几百个测点的站点,很容易达到每秒数千甚至数万个采样点;再叠加断网缓存、历史回补和资产分析,原始数据会快速占满边缘存储和上行带宽。

但工业场景不能只追求"压得小"。边缘设备的 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 要求时间序列基本有序。工业边缘常见做法是:

  1. 采集后按设备时间做有限范围排序;
  2. 设置乱序容忍窗口,例如允许迟到 5 分钟;
  3. 超过窗口的数据单独入库并打质量标记;
  4. 查询层区分实时流和历史修正数据。

不要简单把时间戳排序后覆盖原值,否则会丢失采集顺序和网络异常信息。

五、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. 聚合结果已持久化成功;
  2. 聚合窗口已经关闭,且迟到数据处理完成;
  3. 删除任务可重试、可审计,不会在故障时造成不可恢复丢失。

七、分层保留策略

工业边缘更适合多级保留,而不是把所有数据都保存同样久。

数据层级 典型精度 保留周期 主要用途
实时原始数据 原始采样 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 是列式存储格式,zstdsnappy 等是具体压缩算法。两者不是同一个概念。

列存的优势在于:

  • 同列数据类型一致,编码空间小;
  • 设备 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:只压不监控

压缩率随工况变化,队列积压可能在弱网时突然放大。

应对:持续监控压缩率、延迟、积压、完整率和磁盘水位。

十三、实践建议

  1. 先明确哪些数据必须无损,哪些可以降采样。
  2. 时间戳和数值分开设计编码策略。
  3. 本地 TSDB 优先,避免业务层重复造轮子。
  4. Parquet 用于批量归档和上传,不用于强实时控制。
  5. 关键事件保留原始片段。
  6. 删除原始数据前确认聚合已持久化且窗口已关闭。
  7. 用实测数据评估压缩率、CPU、延迟和磁盘写入量。
  8. 为压缩格式和归档文件保留版本信息。

在 Zenova EdgeOS 的边缘数据链路中,这类能力通常被放在协议运行时和边缘存储层统一处理,让项目侧不必为不同设备、不同数据库和不同上传协议重复实现时序压缩、断网缓存和归档策略。

TL;DR

工业边缘时序压缩不能只看压缩率,要同时看 CPU、延迟、带宽、磁盘寿命和可恢复性。时间戳适合 Delta-of-Delta,浮点值适合 Gorilla XOR 思路,长期趋势用多级降采样和分层保留,批量归档用 Parquet + zstd,本地热存储优先交给 TDengine、InfluxDB 等时序数据库。关键是区分无损压缩、有损降采样和事件级原始保留,并用完整监控证明方案稳定。

相关推荐
Zenova EdgeOS17 小时前
C++ 工业边缘 libcurl HTTP 客户端实战
开发语言·c++·网络协议·边缘计算
find1star1 天前
LeetCode 54:螺旋矩阵——用四个边界模拟矩阵收缩
java·算法·leetcode·边缘计算·学习方法
集和诚JHCTECH3 天前
案例分享 | BRAV-7721助力印刷电路板(PCB)智能缺陷复判系统
人工智能·嵌入式硬件·边缘计算
Raspberry_Pi_官方账号3 天前
Raspberry Pi+TinyML:基于边缘计算的低成本离线皮肤癌AI诊断系统
人工智能·边缘计算·树莓派·raspberry pi·tinyml
土星云SaturnCloud3 天前
VILA 视觉语言模型边缘部署实战
服务器·人工智能·语言模型·自然语言处理·边缘计算
江苏赛融科技3 天前
边缘计算:园区能耗管理系统的“最后一公里”突围
人工智能·边缘计算·智慧园区·园区智能化·能耗管理系统·能耗计算·园区能耗
鲁邦通物联网3 天前
深度实战:制造业存量SCADA系统旁路报警重构与Node-RED边缘计算网关源码级调优指南
边缘计算·工业物联网网关·node-red网关·node-red边缘计算·node-red数采·5g边缘计算网关·低代码物联网网关
Zenova EdgeOS4 天前
Go 工业边缘 Protobuf 实战:从 proto 到 Marshal 的完整落地
物联网·go·边缘计算·序列化·protobuf·工业边缘
赖赖-5 天前
化工车间定制一体机案例:从需求到量产的完整技术拆解
人工智能·电脑·硬件架构·边缘计算