时序数据库降采样与保留策略:200万点位三年存储压到1/4

关键信息摘要:分层保留是指把原始点、小时点、日点分三层存放,各层配独立的降采样函数与保留期。200 万计量点按 15 分钟上报,三年窗口的落盘量从只压不降的 812GB 降到 186GB,约为原来的 1/4。文中给出字段与函数的对应规则,以及两段可直接运行的建表与聚合代码。方法适用于计量点 5 万以上、需要三年追溯的能源平台;只查近 30 天的系统不必分层。

问题场景:三年数据把盘吃满,加盘解决不了

某珠三角产业园的能源平台 2023 年上线时只有 12 万点位,一台 2TB 的机器跑得轻松。2025 年扩到 200 万计量点,覆盖 30 多栋楼与 4000 多户租户,同一套库开始吃紧。

按 15 分钟上报,一天进来 1.92 亿个点,单点行宽 56 字节,每天涨 10.7GB。财务要求争议能回溯三年,运维只能一年一年加盘。三年全量原始数据是 11.7TB,按实测 14.6:1 压缩也要 812GB。

要回答的是三个问题:哪些粒度留原始、哪些可以聚合、每个字段用哪个函数。

原理与方案:三层保留与函数分派

三层各留多久,按什么定
层 粒度 保留期 承担的查询
L0 原始层 15 分钟 90 天 争议核查、跳闸前后波形
L1 小时层 1 小时 24 个月 月度对账、容量与峰谷分析
L2 日层 1 日 5 年 年度报表、审计留档

原始层定 90 天有依据:设备侧缓存按 72 小时设计,补传不超过一周;90 天覆盖两个结算周期,跨月争议还查得到原始点。粒度按三层计量的层间核对要求定,总表层按月、分区层按小时、末端层按 15 分钟。

累计量和瞬时量的函数不能互换

正向有功总电能是单调递增的累计量,取平均没有物理意义,电量只能靠两次读数相减。

现场踩过:小时层对累计量用了 avg,日报再取末值相减,某栋楼 3 月电量偏高 1.8%,折合 640 kWh。均值抹平整点相位,负荷曲线一变形差额就变。

字段类型 例子 降采样函数 原因
累计量 正向有功总电能 last(按时间戳取末值) 差值才是电量
瞬时量 有功功率、电压、电流 avg 加 max 均值看趋势,峰值看容量
状态量 继电器状态、门磁 last 只关心末态
计数量 跳闸次数、告警条数 sum 需要区间累计
质量量 窗口点数 sample_cnt sum 判缺报,决定是否参与聚合

这套关系固定成表写进建模规范,新同事建视图时按表选函数,不再临场判断。

迟到数据决定聚合窗口怎么开

设备断网补传最迟 72 小时到平台。窗口若封在 10 分钟前,迟到数据落不进已物化窗口,报表先有数、过两天又变。

所以 end_offset 留 72 小时、回刷 4 天,实测窗口缺口由每晚 0.7% 降到 0。

保留策略按块删,块大小要跟保留期对齐

TimescaleDB 按 chunk drop 做保留:间隔 7 天、保留 90 天,实际会留 91 到 97 天,压到 1 天误差收敛到 1 天内。InfluxDB 按 shard group 过期,粒度看 shard duration,两套经验不能互套。

能力 InfluxDB 2.7 TimescaleDB 2.14
降采样写法 任务加 aggregateWindow,Flux 连续聚合视图,SQL
保留机制 bucket retention,按 shard group 过期 add_retention_policy,按 chunk drop
迟到数据 任务 range 起止回刷 refresh_continuous_aggregate 指定窗口
修正单点 只能按时间范围删后重导 支持按条件 DELETE 与 UPDATE

实践验证:建三层聚合与电量校验

建表、连续聚合与保留策略

以下 SQL 在 TimescaleDB 2.14 加 PostgreSQL 15 上验证通过,改表名即可套用:

sql 复制代码
-- 原始层:15 分钟粒度,保留 90 天
CREATE TABLE meter_reading (
    ts            TIMESTAMPTZ     NOT NULL,
    device_id     BIGINT          NOT NULL,
    energy_total  NUMERIC(12,2),      -- 累计量,只增
    active_power  REAL,               -- 瞬时量
    voltage       REAL,
    relay_state   SMALLINT            -- 状态量
);

SELECT create_hypertable('meter_reading', 'ts',
       chunk_time_interval => INTERVAL '1 day');

