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

第 7 篇的模拟器里,电机设备的振动超过 0.18 就报状态码 2、超过 0.35 报 3------但这两个阈值是写死在模拟器里的。真实系统不能这样:阈值得能配、能停用、能按设备类型区分。这篇讲告警引擎:规则存 PostgreSQL,遥测在 TDengine,evaluate 实时跨库求值,一条规则从创建到触发的完整旅程。

内置阈值,写死在模拟器里

第 7 篇里,IndustrialDevice 的状态码长这样:

python 复制代码
status = 3 if vibration > 0.35 else 2 if vibration > 0.18 else 1

0.18 和 0.35 这两个阈值,是写死在 Python 模拟器里的。不可配置、不可停用、不可按设备类型区分。生产环境里,这样的阈值只能用来演示。

本篇把阈值从代码里拿出来,做成真正的告警引擎。核心就三件事:规则与数据分离、白名单访问器、纯函数求值,后面一一展开。

先看规则存在哪。

规则存哪?为什么是 PostgreSQL

第 8 篇讲过:查询层的档案数据在 PostgreSQL、时序数据在 TDengine。告警规则同理------它是配置数据:量小、低频变更、需要事务;遥测才是数据:量大、只追加。

alarm_rule 表来自 database/postgres/001_schema.sql:

sql 复制代码
CREATE TABLE IF NOT EXISTS alarm_rule (
  id BIGSERIAL PRIMARY KEY,
  name VARCHAR(128) NOT NULL,
  metric VARCHAR(32) NOT NULL,
  operator VARCHAR(8) NOT NULL CHECK (operator IN ('>', '>=', '<', '<=', '=', '!=')),
  threshold DOUBLE PRECISION NOT NULL,
  severity SMALLINT NOT NULL CHECK (severity BETWEEN 1 AND 5),
  device_type VARCHAR(32),
  factory_id VARCHAR(32) REFERENCES factory(id),
  duration_seconds INTEGER NOT NULL DEFAULT 0 CHECK (duration_seconds >= 0),
  enabled BOOLEAN NOT NULL DEFAULT TRUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

几个字段值得说:

  • CHECK 约束是双保险。应用层有 @Pattern,数据库层再锁一次,防绕过应用直接改库。
  • device_type、factory_id 可空,空就是「不限」。
  • duration_seconds 默认 0。如实说明:字段存了,但当前版本 evaluate 是瞬时求值,并不用它做持续窗口确认。

另外注意:规则 CRUD 在 PG 单库事务内,evaluate 跨库只读。读操作天然不需要分布式事务。

请求校验:白名单从入口开始

创建规则的请求体由 CreateAlarmRuleRequest 承载:

java 复制代码
public record CreateAlarmRuleRequest(
        @NotBlank @Size(max = 128) String name,
        @NotBlank
        @Pattern(regexp = "temperature|humidity|voltage|current_value|power|pressure|flow_rate|"
                + "rotational_speed|vibration")
        String metric,
        @NotBlank @Pattern(regexp = ">|>=|<|<=|=|!=") String operator,
        double threshold,
        @Min(1) @Max(5) short severity,
        @Size(max = 32) String deviceType,
        @Size(max = 32) String factoryId,
        @Min(0) @Max(86_400) int durationSeconds) {
}

metric 白名单在请求层用 @Pattern 锁死,9 项与 telemetry 表 9 个指标列一一对应。operator 只有 6 种,severity 限 1~5,durationSeconds 上限 86400(1 天)。

校验不过,请求根本进不了 Service。

白名单访问器:用 Map 代替 if-else

AlarmEvaluator 里最核心的结构:

java 复制代码
private static final Map<String, Function<TelemetryPoint, Number>> ACCESSORS = accessors();

accessors() 返回 9 个方法引用:temperature、humidity、voltage、current_value、power、pressure、flow_rate、rotational_speed、vibration,分别映射到 TelemetryPoint 的对应访问器。

注意一个细节:TelemetryPoint 是 Java record,访问器是驼峰(currentValue、flowRate),而规则里的 metric 键是下划线风格(current_value、flow_rate)。白名单的键,用的是下划线。

为什么用 Map 而不是 if-else 链或 switch?

  • 新增指标只加一行;
  • metric 是规则的外部输入,拿不到直接抛异常。
java 复制代码
throw new IllegalArgumentException("unsupported alarm metric: " + metric);

白名单访问器防的是「表达式注入」:规则里只能写白名单里的指标名,不能写任意表达式。对比第 8 篇查询白名单防 SQL 注入,这是同一思想在 Java 侧的翻版。

compare:6 种比较符

求值的核心比较逻辑:

java 复制代码
boolean compare(double actual, String operator, double threshold) {
    return switch (operator) {
        case ">" -> actual > threshold;
        case ">=" -> actual >= threshold;
        case "<" -> actual < threshold;
        case "<=" -> actual <= threshold;
        case "=" -> Double.compare(actual, threshold) == 0;
        case "!=" -> Double.compare(actual, threshold) != 0;
        default -> throw new IllegalArgumentException("unsupported alarm operator: " + operator);
    };
}

两个细节值得说:

  • =!= 用 Double.compare,浮点精确比较语义,连 NaN 行为都是确定的;
  • default 抛异常是兜底。请求层 @Pattern 已经锁死 6 种,这里理论上到不了,但防御性编程就是每一层都不信任上一层。

evaluate:一条规则从创建到触发

AlarmEvaluator.evaluate 是整条链路的核心:

java 复制代码
public Optional<AlarmEvaluation> evaluate(
        AlarmRule rule,
        String deviceId,
        TelemetryPoint point) {
    if (!rule.enabled()) {
        return Optional.empty();
    }
    Function<TelemetryPoint, Number> accessor = ACCESSORS.get(rule.metric());
    if (accessor == null) {
        throw new IllegalArgumentException("unsupported alarm metric: " + rule.metric());
    }
    Number number = accessor.apply(point);
    if (number == null) {
        return Optional.empty();
    }
    double actual = number.doubleValue();
    boolean triggered = compare(actual, rule.operator(), rule.threshold());
    return Optional.of(new AlarmEvaluation(
            rule.id(),
            rule.name(),
            deviceId,
            rule.metric(),
            actual,
            rule.operator(),
            rule.threshold(),
            rule.severity(),
            Instant.now(),
            triggered));
}

三步闸门:

  1. enabled 检查------停用规则直接空结果;
  2. 访问器白名单------metric 不在白名单立刻抛异常;
  3. 数值非空检查------遥测点缺该列时返回 Optional.empty,不抛异常。缺数据的设备静默跳过。

AlarmEvaluation 是一次不可变快照:ruleId、ruleName、deviceId、metric、actualValue、operator、threshold、severity、evaluatedAt、triggered,规则、实测值、比较、时间戳全装进去。

Service 层的编排把两个数据源串起来:

java 复制代码
public AlarmEvaluation evaluate(long ruleId, String deviceId) {
    AlarmRule rule = findById(ruleId);
    TelemetryPoint point = telemetryRepository.findLatest(deviceId)
            .orElseThrow(() -> new NotFoundException("telemetry not found for device: " + deviceId));
    return evaluator.evaluate(rule, deviceId, point)
            .orElseThrow(() -> new IllegalArgumentException("rule is disabled or metric value is null"));
}

规则从 PG 查,最新点从 TDengine 查。findLatest 的 SQL:

sql 复制代码
SELECT ts, temperature, humidity, voltage, current_value, power,
       pressure, flow_rate, rotational_speed, vibration,
       status_code, sequence_no
FROM iot.telemetry
WHERE device_id = ?
ORDER BY ts DESC
LIMIT 1

这就是「规则与数据分离」在代码里的样子。evaluate 是纯函数:同样的规则和最新点,永远算出同样的结果。只读不写,天然并发安全。

Controller:五个接口与参数化技巧

AlarmRuleController 挂在 /api/alarm-rules 下:

  • POST / → 201 CREATED,创建规则
  • GET /?enabledOnly=false → 列表,默认全量
  • GET /{id} → 详情
  • PATCH /{id}/enabled?value=true → 停用/启用
  • POST /{id}/evaluate/{deviceId} → 求值

注意:停用用 PATCH enabled 而不是 DELETE。配置类数据要留审计痕迹,停用即刻生效------evaluate 的第一道闸门就是 enabled。

Repository 里有个参数化技巧:

java 复制代码
"SELECT * FROM alarm_rule WHERE (NOT ? OR enabled) ORDER BY id"

PostgreSQL 的布尔参数可以直接 NOT。一个参数化条件切换「全部/仅启用」,不用动态拼 SQL。这和第 8 篇的 CAST(? AS VARCHAR) IS NULL 是同一个思路。

setEnabled 也用了简洁的原子写法:

java 复制代码
return jdbc.update(
        "UPDATE alarm_rule SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
        enabled, id) == 1;

影响行数等于 1 才判存在,不先查再改,避免 TOCTOU。insert 用 RETURNING * 一步拿回完整规则,和第 8 篇呼应。

工业电机案例:从内置阈值到可配置规则

回到第 7 篇的模拟器,看数据怎么来的:

python 复制代码
self.degradation = min(1.0, self.degradation + self._random.uniform(0, 0.00001))
load = 0.65 + 0.25 * math.sin(self.sequence / 90)
load += self._random.gauss(0, 0.02)
rotational_speed = round(1450 * load + self._random.gauss(0, 5))
vibration = 0.02 + load * 0.04 + self.degradation * 0.5
vibration += abs(self._random.gauss(0, 0.006))
pressure = 0.8 + load * 1.4 + self._random.gauss(0, 0.03)
flow_rate = 20 + load * 80 + self._random.gauss(0, 1)
temperature = 30 + load * 35 + vibration * 12
status = 3 if vibration > 0.35 else 2 if vibration > 0.18 else 1

退化每轮加 uniform(0, 1e-5),均值 5e-6/轮,封顶 1.0。振动 = 0.02 + load×0.04 + 退化×0.5 + |噪声|------振动是退化主导的指标。

时间线估算一下(注意是估算):load 均值约 0.65 时,振动 ≈ 0.046 + 0.5d + 噪声。d 爬到 0.268(约 5.4 万轮),振动均值触到 0.18;d 爬到 0.608(约 12 万轮),触到 0.35。按 1Hz 节拍,大约 15 小时和 33 小时。

负载波动和噪声会让实际触发时间浮动。内置阈值只能告诉你大概,规则才能精确管理。

端到端走一遍:

第一步,建一条规则:

bash 复制代码
curl -X POST /api/alarm-rules \
  -H "Content-Type: application/json" \
  -d '{"name":"振动偏高预警","metric":"vibration","operator":">","threshold":0.18,"severity":3,"deviceType":"industrial"}'

返回带 id。第二步,对新设备立即求值:

bash 复制代码
curl -X POST /api/alarm-rules/1/evaluate/d_demo_001

此时振动约 0.05~0.07,triggered 是 false。

第三步,模拟器跑 5 万+ 轮后再求值:

json 复制代码
{
  "ruleId": 1,
  "ruleName": "振动偏高预警",
  "deviceId": "d_demo_001",
  "metric": "vibration",
  "actualValue": 0.234,
  "operator": ">",
  "threshold": 0.18,
  "severity": 3,
  "evaluatedAt": "2026-08-18T10:00:00Z",
  "triggered": true
}

actualValue 0.234 超过 0.18,triggered 变 true。此时设备状态码大概也已经是 2 了。

第四步,再建一条更严重的规则:

bash 复制代码
curl -X POST /api/alarm-rules \
  -H "Content-Type: application/json" \
  -d '{"name":"振动严重","metric":"vibration","operator":">","threshold":0.35,"severity":5}'

同一设备早期求值,这条是 false。两条规则、两个阈值、两个 severity,同一份数据,各自独立判断。可配置规则的价值在这就体现出来了。

边界与取舍:alarm_state 是留白

讲到这里,必须老实交代哪些没做。

001_schema.sql 里预留了 alarm_state 表,字段齐全:rule_id、device_id、event_time、current_value、status(OPEN/ACKED/CLOSED)、acknowledged_by、acknowledged_at,还有 open 状态索引。但当前实现没有写入它------evaluate 是同步实时求值,告警的「生命周期」(确认、关闭、恢复)是留给上层或后续的功能。

duration_seconds 同理:字段在,求值不用。设计预留了持续窗口语义,实现从简。

为什么不编造?因为告警引擎的最小闭环就是规则管理 + 求值。生命周期管理需求多样------工单、值班、推送渠道,过早实现全是负债。先把闭环跑通,状态机等真需要时再补。

总结

这篇做了四件事:

  1. 规则与数据分离:规则在 PG(配置数据),遥测在 TDengine(时序数据),evaluate 跨库只读编排;
  2. 白名单访问器:用 Map 代替 if-else,防表达式注入,与第 8 篇的 SQL 白名单同一思路;
  3. 纯函数求值:无状态、幂等、可重复、并发安全;
  4. 双保险校验:应用层 @Pattern + 数据库层 CHECK,谁也别想绕过。

告警阈值是工业系统的业务规则。写死在代码里的阈值,改一次动一次版本;散落在配置中心里的规则,不可查询也不可审计。规则表的做法,让它可查询、可停用、可按设备类型区分。

你在项目里怎么管理告警阈值?阈值写进代码、配置中心,还是规则表?


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

相关推荐
省长2 小时前
Sa-Token v1.46.0 发布 🚀,来看看有没有令你心动的功能!
java·后端·开源
LayZhangStrive3 小时前
融360 一面
java·面试·后端开发
城管不管3 小时前
重生——第十次面试之开源中国一面挂
java·linux·开发语言·算法·面试·职场和发展·开源
Java小白笔记3 小时前
Windows系统免软件命令激活
java·网络·人工智能·windows·ai·ai编程
爱敲键盘的猴子3 小时前
深入理解 Java HashMap
java·hashmap
霸道流氓气质3 小时前
Java开发者AI编程常用MCP、Skills、Rules全景汇总
java·开发语言·ai编程
天涯醉青楼3 小时前
Spring Boot 接口返回 Bean 时字段名“变形”的深度剖析与解决方案
java
步行cgn4 小时前
MyBatis 高级映射与延迟加载完全指南
java·tomcat·mybatis
thefool1122664 小时前
从中序与后序遍历序列构造二叉树
java
吴声子夜歌4 小时前
Java面试题——JVM(二)
java·开发语言·jvm