本文针对物联网海量遥测数据的查询性能瓶颈,系统阐述通用聚合表的设计与实现路径,为企业级自定义报表统计提供一套高性能、可扩展的优化解决方案。
问题描述
前期基于遥测数据开发统计报表时,即发现查询性能存在严重短板;究其原因,设备以 1 分钟频次上报心跳数据,导致ks_tv表年数据量高达 5 亿条。当查询一个支局的整体功率因素时,由于设备数量多,常规查询已无法完成计算。而电量尖峰平谷的时段化统计,又必须依赖全量明细数据,无法通过粗粒度聚合规避性能问题。
- 当前设备数:实际物理设备数170个。
| 设备 | 类型 | 个数 |
|---|---|---|
| 网关 | 逻辑 | 2 |
| 用电主机 | 物理 | 17 |
| 传感器 | 逻辑 | 441 |
| 用电精灵 | 物理 | 153 |
| 用电精灵通道 | 逻辑 | 306 |
| 合计 | 逻辑 | 919 |
- 当前遥测表数据量统计(12月第二批设备接入)
| 月份 | SQL | 记录条数 |
|---|---|---|
| 2025.10 | select count(*) from ts_kv_2025_10 | 3259,6974 |
| 2025.11 | select count(*) from ts_kv_2025_11 | 3040,1911 |
| 2025.12 | select count(*) from ts_kv_2025_12 | 8784,3354 |
- 数据量推算
当前按1000个设备计算,每个设备只上传一种遥测数据,1次/分钟,年数据量5亿。
bash
日:1000 * 60 * 24 = 1,440,000 (144万)
月:1,440,000 * 30 = 43,200,000 (4千万)
年:43,200,000 * 12 = 518,400,000 (5亿)
问题分析
- 该类统计需求在业务场景中具备高度普遍性,且凸显三大核心特征:
- 指标计算逻辑多元,有功电量需统计累计值、功率因素需计算平均值,各类指标均需按需适配对应的统计方法;
- 统计维度灵活,可按资产目录或组织维度开展核算,因此需以设备作为基础统计单元,借助资产与设备的关联关系,实现设备数据的层级聚合;
- 报表需支撑历史数据回溯统计,需要纵向缩短时间维度的数据,规则链更倾向于资产间汇总,故此方案不可采用。
基于上述特征,本需求的核心优化目标明确为:通过对遥测数据实施按日汇总预处理,实现报表统计性能的质效提升
- 数据汇总结构
-
既有遥测数据
ts_tv的数据结构字段:
entity_id,key,ts,bool_v,str_v,long_v,dbl_v,json_v。 -
新增通用遥测汇总数据
ts_kv_daily的结构为字段:
entity_id,key,date,sum_value,count_value,avg_value,min_value,max_value -
只有数值型才涉及到聚合,因而只针对数值型字段,进行记录。在ts_tv增加时,使用触发器同步添加到本表。哪些指标要汇总,直接在触发器中定义,不定义参数表。
问题解决
- 建表语句
sql
CREATE TABLE ts_kv_daily (
entity_id UUID NOT NULL,
key INT NOT NULL,
ts BIGINT NOT NULL,
sum_value DOUBLE PRECISION DEFAULT 0,
count_value BIGINT DEFAULT 0,
avg_value DOUBLE PRECISION DEFAULT 0,
min_value DOUBLE PRECISION DEFAULT NULL,
max_value DOUBLE PRECISION DEFAULT NULL,
UNIQUE (entity_id, key, ts)
);
- 创建触发器
sql
CREATE OR REPLACE FUNCTION update_ts_kv_daily()
RETURNS TRIGGER AS $$
DECLARE
v_date DATE;
v_ts BIGINT; -- 改为 BIGINT
v_value DOUBLE PRECISION;
BEGIN
-- 根据key决定是否处理,按需增加
IF NEW.key NOT IN (96, 98) THEN
RETURN NEW;
END IF;
-- 只处理数值型数据
IF (NEW.dbl_v IS NOT NULL OR NEW.long_v IS NOT NULL) THEN
v_value := COALESCE(NEW.dbl_v, NEW.long_v::DOUBLE PRECISION);
-- 计算日期和时间戳
v_date := DATE(
(TO_TIMESTAMP(NEW.ts/1000) AT TIME ZONE 'UTC')
AT TIME ZONE 'Asia/Shanghai'
);
v_ts := (EXTRACT(EPOCH FROM
(v_date || ' 00:00:00 Asia/Shanghai')::TIMESTAMPTZ
) * 1000)::BIGINT;
-- 插入或更新 ts_kv_daily 表
INSERT INTO ts_kv_daily
(entity_id, key, ts, sum_value, count_value, avg_value, min_value, max_value)
VALUES
(NEW.entity_id, NEW.key, v_ts, v_value, 1, v_value, v_value, v_value)
ON CONFLICT (entity_id, key, ts)
DO UPDATE SET
sum_value = ts_kv_daily.sum_value + EXCLUDED.sum_value,
count_value = ts_kv_daily.count_value + 1,
avg_value = (ts_kv_daily.sum_value + EXCLUDED.sum_value) / (ts_kv_daily.count_value + 1),
min_value = LEAST(ts_kv_daily.min_value, EXCLUDED.min_value),
max_value = GREATEST(ts_kv_daily.max_value, EXCLUDED.max_value);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 删除存在的触发器
DROP TRIGGER IF EXISTS trg_update_daily_stats ON ts_kv;
-- 创建正确的触发器
CREATE TRIGGER trg_update_daily_stats
AFTER INSERT ON ts_kv
FOR EACH ROW
EXECUTE FUNCTION update_ts_kv_daily();
- 可以随时重新计算的存储过程,按天和指标计算,避免数据量太大造成耗时太长
sql
CREATE OR REPLACE FUNCTION put_ts_kv_daily(
p_date DATE,
p_key INT
)
RETURNS VOID AS $$
DECLARE
v_start_ts BIGINT;
v_end_ts BIGINT;
v_daily_ts BIGINT; -- 每日的时间戳
BEGIN
-- 计算查询的时间范围(北京时区)
v_start_ts := (EXTRACT(EPOCH FROM
(p_date::text || ' 00:00:00')::timestamp AT TIME ZONE 'Asia/Shanghai'
) * 1000)::BIGINT;
v_end_ts := (EXTRACT(EPOCH FROM
(p_date::text || ' 23:59:59.999')::timestamp AT TIME ZONE 'Asia/Shanghai'
) * 1000)::BIGINT;
-- 计算每日聚合的时间戳(北京时区当天的零点)
v_daily_ts := (EXTRACT(EPOCH FROM
(p_date::text || ' 00:00:00 Asia/Shanghai')::timestamptz
) * 1000)::BIGINT;
-- 删除旧数据(使用 ts 字段)
DELETE FROM ts_kv_daily
WHERE ts = v_daily_ts
AND key = p_key;
-- 聚合插入(使用 ts 字段)
INSERT INTO ts_kv_daily (
entity_id, key, ts, sum_value, count_value, avg_value, min_value, max_value
)
SELECT
entity_id,
p_key,
v_daily_ts, -- 使用计算出的每日时间戳
SUM(COALESCE(dbl_v, long_v::DOUBLE PRECISION)),
COUNT(*),
AVG(COALESCE(dbl_v, long_v::DOUBLE PRECISION)),
MIN(COALESCE(dbl_v, long_v::DOUBLE PRECISION)),
MAX(COALESCE(dbl_v, long_v::DOUBLE PRECISION))
FROM ts_kv
WHERE key = p_key
AND ts >= v_start_ts
AND ts <= v_end_ts
AND (dbl_v IS NOT NULL OR long_v IS NOT NULL)
GROUP BY entity_id;
RAISE NOTICE '刷新完成: 日期=%, key=%, 时间戳=%', p_date, p_key, v_daily_ts;
END;
$$ LANGUAGE plpgsql;
- 调用一次过程刷新数据
sql
-- 创建索引(包含查询所需的所有字段),否则执行put_ts_kv_daily会很慢
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ts_kv_2025_12_key_ts_covering
ON ts_kv_2025_12(key, ts, entity_id, dbl_v, long_v);
//若是要刷新多天,或者多个指标,则调用多次
SELECT put_ts_kv_daily('2025-12-31', 96);
SELECT put_ts_kv_daily('2025-12-30', 96);
SELECT put_ts_kv_daily('2025-12-29', 96);
SELECT put_ts_kv_daily('2025-12-28', 96);
SELECT put_ts_kv_daily('2025-12-27', 96);
SELECT put_ts_kv_daily('2025-12-26', 96);
SELECT put_ts_kv_daily('2025-12-25', 96);
SELECT put_ts_kv_daily('2025-12-24', 96);
SELECT put_ts_kv_daily('2025-12-23', 96);
SELECT put_ts_kv_daily('2025-12-22', 96);
SELECT put_ts_kv_daily('2025-12-21', 96);
SELECT put_ts_kv_daily('2025-12-20', 96);
SELECT put_ts_kv_daily('2025-12-19', 96);
SELECT put_ts_kv_daily('2025-12-18', 96);
SELECT put_ts_kv_daily('2025-12-17', 96);
SELECT put_ts_kv_daily('2025-12-16', 96);
SELECT put_ts_kv_daily('2025-12-15', 96);
SELECT put_ts_kv_daily('2025-12-14', 96);
SELECT put_ts_kv_daily('2025-12-13', 96);
SELECT put_ts_kv_daily('2025-12-12', 96);
SELECT put_ts_kv_daily('2025-12-11', 96);
SELECT put_ts_kv_daily('2025-12-10', 96);
SELECT put_ts_kv_daily('2025-12-09', 96);
SELECT put_ts_kv_daily('2025-12-08', 96);
SELECT put_ts_kv_daily('2025-12-07', 96);
SELECT put_ts_kv_daily('2025-12-06', 96);
SELECT put_ts_kv_daily('2025-12-05', 96);
SELECT put_ts_kv_daily('2025-12-04', 96);
SELECT put_ts_kv_daily('2025-12-03', 96);
SELECT put_ts_kv_daily('2025-12-02', 96);
SELECT put_ts_kv_daily('2025-12-01', 96);
-- 聚合完成后删除这个索引
DROP INDEX CONCURRENTLY IF EXISTS idx_ts_kv_2025_12_key_ts_covering;