【系列:TDengine 工业物联网实战:从零搭起可运行系统 · 第 3 篇】

窗口聚合、每设备最新值、离线检测,是时序查询三个高频场景。本文用 TDengine 实战代码讲 INTERVAL + _wstart、LAST_ROW()、HAVING LAST(ts) 三种写法,解释为什么聚合列敢拼 SQL、条件却必须参数绑定,以及 31 天/5000 行边界背后的资源保护逻辑。

设备看板前,你盯着三块屏,心里反复冒出三个问题:

这 10 秒的平均温度是多少?每台设备的最新值是什么?哪些设备已经掉线了?

这三个问题恰好对应时序查询的三个高频场景:窗口聚合、每设备最新值、离线检测。上一篇我们用超级表 + TAG 完成了建模(没看过的读者可以回看第 2 篇),今天进入查询层,用 TelemetryRepository.java 的实战代码把这三种写法过一遍。

第一板斧:窗口聚合,把时间切成桶

先看最常用的场景:按固定时间窗统计趋势 。TelemetryRepository.aggregate 方法的核心 SQL 如下:

java 复制代码
String sql = """
        SELECT _wstart AS window_start,
               AVG(%s) AS average_value,
               MIN(%s) AS minimum_value,
               MAX(%s) AS maximum_value,
               COUNT(%s) AS samples
        FROM iot.telemetry
        WHERE device_id = ? AND ts >= ? AND ts < ?
        INTERVAL(%s)
        """.formatted(safeMetric, safeMetric, safeMetric, safeMetric, safeInterval);

INTERVAL(10s/30s/1m/5m/15m/1h/1d) 是 TDengine 的分桶语法------把时间轴切成固定宽度的窗口,每个窗口返回一行,聚合函数只在该窗口内计算。

关键在 _wstart 这个伪列,它代表窗口的起始时间 。配合 _wend(窗口结束)和 _wduration(窗口时长),能精确知道每个桶落在哪个时间段。前端画趋势图时横轴直接用 _wstart,不用在应用层自己换算。

对比关系型 SQL:INTERVAL 分桶 ≈ 手写 GROUP BY 时间片,但窗口是自动对齐的 ,不用自己算区间边界。比如 INTERVAL(10s),所有桶都对齐到整 10 秒。

结果集每行是一个窗口,包含 window_start、平均值、最小值、最大值和样本数。AVG/MIN/MAX/COUNT 四个指标加 samples 样本数,够渲染一张迷你趋势卡片了。

requireMetric 和 requireInterval 是白名单校验,不合法直接抛 IllegalArgumentException------这一点后面专门讲。

第二板斧:一条 SQL 拿所有设备最新值

设备看板上,「当前值」是最直观的模块。有两种写法,对应不同场景。

写法 A:查单台设备最新一条

sql 复制代码
SELECT ts, temperature, ..., status_code, sequence_no
FROM iot.telemetry
WHERE device_id = ?
ORDER BY ts DESC
LIMIT 1

按时间戳倒序取第一条,逻辑简单直白。

写法 B:所有设备一次拿完

sql 复制代码
SELECT LAST_ROW(*) FROM telemetry PARTITION BY device_id;

LAST_ROW() 返回每个分区的最后一行(整行),PARTITION BY device_id 按设备分组。一条 SQL 就把全部设备的最新值拿回来 ,应用层不用写循环。你熟悉的 ROW_NUMBER() OVER (PARTITION BY ...) 在这里完全不需要。

有了最新值,在线判定顺理成章。TelemetryService 里的逻辑:

java 复制代码
point.timestamp().isAfter(Instant.now().minus(ONLINE_THRESHOLD))

其中 ONLINE_THRESHOLD = Duration.ofMinutes(5)------最新一条数据在 5 分钟内,判定为在线。这是物联网里典型的「心跳」思路:设备持续上报,只要你还在说话,就当你活着。

第三板斧:用 HAVING 揪出离线设备

最新值回答「现在什么状态」,离线检测要回答「谁消失了」。两者本质不同:前者是查存在,后者是查缺席。

offlineDeviceIds 的核心 SQL:

java 复制代码
String sql = """
        SELECT device_id
        FROM iot.telemetry
        GROUP BY device_id
        HAVING LAST(ts) < ?
        LIMIT ?
        """;

拆解:GROUP BY device_id 按设备分组,LAST(ts) 取出组内最后一条记录的时间戳 ,HAVING LAST(ts) < ? 过滤出最后上报时间早于 cutoff 的设备------这些就是离线的。

注意 LAST(ts) 是聚合函数 ,不是伪列。它返回分组内最新一条记录的时间戳,和 _wstart 有本质区别------一个是计算结果,一个是窗口元数据。

002_examples.sql 里还有一个变体,把最后上报时间也查出来,排查起来更方便:

sql 复制代码
SELECT device_id, LAST(ts) AS last_seen
FROM telemetry
GROUP BY device_id
HAVING LAST(ts) < NOW - 5m

