通过边沿检测与状态机实现告警去重与生命周期管理,以数据库未闭合记录作为状态源确保幂等性,结合REST接口与前端轮询或推送,构成从采集、判定、持久化到展示闭环的实时设备告警系统全链路方案。
实时监控告警这个功能,说起来简单,但真正落地的时候,遇到的难题比预想中更多——重复报警、僵尸告警、前端刷屏,每一个都可能让人头疼。下面就将从数据采集、告警判定、持久化、接口暴露,到前端展示、闭环处理、兜底补偿的整个链路完整梳理一遍。每个环节的关键设计要点和通用代码示例都会提供,并且尽量与具体业务解耦,方便您直接参考使用。

长期稳定更新的攒劲资源: >>>点此立即查看<<<
一个典型的实时告警系统,拆解来看,主要由五个部分组成:
┌──────────┐ 采集 ┌──────────┐ 判定/去重 ┌──────────┐
│ 数据源 │ ─────── │ 采集任务 │ ───────── │ 告警表 │
│(设备/API)│ 周期轮询 │(定时/流) │ 生成/闭环 │ (DB) │
└──────────┘ └──────────┘ └────┬─────┘
│ REST
┌──────▼─────┐ 推送/轮询 ┌─────────┐
│ 后端接口层 │ ────────── │ 前端界面 │
└────────────┘ │ +提醒 │
└─────────┘
设计时,需要先确定几个原则:
采集到的原始状态通常是电平信号——布尔量,故障为 true,正常为 false。如果每个采集周期只要状态为 true 就插入一条告警,2秒采集一次,一分钟内就会产生30条记录,一天下来表中将充满重复数据。
正确的做法是采用边沿检测(Edge Detection):仅在状态发生跳变时执行动作。
| 边沿 | 上一次 | 本次 | 动作 |
|---|---|---|---|
| 上升沿 | false | true | 生成新告警(记录 start_time) |
| 下降沿 | true | false | 关闭告警(记录 stop_time、时长) |
| 保持 | 相同 | 相同 | 不执行任何操作 |
/**
* 通用布尔量边沿检测器,线程安全
* key 通常是 "设备ID:信号名"
*/
public class EdgeDetector {
private final Map lastState = new ConcurrentHashMap<>();
public enum Edge { RISING, FALLING, NONE }
public Edge detect(String key, boolean current) {
Boolean prev = lastState.put(key, current);
if (prev == null) {
// 首次采集:将 true 视为上升沿,false 视为无事件
return current Edge.RISING : Edge.NONE;
}
if (!prev && current) return Edge.RISING;
if (prev && !current) return Edge.FALLING;
return Edge.NONE;
}
}
EdgeDetector detector = new EdgeDetector();
void onSample(String deviceId, String signal, boolean faultBit, Date now) {
String key = deviceId + ":" + signal;
switch (detector.detect(key, faultBit)) {
case RISING:
alarmService.open(deviceId, signal, now); // 生成告警
break;
case FALLING:
alarmService.close(deviceId, signal, now); // 闭合告警
break;
default:
// 状态未变,忽略
}
}
状态存储位置:单机可用内存中的 Map;多实例或需要重启不丢失数据,可以将“上一次状态”存放在 Redis 中,或者直接以数据库中“是否存在未闭合告警”作为判断依据(见第3节,这种方法最稳定,无需额外状态存储)。
相比内存中的边沿检测,更可靠的方案是:以数据库中“是否存在未闭合记录”作为状态源。这种方案天然具有幂等性,重启不丢失,多实例环境也安全。
一条告警记录包含三个关键时间点与两个派生字段:
start_time ──────(持续中)────── stop_time
total_time = stop - start(秒)
handle_status: 待确认 → 已确认 → 已处理(或 超时未确认)
public void open(Long deviceId, String alarmName, Date startTime) {
// 去重:若已存在该设备该类型"未闭合"告警,则不再新建
if (mapper.selectUnfinished(deviceId, alarmName) != null) {
return;
}
Alarm a = new Alarm();
a.setDeviceId(deviceId);
a.setAlarmName(alarmName);
a.setStartTime(startTime);
a.setAlarmLevel(resolveLevel(alarmName)); // 名称→等级映射
a.setHandleStatus(STATUS_PENDING); // 默认待确认
mapper.insert(a);
}
public boolean close(Long deviceId, String alarmName, Date stopTime) {
Alarm a = mapper.selectUnfinished(deviceId, alarmName);
if (a == null) return false; // 没有未闭合记录,忽略
long seconds = (stopTime.getTime() - a.getStartTime().getTime()) / 1000;
return mapper.updateStop(a.getId(), stopTime, seconds) > 0;
}
将“哪个告警多严重”抽成映射表,后续添加告警只需修改配置,无需改动逻辑:
public class AlarmLevel {
public static final int INFO = 1, WARN = 2, SERIOUS = 3, FATAL = 4;
private static final Map MAP = new HashMap<>();
static {
MAP.put("急停", FATAL);
MAP.put("高压报警", SERIOUS);
MAP.put("低流量报警", WARN);
// ...
}
/** 未命中默认按严重处理,确保"漏配也不漏报" */
public static int of(String name) {
return MAP.getOrDefault(name, SERIOUS);
}
}
更进一步,可以将映射表放到数据库字典表(
sys_dict_data)或独立阈值配置表中,实现运营可视化配置。
CREATE TABLE device_alarm (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
device_id BIGINT NOT NULL COMMENT '设备ID',
alarm_name VARCHAR(64) NOT NULL COMMENT '告警名称/类型',
alarm_level TINYINT NOT NULL DEFAULT 3 COMMENT '等级 1提示2预警3严重4致命',
start_time DATETIME NOT NULL COMMENT '开始时间',
stop_time DATETIME NULL COMMENT '结束时间(NULL=未恢复)',
total_time INT NULL COMMENT '持续秒数',
handle_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认1已确认2超时3已处理',
handle_user VARCHAR(64) NULL COMMENT '处理人',
handle_time DATETIME NULL COMMENT '处理时间',
handle_remark VARCHAR(500) NULL COMMENT '处理备注',
create_time DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_device_unfinished (device_id, alarm_name, stop_time),
KEY idx_stop_time (stop_time),
KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备告警表';
| 查询场景 | 索引 | 说明 |
|---|---|---|
| “该设备该类型是否有未闭合告警”(去重、闭合) | (device_id, alarm_name, stop_time) | 最高频写路径,联合索引可命中 |
| “当前所有未恢复告警”(大屏轮询) | idx_stop_time | WHERE stop_time IS NULL |
| 历史分页/导出 | idx_create_time | 按时间倒序翻页 |
stop_time IS NULL 表示未恢复——使用 NULL 而非额外的 is_finished 布尔字段,语义清晰且能与时间字段复用索引。
-- 查未闭合(去重判断)
SELECT * FROM device_alarm
WHERE device_id = #{deviceId} AND alarm_name = #{alarmName}
AND stop_time IS NULL LIMIT 1;
-- 闭合告警
UPDATE device_alarm
SET stop_time = #{stopTime}, total_time = #{seconds}
WHERE id = #{id} AND stop_time IS NULL; -- 加 stop_time IS NULL 防并发重复闭合
-- 当前未恢复列表
SELECT a.*, d.device_name
FROM device_alarm a LEFT JOIN device d ON a.device_id = d.id
WHERE a.stop_time IS NULL
ORDER BY a.alarm_level DESC, a.start_time DESC;
并发防护:UPDATE ... WHERE id= AND stop_time IS NULL 利用行锁及条件,天然防止两个线程重复闭合同一告警(第二个 UPDATE 影响 0 行)。
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | /alarm/list | 分页历史查询 |
| GET | /alarm/current | 当前未恢复告警(前端轮询) |
| GET | /alarm/{id} | 详情 |
| POST | /alarm/confirm/{id} | 确认告警 |
| POST | /alarm/batchConfirm | 批量确认 |
| POST | /alarm/handle | 处理闭环(带备注) |
| POST | /alarm/export | 导出 Excel |
查询使用 REST 名词 + GET;“确认/处理”这类状态流转操作使用动词子路径(
/confirm、/handle),比强行 PUT 整个对象更清晰、更安全——避免前端误改其他字段。
@RestController
@RequestMapping("/alarm")
public class AlarmController {
@Autowired private AlarmService service;
/** 前端轮询:当前所有未恢复告警 */
@GetMapping("/current")
public Result> current() {
return Result.ok(service.listUnfinished());
}
/** 确认(幂等) */
@PostMapping("/confirm/{id}")
public Result confirm(@PathVariable Long id) {
service.confirm(id, currentUser());
return Result.ok();
}
/** 处理闭环 */
@PostMapping("/handle")
public Result handle(@RequestParam Long id,
@RequestParam(required = false) String remark) {
service.handle(id, remark, currentUser());
return Result.ok();
}
}
@PreAuthorize)。前端要实现“实时”感知新告警,有三种技术路线可供选择:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 轮询 Polling | setInterval 定时请求 | 实现最简单,无长连接,兼容性强 | 存在延迟,有空请求开销 | 秒级实时需求,部署简单 |
| SSE | EventSource 服务端单向推送 | 原生断线重连,比 WebSocket 轻量 | 单向通信,老 IE 不支持 | 只需服务端向客户端推送 |
| WebSocket | 全双工长连接 | 真正实时,双向通信 | 需维护连接/心跳/鉴权 | 高频、双向交互需求 |
class AlarmPoller {
constructor(fetchFn, { interval = 5000, onNew } = {}) {
this.fetchFn = fetchFn; // 返回 Promise
this.interval = interval;
this.onNew = onNew;
this.knownIds = new Set();
this.timer = null;
}
start() {
this.tick(); // 立即拉取一次
this.timer = setInterval(() => this.tick(), this.interval);
}
async tick() {
try {
const list = await this.fetchFn();
// 找出本轮"新出现"的告警
const fresh = list.filter(a => !this.knownIds.has(a.id));
list.forEach(a => this.knownIds.add(a.id));
// 清理已恢复的 id,防止 Set 无限膨胀
const alive = new Set(list.map(a => a.id));
this.knownIds = new Set([...this.knownIds].filter(id => alive.has(id)));
if (fresh.length && this.onNew) this.onNew(fresh);
} catch (e) {
console.error('轮询告警失败', e); // 失败不中断下一轮
}
}
stop() { clearInterval(this.timer); this.timer = null; }
}
// 用法
const poller = new AlarmPoller(() => api.getCurrentAlarms(), {
interval: 5000,
onNew: (alarms) => alarms.forEach(a => alarmManager.alarm({
title: a.deviceName, body: a.alarmName, level: mapLevel(a.alarmLevel),
})),
});
poller.start();
轮询的两个关键技巧:
Set 内存泄漏。// 前端
const es = new EventSource('/alarm/stream');
es.addEventListener('alarm', (e) => {
const alarm = JSON.parse(e.data);
alarmManager.alarm({ title: alarm.deviceName, body: alarm.alarmName });
});
es.onerror = () => console.warn('SSE 断开,浏览器将自动重连');
// 后端 Spring MVC
@GetMapping(value = "/alarm/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
SseEmitter emitter = new SseEmitter(0L); // 不超时
emitterRegistry.add(emitter); // 存储,产生告警时广播
emitter.onCompletion(() -> emitterRegistry.remove(emitter));
return emitter;
}
// 产生新告警时:emitter.send(SseEmitter.event().name("alarm").data(alarm));
const ws = new WebSocket('wss://host/ws/alarm');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'ALARM') alarmManager.alarm(msg.payload);
};
// 心跳保活
setInterval(() => ws.readyState === 1 && ws.send('ping'), 30000);
选型建议:秒级监控大屏优先选择轮询(最稳定,运维成本最低);需要毫秒级实时性或服务端主动推送,且不想维护重连逻辑时使用 SSE;存在双向指令交互需求(如页面下发控制命令)时,才考虑 WebSocket。
新告警到达后,触发“提醒三件套”:
document.visibilityState !== 'visible'(用户离开当前页面)时弹出系统通知,避免在前台重复打扰。document.title 计数,并结合 Canvas 绘制 favicon 角标,引导用户返回查看。统一入口示例:
poller.onNew = (alarms) => {
alarms.forEach(a => alarmManager.alarm({
title: a.deviceName,
body: a.alarmName,
level: mapLevel(a.alarmLevel),
tag: 'alarm-' + a.id, // 去重,防止刷屏
}));
};
告警不能只“响”,必须能被人员闭环,否则会变成噪音。典型状态机如下:
产生 待确认 ─────── (用户点确认) ── 已确认 ── (处理完+备注) ── 已处理 │ └─(超过N分钟无人确认)── 超时未确认 ← 用于考核值班响应
public void confirm(Long id, String user) {
Alarm a = new Alarm();
a.setId(id);
a.setHandleStatus(STATUS_CONFIRMED);
a.setHandleUser(user);
a.setHandleTime(new Date());
mapper.updateSelective(a); // 只更新非空字段
}
async function confirmAlarm(id) {
await api.confirm(id);
this.$message.success('已确认');
this.refresh(); // 刷新当前告警列表
alarmManager.clear(); // 清除角标/停止声音
}
幂等性:确认/处理接口应能被重复调用而不出错(应对重复点击、网络重试)。使用“设置目标状态”而非“状态+1”的方式,即可天然实现幂等。
实时系统必须考虑异常路径,否则数据会失真:
| 问题 | 成因 | 后果 |
|---|---|---|
| 僵尸告警 | 恢复边沿丢失(设备离线、缓存过期、进程重启) | stop_time 永远为 NULL,大屏持续显示红色告警 |
| 超时未确认 | 无人值守,告警长期处于“待确认”状态 | 无法考核响应,告警堆积 |
/** 每分钟运行一次,修复未闭合告警 */
public void fallback() {
Date now = new Date();
for (Alarm a : mapper.selectAllUnfinished()) {
long elapsedMin = (now.getTime() - a.getStartTime().getTime()) / 60000;
// 1) 超时未确认 → 标记
if (a.getHandleStatus() == STATUS_PENDING && elapsedMin >= TIMEOUT_MIN) {
mapper.markTimeout(a.getId());
}
// 2) 僵尸告警 → 若数据源已恢复正常/持续离线超过阈值,则强制关闭
Boolean live = readCurrentState(a); // 从缓存/实时源读取当前状态
if (Boolean.FALSE.equals(live)) {
mapper.forceClose(a.getId(), now, "点位已恢复,兜底关闭");
} else if (live == null && offlineTooLong(a)) {
mapper.forceClose(a.getId(), now, "设备持续离线,兜底关闭");
}
}
}
Map 记录,避免瞬时网络抖动误关正常告警。| 层 | 要点 |
|---|---|
| 采集/判定 | 使用边沿检测而非电平,避免产生大量重复数据;判定逻辑与采集解耦 |
| 去重 | 以“数据库中是否存在未闭合记录”为状态源,幂等且重启不丢失 |
| 生命周期 | stop_time IS NULL 表示未恢复;闭合时计算 total_time |
| 并发 | 闭合 UPDATE 语句带 AND stop_time IS NULL 防止重复闭合 |
| 等级 | 名称→等级映射表化/字典化,漏配默认从严处理 |
| 数据库 | 联合索引 (device_id, alarm_name, stop_time) 覆盖写路径 |
| 接口 | 查询使用 REST 名词,状态流转使用动词子路径;写接口需鉴权 + 审计 |
| 推送选型 | 秒级需求用轮询,单向实时用 SSE,双向实时用 WebSocket |
| 前端去重 | 使用 id 集合 diff 判断“新增”,并清理已恢复 id 防止内存泄漏 |
| 提醒 | 声音 + 仅后台通知 + 角标;使用 tag 去重防止刷屏 |
| 闭环 | 确认/处理操作幂等,记录操作人与时间 |
| 兜底 | 定时扫描修复僵尸告警与超时未确认;防抖动误关 |
| 健壮性 | 轮询/推送失败不中断循环;异常路径必须有兜底机制 |
以上即“实时设备告警”功能从数据源到界面的全链路技术详解。核心思想可概括为三句话:采集判定用边沿、状态落库带生命周期、异常路径必有兜底;而前端则遵循只读展示 + 幂等闭环 + 恰到好处的提醒。这套模式可以直接迁移到设备监控、服务器运维告警、业务风控预警等任意实时告警场景。
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述