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

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

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

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:

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 跨库只读。读操作天然不需要分布式事务。

信息图:规则卡片进入 Postgres 卷轴库,遥测数据流入 TDengine 塔

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

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

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 里最核心的结构:

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 是规则的外部输入,拿不到直接抛异常。
throw new IllegalArgumentException("unsupported alarm metric: " + metric);

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

信息图:9 个指标白名单格子指向求值引擎方块

compare:6 种比较符

求值的核心比较逻辑:

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 是整条链路的核心:

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 层的编排把两个数据源串起来:

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:

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 里有个参数化技巧:

"SELECT * FROM alarm_rule WHERE (NOT ? OR enabled) ORDER BY id"

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

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

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

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

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

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

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 小时。

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

端到端走一遍:

第一步,建一条规则:

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

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

curl -X POST /api/alarm-rules/1/evaluate/d_demo_001

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

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

{
  "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 了。

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

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

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

信息图:退化曲线随时间上升穿过 0.18 与 0.35 两条阈值线

边界与取舍: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,谁也别想绕过。

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

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


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

更多推荐