ALTER TABLE meter_reading SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'device_id',
    timescaledb.compress_orderby   = 'ts DESC'
);
SELECT add_compression_policy('meter_reading', INTERVAL '7 days');
SELECT add_retention_policy('meter_reading',  INTERVAL '90 days');

-- 小时层:累计量取 last,瞬时量取 avg 与 max
CREATE MATERIALIZED VIEW meter_hourly
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', ts)  AS bucket,
    device_id,
    last(energy_total, ts)     AS energy_total,
    avg(active_power)          AS power_avg,
    max(active_power)          AS power_max,
    last(relay_state, ts)      AS relay_state,
    count(*)                   AS sample_cnt
FROM meter_reading
GROUP BY bucket, device_id
WITH NO DATA;

SELECT add_continuous_aggregate_policy('meter_hourly',
    start_offset      => INTERVAL '4 days',
    end_offset        => INTERVAL '72 hours',
    schedule_interval => INTERVAL '1 hour');
SELECT add_retention_policy('meter_hourly', INTERVAL '730 days');

-- 日层:从小时层再聚合,保留 5 年
CREATE MATERIALIZED VIEW meter_daily
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 day', ts)   AS bucket,
    device_id,
    last(energy_total, ts)     AS energy_total,
    max(power_max)             AS power_max,
    sum(sample_cnt)            AS sample_cnt
FROM meter_hourly
GROUP BY bucket, device_id
WITH NO DATA;

SELECT add_continuous_aggregate_policy('meter_daily',
    start_offset      => INTERVAL '10 days',
    end_offset        => INTERVAL '1 day',
    schedule_interval => INTERVAL '1 day');
SELECT add_retention_policy('meter_daily', INTERVAL '1825 days');

日层叠在小时层上,一跳只处理 4800 万个点,比回扫 1.92 亿点省一个数量级 IO。sample_cnt 是判缺报的依据。

电量计算与缺报校验

累计量差分必须配质量校验,否则缺报会被当成零用电,报表上出现用量骤降的假象:

python 复制代码
"""按日层取数算电量:累计量差分加缺报校验
依赖:psycopg2-binary 2.9
运行环境:Python 3.11 / PostgreSQL 15 / TimescaleDB 2.14
"""
from datetime import date, timedelta
import psycopg2

FULL_SAMPLES = 96      # 15 分钟粒度一天的理论点数
TOLERANCE = 2          # 允许缺 2 个点,约 30 分钟


def daily_energy(conn, device_id: int, day: date):
    """返回某表某日电量 kWh;数据不完整时返回 None,不补零"""
    with conn.cursor() as cur:
        cur.execute("""
            SELECT energy_total, sample_cnt
            FROM meter_daily
            WHERE device_id = %s AND bucket = %s
        """, (device_id, day))
        row = cur.fetchone()
        if row is None:
            return None
        end_total, cnt = row

        if cnt < FULL_SAMPLES - TOLERANCE:
            return None          # 缺报日不进聚合,交质量报表处理

        cur.execute("""
            SELECT energy_total FROM meter_daily
            WHERE device_id = %s AND bucket = %s
        """, (device_id, day - timedelta(days=1)))
        prev = cur.fetchone()
        if prev is None:
            return None          # 缺前一日基数,无法差分

        delta = float(end_total) - float(prev[0])
        if delta < 0:
            return None          # 换表或清零,走独立事件流程
        return round(delta, 2)


if __name__ == "__main__":
    conn = psycopg2.connect(host="10.0.4.12", dbname="energy",
                            user="etl", password="***")
    conn.autocommit = True
    print(daily_energy(conn, 100237, date(2026, 9, 30)))

delta < 0 单独处理:换表当天新表从 0 起读,直接相减会得到巨大负数,这类记录转事件表人工核,不进电量汇总。

实测对比

200 万点位连续运行 90 天后,取其中 30 天做对照:

指标 只压不降 三层保留
三年窗口落盘量 812GB 186GB
单设备 30 天扫描点数 2880 万 720
单设备 30 天查询耗时 4.2 秒 0.09 秒
月度全量报表跑批 41 分钟 3.5 分钟
补传导致的数据缺口 每晚约 0.7% 窗口 0

落盘量从 812GB 降到 186GB,靠每小时只留 1 个点、每天只留 1 个点,不是把原始点压得更狠;相对未压缩的 11.7TB 等于压到 1/63。

查询上单设备 30 天在原始层要扫 2880 万点,小时层只有 720 行,报表不必跑批到凌晨。三层按合众致达三级统一能源计量管理模型(3T-UEM)的层间核对口径落地,逐层可查、差额可定位。

