工程笔记

IoT 重复告警与 Redis 分布式锁实践

在铁路隐蔽工程智能监测系统中,设备传感器数据上报频率较高时,会出现同一设备的同类型告警被重复创建的问题。这导致告警记录冗余,影响系统的可靠性和用户体验。

问题现象

铁路隐蔽工程监测系统上线后,告警表出现了大量重复记录。查日志发现,同一设备、同一类型的告警,在 1 毫秒内被两个线程各创建了一次:

1
2
19:09:35.723 [pool-2-thread-25] 创建告警 - 设备: WT6B00000147, 类型: 振动超限
19:09:35.724 [pool-2-thread-30] 创建告警 - 设备: WT6B00000147, 类型: 振动超限

传感器上报频率高,数据通过线程池并行处理,同一个设备的数据被两个线程同时拿到了。

根因

问题出在原来的告警创建逻辑:

1
2
3
if (!isActiveAlarmExist(deviceSn, alarmType)) {
createAlarmRecord(deviceSn, alarmType, alarmValue, thresholdValue);
}

isActiveAlarmExist() 查 Redis,createAlarmRecord() 写数据库并设置 Redis key。两个操作不是原子的。

线程 A 查到”没有告警”,还没写进去,线程 B 也查到”没有告警”,于是两条都写进去了。这就是竞态条件。

第一版方案:Redis 加锁

用 Redis 的 setIfAbsent 把”检查”和”占位”合成一步:

1
2
3
4
5
6
7
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent("alarm:active:" + deviceSn + ":" + alarmType,
"1", 24, TimeUnit.HOURS);

if (Boolean.TRUE.equals(isNew)) {
createAlarmRecord(deviceSn, alarmType, alarmValue, thresholdValue);
}

Redis 单线程执行 setIfAbsent,保证只有一个线程能拿到锁。这步做完,重复告警基本解决了。

但还有个坑

Redis 锁设置成功了,数据库写入却失败了怎么办?比如数据库挂了、事务回滚了、网络超时了。

这时候 Redis 里留着一个 "alarm:active:xxx" 的 key,24 小时内不会过期。后续真实的告警来了,查到 Redis 里有记录,就直接跳过了——告警被静默丢弃了

这比重复告警更严重。重复顶多是数据脏一点,丢告警是功能缺陷。

终版方案:加锁 + 重试 + 回滚

思路很简单:数据库写入失败时,把 Redis 里的锁删掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private boolean tryCreateAlarm(String deviceSn, String alarmType,
Long value, Long threshold) {
String key = "alarm:active:" + deviceSn + ":" + alarmType;

try {
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 24, TimeUnit.HOURS);

if (!Boolean.TRUE.equals(acquired)) {
log.info("告警已存在,跳过 - deviceSn={}, type={}", deviceSn, alarmType);
return false;
}

// 数据库写入,带重试
boolean success = insertWithRetry(deviceSn, alarmType, value, threshold);

if (success) {
return true;
} else {
// 数据库没写进去,Redis 锁不能留着
redisTemplate.delete(key);
log.warn("数据库写入失败,已回滚 Redis 锁 - key={}", key);
return false;
}

} catch (Exception e) {
// Redis 挂了,降级直接写库,宁可重复也不能丢
log.warn("Redis 异常,降级直接入库: {}", e.getMessage());
createAlarmRecord(deviceSn, alarmType, value, threshold);
return true;
}
}

数据库写入加了重试,最多 3 次,延迟递增:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private boolean insertWithRetry(String deviceSn, String alarmType,
Long value, Long threshold) {
for (int i = 1; i <= 3; i++) {
try {
IotAlarm alarm = new IotAlarm();
alarm.setDeviceSn(deviceSn);
alarm.setAlarmType(alarmType);
alarm.setAlarmValue(value);
alarm.setThresholdValue(threshold);
alarm.setAlarmTime(new Date());
alarm.setStatus(0L);

iotAlarmService.insertIotAlarm(alarm);

if (alarm.getId() != null) {
return true;
}
} catch (Exception e) {
log.warn("第{}次写入失败: {}", i, e.getMessage());
if (i < 3) {
try {
Thread.sleep(100L * i);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
return false;
}

几个实际考虑

为什么不用数据库唯一索引?

可以加,但不太够用。IoT 场景写入量大,唯一索引冲突会触发数据库回滚,开销不小。而且告警有状态流转(活跃→已恢复),唯一索引不好表达”同一时刻只能有一个活跃告警”这个业务语义。Redis 锁把并发控制和数据存储分开,各干各的,更轻量。

重试 3 次够吗?

够用了。1 次基本拦不住网络抖动,无限重试会拖垮线程池。3 次 + 递增延迟,既能覆盖大多数瞬时故障,又不会阻塞太久。

Redis 挂了怎么办?

代码里 catch 了 Redis 异常,直接降级写库。这时候可能又出现重复告警,但保证不丢数据。监控类系统,丢数据的后果比重复严重得多。

效果

上线后重复告警归零。Redis 和数据库的一致性通过回滚机制兜底,偶发的数据库瞬时故障也能自愈。

这套东西不复杂,核心就三点:

  1. setIfAbsent 原子加锁,解决并发重复
  2. 数据库失败时删 Redis key,避免假状态
  3. Redis 异常时降级写库,保证可用性

适合 IoT、监控、上报这类高并发、写多读少的场景。