多实例 MQTT 消费下「累计差值」重复累加问题与消息级幂等去重
一、问题背景
在物联网 / 能源监控类系统里,设备(如电表、水表、气表)会按固定周期上报累计值(例如累计耗电量 kWh、累计用水量吨)。要得到某个时间段内的用量,需要计算相邻两次上报累计值的差值:
Δ用量 = 本次累计值 − 上次累计值
其中「上次累计值」通常存放在一个共享快照里------每台设备只保留最新一条记录。
系统为了高可用,往往会部署多个服务实例,同时订阅同一个 MQTT 主题 (通配符订阅)。而 MQTT 是发布/订阅模型:同一个主题下,每个订阅者都会收到同一份消息。于是同一条设备上报,会被 N 个实例各自消费一次。
问题随之而来。每个实例都会:
- 读快照,得到同一个「上次累计值」;
- 算出同一个差值;
- 把这个差值累加进用量统计表(常见写法是
INSERT ... ON DUPLICATE KEY UPDATE 用量 = 用量 + 差值这类加法)。
结果就是:同一份用量被累加了 N 次,统计翻倍,而且事后极难被发现和修复。
二、根因
本质是一次典型的「读-改-写」并发竞争(read-modify-write race):
实例 A:读快照 prev=1000 → 算 Δ=20 → 统计 +20 → 快照改为 1020
实例 B:读快照 prev=1000 → 算 Δ=20 → 统计 +20 → 快照改为 1020 ← 重复
关键点有两个:
- 差值不是消息里直接给的,而是从共享快照里「推导」出来的;
- 用量统计表按「设备 + 时间桶」聚合,没有"这条消息"的天然唯一标识,因此无法直接对统计表加「同一条消息只加一次」的约束。
所以问题不能靠给统计表加唯一键解决,只能在消息消费的入口或差值推导的环节做幂等。
三、可选方案与取舍
| 方案 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 快照乐观锁(CAS) | 写快照时加条件 WHERE 累计值 = 我读到的值,抢到推进权的实例才累加差值 |
无需额外组件 | 极端并发下相邻两条不同消息可能因竞争丢一条差值;与"其它字段也要更新"耦合,实现易碎 |
| 悲观锁 + 事务 | SELECT ... FOR UPDATE 锁行读快照,把「读→算→写→累加」包进事务按设备串行化 |
严格不丢不重 | 需事务改造,改动更大 |
| 消息级去重(Redis SETNX) | 在消息入口做「同一条消息只处理一次」,下游逻辑完全不动 | 语义最直观、侵入最小 | 依赖 Redis 可用性 |
综合「改动小、语义清晰、不侵入业务」,本文采用消息级去重方案。
四、实现要点
1. 去重 key 设计
key = 前缀 + topic + ":" + md5(payload)
topic区分设备与消息类型,md5(payload)区分消息内容;- 同一条消息内容完全一致 → md5 一致 → 多个实例里只有一个能抢到 key;
- 不同消息(哪怕来自同一设备)→ payload 不同 → md5 不同 → 互不影响。
前提 :payload 里必须带「每条消息都不同」的字段(如
timestamp/messageId)。若 payload 恒定不变,md5 恒定,会把同一设备的后续正常消息误判为重复、被错误过滤。
2. 抢占方式:SETNX + TTL,而不是「锁 + 释放」
用 SET key 1 NX EX ttl(setIfAbsent)做去重标记 :抢到就处理、抢不到就跳过,并且不主动删除,靠 TTL 自动过期。
- 若是「加锁 → 处理 → 释放」,在 MQTT QoS1「至少一次」的语义下,消息稍后重投递时锁已释放,会被再次处理 → 仍重复;
- SETNX + TTL 让 key 在窗口期内一直存在,窗口期内的重复投递都被挡住。
3. TTL 取多大合适
重复投递的典型时间间隔:
- 多实例同时消费同一条消息:毫秒 ~ 秒级;
- QoS1 重传(订阅方 PUBACK 未返回 broker):秒级 ~ 1 分钟(取决于 broker 重试配置)。
因此 TTL 设为 5 ~ 10 分钟 已留有充分余量。key 会自动过期,无需手动清理,Redis 内存占用可忽略。
补充:即使没有去重,迟到很久的重复(例如某实例离线数小时后重连、补投消息)也不会重复累加------因为快照已被其它实例推进,迟到实例再算差值会得到 0 而被自然跳过。所以 TTL 只需覆盖「同时消费」的短窗口即可。
4. 失败降级
Redis 不可用时,setIfAbsent 会抛异常。此时应降级放行(照常处理),而不是拒绝处理:宁可短暂失去去重、也不丢数据,并记录告警日志。
五、代码骨架
java
@Component
public class MessageDedupService {
@Resource
private StringRedisTemplate redis;
private static final int DEDUP_TTL_MINUTES = 10;
/**
* 尝试处理消息
*
* @return true=首次处理(继续);false=重复消息(跳过)
*/
public boolean tryProcess(String topic, String payload) {
String key = "mqtt:dedup:" + topic + ":" + md5(payload);
Boolean first;
try {
first = redis.opsForValue()
.setIfAbsent(key, "1", Duration.ofMinutes(DEDUP_TTL_MINUTES));
} catch (Exception e) {
log.warn("消息去重失败,降级放行, topic={}", topic, e);
return true;
}
return !Boolean.FALSE.equals(first);
}
}
在各类消息的消费入口统一调用:
java
public void onMessage(String topic, String payload) {
if (!dedup.tryProcess(topic, payload)) {
log.info("重复消息,跳过, topic={}", topic);
return;
}
// ... 具体业务处理 ...
}
六、总结
- 多实例消费 MQTT 导致重复累加,根因是「差值从共享快照推导」的读-改-写竞争,而非消息中间件本身的语义问题。
- 在消息入口做 SETNX + TTL 的消息级去重,是改动最小、语义最清晰的幂等方案,且对下游业务零侵入。
- 两个最容易被忽略的点:
- 去重 key 依赖消息内容唯一性------payload 必须含时间戳/消息 ID 这类逐条不同的字段;
- 用 TTL 而不是主动释放锁------以覆盖 QoS1「至少一次」的重投递。
- 一定要做好 Redis 故障降级,避免可用性被去重组件绑架。