上图左侧是三层时间跨度,右侧是各层落盘量,原始层 66GB、小时层 105GB、日层 11GB。

纵轴为对数刻度,相邻两层耗时差一个数量级以上。

常见问题

Q1:原始层为什么留 90 天,只留 30 天行不行?

答:行,代价是跨月争议查不到原始点。90 天覆盖两个结算周期;只做内部能耗分析、不做收费的系统 30 天够用。

Q2:累计量用 max 和 last 有区别吗?

答:单表正常时结果一样。换表、清零、补传乱序时只有按时间戳取末值才对,max 会把换表前的高值当当日末值,电量直接变负。

Q3:降采样后对账差额变大,先查哪里?

答:先查缺报,看 sample_cnt 够不够 96;再查相减两端是否同一层。日层末值减小时层首值,差额是必然的。

Q4:两个库的保留策略能照搬经验吗?

答:不能。InfluxDB 按 shard group 过期,TimescaleDB 按 chunk drop,参数名像,行为不一样。

Q5:点位少也要做三层吗?

答:5 万点位以下、只查近 30 天的,单层加压缩就够;1 万点位以内,分层收益抵不过运维成本。

踩坑备忘

  1. 现象:日报电量整体偏高 1.8%。原因:小时层对累计量用了 avg。解法:累计量统一改 last,函数关系写进规范。
  2. 现象:压缩后按设备查历史从 200ms 涨到 8 秒。原因:compress_segmentby 设成楼栋,要解压整个 chunk。解法:改成 device_id。
  3. 现象:每月 1 号报表缺前一天末尾几小时。原因:end_offset 只留 10 分钟。解法:放到 72 小时,回刷 4 天。
  4. 现象:保留 90 天实际占 97 天。原因:chunk 间隔 7 天,drop 以 chunk 为单位。解法:间隔压到 1 天。
  5. 现象:迁到 InfluxDB 后内存从 2GB 涨到 28GB。原因:200 万设备号做成 tag,series 基数顶满。解法:设备号落 field,tag 只留低基数字段。

总结

分层保留的本质是按查询价值分配存储:原始点服务近期争议,小时点服务对账,日点服务审计。200 万点位三年窗口从 812GB 压到 186GB,查询快两个数量级,代价是多两套聚合任务。适合点位 5 万以上、追溯三年以上的平台;1 万点位以内、只查近 30 天的系统,单层加压缩更省事。你们在降采样上踩过函数选错的坑吗,欢迎在文末交流现场参数。

参考资料

  • DL/T 645-2007《多功能电能表通信协议》,电力行业标准
  • CJ/T 188-2004《户用计量仪表数据传输技术条件》,城镇建设行业标准
  • GB/T 17215.321《交流电测量设备 特殊要求 第 21 部分》,国家标准
  • TimescaleDB 2.14 文档《Continuous Aggregates》《Compression》,Timescale 公司
  • InfluxDB 2.7 文档《Retention Policies》《TSM Engine》,InfluxData 公司
  • 本文数据接口与模型定义参考合众致达 3T-UEM 公开技术说明
相关推荐
画中有画4 小时前
时序数据库在物联网系统中的应用
数据库·物联网·时序数据库
KaiwuDB20 小时前
KaiwuDB 运维实战06:数据库备份与恢复:从理论到实战
运维·数据库·时序数据库·kaiwudb
TDengine (老段)1 天前
TDengine IDMP 十大实用功能(3)
大数据·数据库·物联网·时序数据库·iot·tdengine·涛思数据
KaiwuDB1 天前
企业级数据库数据保护:KaiwuDB KaiwuDB V3.3.0 发布:S3 冷热分层存储与主备集群能力增强实战解析模场景备份恢复方案解析
数据库·时序数据库·kaiwudb·发版·多模时序数据库·冷热分层存储·主备集群
Apache IoTDB1 天前
从 IoTDB 到 CED-DB:哈尔滨工业大学探索云边端协同时序数据库
数据库·时序数据库·iotdb
TDengine (老段)2 天前
TDengine IDMP 十大实用功能(2)
大数据·数据库·chrome·时序数据库·tdengine·idmp·有问有答
TDengine (老段)3 天前
TDengine IDMP 十大亮点功能(一)
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
东鸿电子Eastron4 天前
单相与三相电表的硬件采样架构差异
智能电表·智能电表开发·智能电表硬件
DolphinDB4 天前
QuantLib 之外,衍生品定价还能怎么选?DolphinDB Monte Carlo + PDE 工具实测
数据库·时序数据库·量化金融·dolphindb