SpringBoot中实现告警判定算法-位报警与模拟量阈值-技术详解
适用场景:IoT / 工业监控 / 设备告警 / 任何"周期采样 → 判定是否告警 → 落库 → 恢复"的系统。
目录
- 为什么告警判定要分两类
- 第一类:位报警(布尔量)------ 边沿检测
- 第二类:模拟量阈值 ------ 双阈值迟滞(回差)
- 两类算法对比
- 告警生命周期与去重(两类共用)
- 告警分级映射
- 本项目落地实现解析
- 通用示例代码
- 设计要点与踩坑清单
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
一、为什么告警判定要分两类
设备采集回来的信号本质上分两种物理类型,判定方式完全不同:
| 信号类型 | 例子 | 取值 | 判定方式 |
|---|---|---|---|
| 开关量 / 布尔量(位) | 急停、水泵故障、风机故障、缺水故障 | true/false(1 个 bit) | 边沿检测:状态跳变才动作 |
| 模拟量 / 连续量 | 粉尘浓度 TSP、压力、流量、电流 | 连续数值 | 阈值比较:越过阈值才动作,且要防抖动 |
如果对这两类用同一种判定方式,会出问题:
- 布尔量若"只要为 true 就插一条告警",2 秒一采会瞬间刷出成千上万条重复记录。
- 模拟量若"只要
值 > 阈值就告警、值 <= 阈值就恢复",当数值在阈值附近抖动时会疯狂地"告警---恢复---告警---恢复"(告警抖动 / flapping)。
因此需要两套算法:位报警用边沿检测去重 ,模拟量用双阈值迟滞防抖。
二、第一类:位报警(布尔量)------ 边沿检测
2.1 电平 vs 边沿
- 电平(Level):信号当前的状态值(true/false)。
- 边沿(Edge) :信号状态发生的跳变 。
- 上升沿 :false → true(正常→故障),此刻应生成告警。
- 下降沿 :true → false(故障→正常),此刻应恢复告警。
- 保持(状态未变):什么都不做。
只在"跳变"时动作,就天然实现了去重------一次故障从发生到恢复,只产生一条告警记录(一次上升沿开、一次下降沿关)。
2.2 算法本质:本次值与上次值对比
边沿检测需要保存"上一次的状态"。每轮采集把"本次值"与"上次值"对比:
if (!oldStatus && newStatus) -> 上升沿 -> 生成告警
if (oldStatus && !newStatus) -> 下降沿 -> 恢复告警
否则 -> 无动作
状态存哪里? 本项目把上一轮整包 PLC 数据缓存在 Redis(10s 过期),下一轮取出来做对比,既做了边沿检测的"上次状态源",又避免了额外维护状态表。
2.3 空值处理
传感器/通信可能读到 null。判定前要归一化(本项目把 null 视为"正常 false"),避免 NPE 且语义安全:
java
oldStatus = oldStatus == null ? false : oldStatus;
newStatus = newStatus == null ? false : newStatus;
三、第二类:模拟量阈值 ------ 双阈值迟滞(回差)
3.1 单阈值的抖动问题
若只用一个阈值 T:值 > T 告警、值 <= T 恢复。当实际值在 T 附近小幅波动(如 T=150,值在 149~151 抖动)时,会产生大量"告警→恢复→告警→恢复",即告警抖动(flapping),既刷屏又无意义。
3.2 迟滞(Hysteresis)/ 回差带
解决办法是用两个阈值构成一个"回差带":
-
报警阈值
alarmThreshold(高) :值 > alarmThreshold才触发告警。 -
预警/恢复阈值
warnThreshold(低) :值 <= warnThreshold才恢复告警。 -
两者之间的区间(
warnThreshold < 值 <= alarmThreshold)是"死区",维持当前状态不变。浓度
▲
│ ┌─────────── 告警区(> alarmThreshold 触发)
alarmThreshold ─┤
│ │ 死区(维持现状,不翻转)
warnThreshold ──┤
│ └─────────── 恢复区(<= warnThreshold 才恢复)
└───────────────────────────▶ 时间
这样,值必须先冲高越过报警线 才告警,之后必须回落到预警线以下才恢复------中间的小抖动被死区吸收,告警不再来回翻转。这正是工业上常见的"施密特触发器(Schmitt Trigger)"思想。
3.3 采样频率控制
模拟量还常配合采样降频:即使采集任务 2s 一轮,TSP 可以只每 N 秒采样并入库一次(比如tsp.sample.interval` 默认 10s),减少历史数据量与写入压力。
四、两类算法对比
| 维度 | 位报警(边沿检测) | 模拟量阈值(双阈值迟滞) |
|---|---|---|
| 输入 | 布尔量(1 bit) | 连续数值 |
| 触发条件 | 上升沿 false→true | 值 > alarmThreshold |
| 恢复条件 | 下降沿 true→false | 值 <= warnThreshold |
| 去重手段 | 依赖"上次状态"对比 | 依赖回差带 + 未闭合记录判断 |
| 防抖手段 | 天然去重(跳变才动作) | 回差带(死区)吸收抖动 |
| 状态源 | 上次采集值(本项目存 Redis) | 当前值 + 数据库未恢复记录 |
| 典型信号 | 急停、水泵/风机故障、缺水 | 粉尘浓度、压力、温度 |
五、告警生命周期与去重(两类共用)
不论哪类算法,产生的告警都落成一条有生命周期的记录:
start_time ────(持续中,stop_time = NULL)────▶ stop_time
total_time = stop - start
handle_status: 0待确认 → 1已确认 / 2超时未确认 → 3已处理
5.1 触发(开告警)
生成一条新记录:设置设备/料棚、告警名、开始时间、等级、处理状态=待确认、备注。
5.2 恢复(关告警)
找到该设备该告警"未闭合"的记录,写入 stop_time(并可计算持续时长)。
5.3 去重的两种实现
- 位报警:靠"上次状态"对比。只有上升沿才插入,天然不会重复插。
- 模拟量 :额外用"数据库是否存在未恢复记录 "兜底去重(
selectUnfinishedAlarm(...) != null)。这样即使进程重启(内存态丢失)或多实例并发,也不会对同一持续告警重复插入。
经验:以"数据库未闭合记录"作为状态源,比纯内存状态更健壮(重启不丢、多实例安全),是模拟量告警推荐的去重方式。
六、告警分级映射
把"告警名称 → 严重等级"抽成映射表,新增告警只改配置、不改判定逻辑:
- 等级:1 提示 / 2 预警 / 3 严重 / 4 致命。
- 未命中默认按"严重"处理,保证"漏配也不漏报"。
AlarmConstants 的映射示例:急停、总故障=致命(4);各类故障=严重(3);低流量、TSP 超标=预警(2)。
七、项目落地实现解析
7.1 位报警:checkAlarmStatus(边沿检测)
采集任务每轮取出上一轮缓存的 oldData 与本轮 newData,对 11 类布尔故障逐一做边沿检测:
java
private void checkAlarmStatus(BusDevice device, Long shedId, String alarmName,
Boolean oldStatus, Boolean newStatus, Date now) {
Long deviceId = device.getId();
oldStatus = oldStatus == null ? false : oldStatus; // 空值归一化
newStatus = newStatus == null ? false : newStatus;
// 上升沿:正常→报警,生成新告警
if (!oldStatus && newStatus) {
BusDeviceAlarm alarm = new BusDeviceAlarm();
alarm.setShedId(shedId);
alarm.setDeviceId(deviceId);
alarm.setAlarmName(alarmName);
alarm.setStartTime(now);
alarm.setAlarmLevel(AlarmConstants.getAlarmLevel(alarmName)); // 名称→等级
alarm.setHandleStatus(AlarmConstants.HANDLE_STATUS_PENDING);
alarm.setRemark("PLC触发" + alarmName + ",设备:" + device.getDeviceName()
+ ",PLC:" + device.getPlcAddress());
busDeviceAlarmService.insertBusDeviceAlarm(alarm);
}
// 下降沿:报警→正常,恢复告警
if (oldStatus && !newStatus) {
busDeviceAlarmService.updateAlarmStopTime(deviceId, alarmName, now);
}
}
调度侧对多类故障批量判定(急停、风机/水泵故障、摇摆/仰俯/超限位/编码器/缺水故障、总故障等):
java
checkAlarmStatus(device, shedId, "急停", oldData.getEStop(), newData.getEStop(), now);
checkAlarmStatus(device, shedId, "摇摆故障", oldData.getSwingFault(), newData.getSwingFault(), now);
// ... 其余同理
"上次状态"从哪来?------上一轮整包数据缓存在 Redis:
java
String plcCacheKey = Constants.PLC_KEY + device.getPlcAddress();
PlcData oldPlcData = redisCache.getCacheObject(plcCacheKey); // 取上次
redisCache.setCacheObject(plcCacheKey, plcData, 10, TimeUnit.SECONDS); // 存本次
detectAndUploadAlarm(device, oldPlcData, plcData); // 边沿检测
首次采集
oldData == null时直接 return,不做判定(无历史可比)。
7.2 模拟量阈值:tspThresholdCheck(双阈值迟滞 + DB 去重)
java
private void tspThresholdCheck(BusDevice device, double tspValue, Date now) {
Long deviceId = device.getId();
Long shedId = device.getShedId();
// 阈值配置:设备级优先,料棚级兜底
BusTspThresholdConfig config =
tspThresholdConfigService.selectEnableConfigByDeviceId(deviceId, shedId);
if (config == null) return; // 无配置不检测
double alarmThreshold = config.getAlarmThreshold().doubleValue(); // 报警阈值(高)
double warnThreshold = config.getWarnThreshold().doubleValue(); // 恢复阈值(低)
// DB 去重:以"未恢复记录"为准,重启/多实例不重复插
boolean isCurrentlyAlarming =
busDeviceAlarmService.selectUnfinishedAlarm(deviceId, "TSP粉尘浓度超标") != null;
if (tspValue > alarmThreshold) {
if (!isCurrentlyAlarming) { // 触发(越过高阈值 且 当前无未恢复记录)
BusDeviceAlarm alarm = new BusDeviceAlarm();
alarm.setShedId(shedId);
alarm.setDeviceId(deviceId);
alarm.setAlarmName("TSP粉尘浓度超标");
alarm.setStartTime(now);
alarm.setAlarmLevel(AlarmConstants.getAlarmLevel("TSP粉尘浓度超标"));
alarm.setHandleStatus(AlarmConstants.HANDLE_STATUS_PENDING);
alarm.setRemark("TSP浓度=" + tspValue + "μg/m³,报警阈值=" + alarmThreshold + "...");
busDeviceAlarmService.insertBusDeviceAlarm(alarm);
}
} else if (tspValue <= warnThreshold) { // 恢复(回落到低阈值以下)
if (isCurrentlyAlarming) {
busDeviceAlarmService.updateAlarmStopTime(deviceId, "TSP粉尘浓度超标", now);
}
}
// warnThreshold < tspValue <= alarmThreshold 落在"死区",维持现状不动作
}
采样降频(tsp.sample.interval 默认 10s)与开关(tsp.sample.enabled)由 sampleAndCheckTsp 控制,超标检查前还会异步写一条 TSP 历史记录。
7.3 阈值配置的分级兜底
selectEnableConfigByDeviceId(deviceId, shedId):优先取设备级 阈值配置,没有则回退到料棚级配置。这让运营能"整棚统一配 + 个别设备特殊配",是"配置驱动 + 就近覆盖"的典型设计。
八、通用示例代码
8.1 通用边沿检测器(位报警)
java
public class EdgeAlarmDetector {
public enum Edge { RISING, FALLING, NONE }
/** 对比上次/本次布尔状态,返回边沿类型 */
public static Edge detect(Boolean oldStatus, Boolean newStatus) {
boolean o = oldStatus != null && oldStatus;
boolean n = newStatus != null && newStatus;
if (!o && n) return Edge.RISING; // 触发
if (o && !n) return Edge.FALLING; // 恢复
return Edge.NONE;
}
}
// 使用
switch (EdgeAlarmDetector.detect(old, now)) {
case RISING: alarmService.open(deviceId, name); break;
case FALLING: alarmService.close(deviceId, name); break;
default: /* 无动作 */
}
8.2 通用双阈值迟滞判定(模拟量)
java
public class HysteresisAlarm {
private final double high; // 触发阈值
private final double low; // 恢复阈值 (low < high)
public HysteresisAlarm(double high, double low) {
if (low >= high) throw new IllegalArgumentException("low 必须小于 high");
this.high = high;
this.low = low;
}
public enum Action { TRIGGER, RECOVER, KEEP }
/**
* @param value 当前测量值
* @param alarmingNow 当前是否处于告警中(建议来自"数据库未闭合记录")
*/
public Action evaluate(double value, boolean alarmingNow) {
if (value > high && !alarmingNow) return Action.TRIGGER; // 越过高阈值且未在告警
if (value <= low && alarmingNow) return Action.RECOVER; // 回落到低阈值以下且在告警
return Action.KEEP; // 死区/状态不变
}
}
// 使用
boolean alarming = alarmService.hasUnfinished(deviceId, "浓度超标");
switch (new HysteresisAlarm(150, 100).evaluate(value, alarming)) {
case TRIGGER: alarmService.open(deviceId, "浓度超标"); break;
case RECOVER: alarmService.close(deviceId, "浓度超标"); break;
default: /* KEEP,不动作 */
}
8.3 告警名称 → 等级映射(配置化)
java
public class AlarmLevels {
public static final int INFO=1, WARN=2, SERIOUS=3, FATAL=4;
private static final Map<String,Integer> MAP = new HashMap<>();
static {
MAP.put("急停", FATAL);
MAP.put("浓度超标", WARN);
// ...
}
/** 未命中默认从严,避免漏报 */
public static int of(String name) { return MAP.getOrDefault(name, SERIOUS); }
}
九、设计要点与踩坑清单
| 主题 | 要点 |
|---|---|
| 区分信号类型 | 布尔量走边沿检测,模拟量走双阈值迟滞,不可混用。 |
| 位报警去重 | 只在跳变时动作;靠"上次状态"对比(本项目缓存在 Redis)。 |
| 首次采集 | oldData == null 时不判定,避免把初始状态误当上升沿。 |
| 空值归一化 | 读到 null 统一按"正常/无数据"处理,防 NPE 与误报。 |
| 模拟量防抖 | 必须用双阈值(回差带),单阈值会告警抖动刷屏。 |
| high/low 关系 | 恢复阈值必须严格小于触发阈值,否则死区不成立。 |
| 模拟量去重 | 以"数据库未闭合记录"为状态源,重启/多实例安全。 |
| 采样降频 | 模拟量可独立于采集频率降频入库(如 10s),减小数据量。 |
| 告警生命周期 | 触发写 start,恢复写 stop(可算时长);stop_time IS NULL 表未恢复。 |
| 分级映射 | 名称→等级配置化,漏配默认从严。 |
| 阈值配置分级 | 设备级优先、料棚级兜底,兼顾统一与个性化。 |
| 异常隔离 | 单个告警判定异常应 try-catch 兜底,不影响整轮采集与其它设备。 |
小结
告警判定的核心是"按信号物理类型选算法 ":布尔量用边沿检测 (跳变才动作,天然去重),模拟量用双阈值迟滞 (回差带吸收抖动)。两者最终都收敛到统一的"带生命周期的告警记录 + 未闭合记录去重 + 名称分级 "模型。本项目在 GetModbusTCPDataTask 中用 checkAlarmStatus(位报警)与 tspThresholdCheck(模拟量)分别实现了这两类算法,并用 Redis 缓存上次状态、数据库未闭合记录双重去重,构成了一套健壮、可平移的工业告警判定范式。