
MQTT消息幂等处理:避免重复控设备、重复数据入库问题
大家好,我是「黒漂技术佬」。今天咱们聊一个看似基础,但在生产环境里能坑死你的问题------MQTT消息的幂等处理。
想象一个画面:凌晨三点,你正睡得香,手机突然收到告警:"大棚A区水肥机持续注水30分钟"。你一个激灵爬起来,发现因为一条控制指令被重复执行了三次,水肥机对着同一块地浇了三遍水。菜苗淹死了,你的年终奖也淹死了。
这就是幂等性缺失的代价。咱们从头捋。
一、MQTT中为什么会出现重复消息?
先搞清楚病根,再开药方。
1.1 QoS 1/2的重发机制
这是最核心的原因。MQTT的QoS 1(至少一次)和QoS 2(恰好一次)都涉及确认重传:
- QoS 1:发布者发送PUBLISH → 等待PUBACK → 超时未收到 → 重发。接收端可能收到多次。
- QoS 2:虽然号称"恰好一次",但在网络极不稳定的情况下,PUBREL丢失同样会导致重发,只不过概率比QoS 1低很多。
别被QoS 2的名字忽悠了,在极端网络条件下它也不是银弹。
1.2 网络抖动 + 客户端重连
MQTT设备大多跑在弱网环境下------大棚里的4G信号、地库里的WiFi,说断就断。客户端断连后重新连接,Broker会把未收到ACK的消息重新投递给客户端。如果客户端在断开前已经处理了这条消息但ACK丢了,那么重连后就会收到同一条消息两次。
1.3 持久会话恢复后的积压消息
MQTT的Clean Session设为false(持久会话)时,客户端离线期间,Broker会替它积攒所有匹配订阅的消息。一旦客户端重连,这些积压消息一次性倾泻过来。如果客户端之前处理过部分消息但ACK未到达,重复不可避免。
二、重复消息对不同业务的影响分析
不是所有重复都会出问题,咱们分类看看:
2.1 传感器数据:重复写入 → 统计数据失真
温湿度传感器每10秒上报一次。如果同一条数据被写了两次:
sql
-- 数据库里出现这样的脏数据(同一设备同一秒两条相同记录)
device_001 | 2025-08-05 10:00:00 | temperature | 28.5
device_001 | 2025-08-05 10:00:00 | temperature | 28.5
计算平均值的时候,28.5被算了两次。表面上看误差不大,但做周报月报的时候,数据量越大偏差越离谱------你能接受给老板的报告里温度"莫名其妙偏高0.3度"吗?
2.2 控制指令:重复执行 → 灾难
这是最要命的情况。水肥机收到"开启阀门A 30秒"的指令,如果被执行三次,结果就是阀门开了90秒。过量灌溉造成水肥浪费不说,植物根系长期浸泡还可能烂根死亡。
一个真实场景:卷帘机控制。早上收到"卷起"指令但ACK丢失,中午系统自动重发,结果卷帘机对已经卷起的棚又执行了一遍"卷起"------电机堵转、烧毁。一个电机几千块,全市几十个大棚......
2.3 设备状态:重复更新 → 影响不大
设备状态更新(如"设备在线"、"设备离线")天然具有幂等性------一个设备不可能"上线两次"。即使收到重复的状态消息,最终结果也是一致的。这类场景不用过度设计。
三、幂等性设计方案
方案一:MQTT Packet Identifier去重
MQTT协议层自带Packet Identifier(报文标识符),范围1-65535,单次连接内唯一。
ini
Client → Broker: PUBLISH PacketId=100 "开阀门"
Broker → Client: PUBACK 100
Client → Broker: PUBLISH PacketId=100 "开阀门" (重发------Broker可通过PacketId识别)
优点 :零代码成本,协议原生支持。
限制 :重连后PacketId从1重新开始;只能在同一连接生命周期内去重。不适用于持久会话恢复场景。
思路可行但不够------打包票的场景不能单靠它。
方案二:业务唯一键去重(推荐)
每条业务消息携带全局唯一ID,由发布端生成(如UUID或雪花算法ID),接收端以此判重。
Redis方案------轻量且高效:
java
public boolean isDuplicate(String messageId) {
// SET key value NX EX 3600 ------ 只有当key不存在时才设置成功,并设1小时过期
String result = redisTemplate.opsForValue()
.setIfAbsent("msg:dedup:" + messageId, "1", Duration.ofHours(1));
return result == null; // null表示key已存在 → 重复消息
}
SETNX + EXPIRE 组合成一条原子命令 SET NX EX,避免两步操作间的竞态。
数据库方案------兜底保障:
sql
-- 在消息表上建唯一索引
ALTER TABLE sensor_data ADD UNIQUE KEY uk_msg_dedup (message_id);
-- 插入时用 INSERT IGNORE,重复自动跳过
INSERT IGNORE INTO sensor_data (message_id, device_id, sensor_type, value, ts)
VALUES ('msg_abc123', 'dev_001', 'temperature', 28.5, '2025-08-05 10:00:00');
Redis做主去重(快),数据库做兜底(稳),双保险。
方案三:状态机校验
对于控制类指令,通过设备状态机来避免重复执行无效操作:
scss
设备状态流转:
IDLE(空闲) → RUNNING(运行中) → IDLE(空闲)
java
public class ValveController {
private volatile String state = "IDLE"; // Redis或本地缓存维护
public void irrigate(String commandId, int duration) {
// 状态机校验:已经在灌溉中,不重复执行
if ("RUNNING".equals(state)) {
log.warn("阀门已在运行中,忽略重复指令: {}", commandId);
return;
}
// 原子切换状态
state = "RUNNING";
try {
// 执行灌溉逻辑
openValveForDuration(duration);
} finally {
state = "IDLE";
}
}
}
注意:分布式场景下,状态维护必须放到Redis这类共享存储中,本地变量在多实例部署时会失效。
方案四:数据时间窗口去重
高频传感器数据有特点:相邻几条数据值可能完全相同。没必要逐条去重ID,而是按"时间窗口+值相同"去重。
java
public boolean isDuplicateSensorData(String deviceId, String sensorType,
double value, long timestamp) {
String key = "sensor:latest:" + deviceId + ":" + sensorType;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
String[] parts = cached.split(":");
double lastValue = Double.parseDouble(parts[0]);
long lastTime = Long.parseLong(parts[1]);
// 同一设备同一传感器,5秒内的相同值视为重复
if (Math.abs(value - lastValue) < 0.01 && (timestamp - lastTime) < 5000) {
return true;
}
}
// 更新缓存
redisTemplate.opsForValue().set(key, value + ":" + timestamp, Duration.ofSeconds(30));
return false;
}
这个方案有精度损失风险------如果温度确实在5秒内没变,它就会被"错误去重"。权衡取舍,适用于传感器精度不高、数据变化缓慢的场景。
四、智慧农业场景的幂等设计实战
4.1 水肥机控制指令幂等:三层保障
水肥机是农业物联网里最"贵"的设备之一------一条错误指令毁一季收成。
第一层:commandId + Redis去重
指令下发前生成唯一commandId,Redis SETNX判断是否重复。
第二层:设备状态机校验
维持水肥机状态(IDLE/RUNNING),不在IDLE状态时拒绝执行新指令。
第三层:硬件安全开关
水肥机固件端实现最大运行时长保护------单次运行超过设定阈值强制停止。这是最后一道防线,软件不可靠时硬件兜底。
java
@Service
public class IrrigationCommandService {
@Autowired
private StringRedisTemplate redisTemplate;
public void executeCommand(IrrigationCommand cmd) {
// 第一层:commandId去重
if (!redisTemplate.opsForValue()
.setIfAbsent("cmd:dedup:" + cmd.getCommandId(), "1", Duration.ofHours(24))) {
log.warn("重复指令,已忽略: {}", cmd.getCommandId());
return;
}
// 第二层:状态机校验
String deviceKey = "device:state:" + cmd.getDeviceId();
String currentState = redisTemplate.opsForValue().get(deviceKey);
if ("RUNNING".equals(currentState)) {
log.warn("设备正在运行中,忽略指令: {}", cmd.getCommandId());
return;
}
// 锁定状态并执行
redisTemplate.opsForValue().set(deviceKey, "RUNNING", Duration.ofMinutes(10));
try {
// 通过MQTT下发指令到设备
mqttClient.publish("agriculture/farm01/cmd/irrigate/" + cmd.getDeviceId(),
cmd.toJson().getBytes(), 1, false);
// 异步等执行结果...
} finally {
// 执行完毕(或超时)恢复状态
redisTemplate.opsForValue().set(deviceKey, "IDLE");
}
}
}
4.2 传感器数据写入幂等
传感器数据量大、写入频繁,方案要轻量:
sql
-- 建立联合唯一索引
CREATE UNIQUE INDEX uk_sensor_ts
ON sensor_data_raw (device_id, sensor_type, collect_time);
-- 批量写入,重复自动跳过
INSERT IGNORE INTO sensor_data_raw (device_id, sensor_type, value, collect_time)
VALUES
('dev_001', 'temperature', 28.5, '2025-08-05 10:00:00'),
('dev_001', 'humidity', 65.2, '2025-08-05 10:00:00');
如果你用的是InfluxDB这类时序数据库,虽然没有传统唯一索引,但可以利用"相同timestamp+相同tags+相同fields的point会被覆盖"的特性,天然幂等。
五、幂等设计的代价
说了这么多好处,也得诚实说说代价:
| 代价项 | 说明 |
|---|---|
| Redis内存开销 | 每个messageId占用约100字节+过期时间内的内存 |
| 数据库IO增加 | 每次写入多一次唯一索引检查 |
| 代码复杂度 | 多一层去重逻辑,出问题时排查路径更长 |
| 序列化要求 | messageId必须保证全局唯一且有序可追溯 |
关键原则:只对真正的"非幂等操作"做幂等设计。传感器数据可以用INSERT IGNORE就够了,控制指令才需要Redis+状态机双层保障。
六、幂等性Checklist模板
拿这个清单逐项过一遍,基本不会漏:
sql
□ 控制类消息是否携带全局唯一commandId?
□ 写入类消息是否使用INSERT IGNORE或唯一索引防重?
□ Redis去重的key是否设置了合理的过期时间?
□ 高并发场景是否使用了SET NX EX原子操作(非SETNX + EXPIRE两步)?
□ 设备端是否实现了硬件保护(最大运行时长/次数限制)?
□ 状态机状态是否存放在共享存储中(Redis而非本地变量)?
□ 告警类消息是否设置了去重时间窗口?
□ 是否有监控统计重复消息占比?
幂等性是分布式系统的基本功,MQTT场景下尤其容易踩坑------因为消息重发是协议特性而非Bug。理解了原因,选对了方案,你的智慧农业系统才能在田间地头稳稳当当地跑下去。
------ 黒漂技术佬