NOW - 5m 是 TDengine 的时间表达式,等价于应用层传入 cutoff。HAVING 对聚合结果过滤,语义和关系型 SQL 一致,只是 LAST() 这个聚合函数是时序库特有的。换成关系型 SQL,得自连接或窗口函数才能实现「取每组最后一条再过滤」,TDengine 一行搞定。

cutoff 的计算也有讲究:cutoff = now - minutes,minutes 被限制在 1~43200(30 天),防止误传一个天荒地老的时间把全表扫穿。

为什么敢拼 SQL?白名单 + 参数绑定双保险

细心的读者会发现:aggregate 方法里用了 .formatted() 拼 SQL,而其他查询全部用的 ? 参数绑定。这是不是 SQL 注入的隐患?

拼接的每个值,都先过了白名单校验。

  • 参数绑定 (? 占位):device_id、start、end、offset、limit、cutoff------所有用户传入的原始值,全部走 ? + jdbc 参数绑定
  • 拼接 (.formatted()):只有 metric 和 interval------它们先经过 requireMetric / requireInterval 的 Set.contains 校验,不合法直接抛异常

METRICS 白名单有 9 个指标名,INTERVALS 白名单有 7 个窗口值,都是代码写死的枚举,用户输入绕不过去。

为什么不干脆全参数绑定?因为 INTERVAL(?) 和聚合函数列名不能参数化 ------TDengine 的驱动不支持列名占位符,INTERVAL(?) 也会被当成字面量。所以只能白名单校验后拼接。这是「受限拼接」的正确姿势:先验证,再拼接;不信任任何原始输入。

31 天、5000 行:查询边界的资源保护逻辑

时序库最怕什么?大范围无 LIMIT 扫全表。一台设备每秒上报一条,365 天就是 3153 万行,全量查询直接拖垮集群。

QueryRangeValidator 用几个硬边界兜底。看 application.yml 的默认值:

yaml 复制代码
tdengine:
  query-timeout-seconds: ${TDENGINE_QUERY_TIMEOUT:30}
  max-range-days: ${TDENGINE_MAX_RANGE_DAYS:31}
  max-page-size: ${TDENGINE_MAX_PAGE_SIZE:5000}
  • validate(start, end):三个条件------非空、start < end、跨度 ≤ 31 天
  • validateLimit:1 ≤ limit ≤ 5000,单页上限,防止有人一把梭 LIMIT 1000000
  • validateOffset:offset ≥ 0,负数直接拒绝

还有两个实战技巧。第一个是 limit+1 翻页:

java 复制代码
repository.findTelemetry(..., safeLimit + 1)

多查一行判断「是否还有下一页」,PageResponse 再截断回 safeLimit。一次查询同时拿到数据和分页信息,不用额外 COUNT。

第二个是 30 秒查询超时 。query-timeout-seconds 从环境变量注入,默认 30 秒。慢查询直接掐掉,避免拖垮连接池------生产环境里,超时是比报错更重要的保护机制。

小结:三个问题,三种套路

回到开头的三个问题,现在都有标准答案:

问题 核心写法 关键词
这 10 秒平均温度多少? INTERVAL + 聚合函数 _wstart、AVG/MIN/MAX/COUNT
每台设备最新值是什么? LAST_ROW(*) PARTITION BY 在线判定 5 分钟阈值
哪些设备掉线了? GROUP BY + HAVING LAST(ts) 缺席检测、cutoff

聚合看趋势(窗口)、最新看状态(LAST_ROW)、缺席看离线(HAVING)。再加上白名单拼接和边界控制,一个完整的时序查询层就立起来了。

下一篇文章进入写入侧:用 Python 把真实设备数据灌进 TDengine。你会发现「查得对」的前提是「写得好」------两条线会在超级表模型上汇合。


觉得有用?点个关注,持续获取优质内容。

相关推荐
MuMuMu122315 小时前
越华环保集团碳惠小屋:物联网 + 区块链赋能碳普惠终端,构建可信个人碳账户体系
物联网
注塑机数据采集联网15 小时前
如何解决注塑机数据采集联网对接MES系统数据WiFi传输数据不稳问题
大数据·数据库·物联网·数据挖掘·数据分析
liubinkaixin16 小时前
海上风电叶片试验的上千片应变片,长线干扰怎么解
物联网
数字新视界16 小时前
机房动环监控提升数据中心安全性
物联网·3d·数字孪生·三维可视化·数据可视化
serene9416 小时前
Modbus“出道史”:1979年诞生,后来一路火到现在
物联网·开发·modbus·通讯协议·模拟器
黄鹂鸟科技18 小时前
软件定制开发设计要点:从需求梳理、原型确认到敏捷交付与持续维护
后端·物联网·系统架构
虎王物联18 小时前
随身WiFi什么时候能恢复出厂:去控机和硬刷机的差别
物联网·随身wifi
IT规划师18 小时前
MQTT实战 - QoS 0-1-2 到底怎么选?一张图看懂(四)
物联网·mqtt·.net core·iot·qos
钱工的门窗技术笔记1 天前
智能门窗的边界与前提:为什么“智能”不能替代本体性能
网络·物联网·智能家居