第 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));
}
三步闸门:
- enabled 检查------停用规则直接空结果;
- 访问器白名单------metric 不在白名单立刻抛异常;
- 数值非空检查------遥测点缺该列时返回 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 同理:字段在,求值不用。设计预留了持续窗口语义,实现从简。
为什么不编造?因为告警引擎的最小闭环就是规则管理 + 求值。生命周期管理需求多样------工单、值班、推送渠道,过早实现全是负债。先把闭环跑通,状态机等真需要时再补。
总结
这篇做了四件事:
- 规则与数据分离:规则在 PG(配置数据),遥测在 TDengine(时序数据),evaluate 跨库只读编排;
- 白名单访问器:用 Map 代替 if-else,防表达式注入,与第 8 篇的 SQL 白名单同一思路;
- 纯函数求值:无状态、幂等、可重复、并发安全;
- 双保险校验:应用层 @Pattern + 数据库层 CHECK,谁也别想绕过。
告警阈值是工业系统的业务规则。写死在代码里的阈值,改一次动一次版本;散落在配置中心里的规则,不可查询也不可审计。规则表的做法,让它可查询、可停用、可按设备类型区分。
你在项目里怎么管理告警阈值?阈值写进代码、配置中心,还是规则表?
觉得有用?点个关注,持续获取优质内容。