多实例 MQTT 消费下「累计差值」重复累加问题与消息级幂等去重

多实例 MQTT 消费下「累计差值」重复累加问题与消息级幂等去重

一、问题背景

在物联网 / 能源监控类系统里,设备(如电表、水表、气表)会按固定周期上报累计值(例如累计耗电量 kWh、累计用水量吨)。要得到某个时间段内的用量,需要计算相邻两次上报累计值的差值:

复制代码
Δ用量 = 本次累计值 − 上次累计值

其中「上次累计值」通常存放在一个共享快照里------每台设备只保留最新一条记录。

系统为了高可用,往往会部署多个服务实例,同时订阅同一个 MQTT 主题 (通配符订阅)。而 MQTT 是发布/订阅模型:同一个主题下,每个订阅者都会收到同一份消息。于是同一条设备上报,会被 N 个实例各自消费一次。

问题随之而来。每个实例都会:

  1. 读快照,得到同一个「上次累计值」;
  2. 算出同一个差值;
  3. 把这个差值累加进用量统计表(常见写法是 INSERT ... ON DUPLICATE KEY UPDATE 用量 = 用量 + 差值 这类加法)。

结果就是:同一份用量被累加了 N 次,统计翻倍,而且事后极难被发现和修复。

二、根因

本质是一次典型的「读-改-写」并发竞争(read-modify-write race):

复制代码
实例 A:读快照 prev=1000 → 算 Δ=20 → 统计 +20 → 快照改为 1020
实例 B:读快照 prev=1000 → 算 Δ=20 → 统计 +20 → 快照改为 1020   ← 重复

关键点有两个:

  1. 差值不是消息里直接给的,而是从共享快照里「推导」出来的;
  2. 用量统计表按「设备 + 时间桶」聚合,没有"这条消息"的天然唯一标识,因此无法直接对统计表加「同一条消息只加一次」的约束。

所以问题不能靠给统计表加唯一键解决,只能在消息消费的入口或差值推导的环节做幂等。

三、可选方案与取舍

方案 思路 优点 缺点
快照乐观锁(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 的消息级去重,是改动最小、语义最清晰的幂等方案,且对下游业务零侵入。
  • 两个最容易被忽略的点:
    1. 去重 key 依赖消息内容唯一性------payload 必须含时间戳/消息 ID 这类逐条不同的字段;
    2. 用 TTL 而不是主动释放锁------以覆盖 QoS1「至少一次」的重投递。
  • 一定要做好 Redis 故障降级,避免可用性被去重组件绑架。
相关推荐
jonyleek11 小时前
物联网‘假离线’实战排查指南:基于JVS-IOT的数字孪生状态同步与MQTT心跳防抖优化
mqtt·数字孪生·状态同步·心跳保活·jvs-iot·物联网运维·设备影子
硅基造物主2 天前
STM32 从 0 到 1 做智能家居(十二):USART2 分时复用 —— ESP-12F + MQTT
网络·stm32·mqtt·智能家居·esp-12f
weixin_404380632 天前
MQTT 上云验收:ESP32-S3 从局域网控灯到远程网页控制
物联网·mqtt·esp32
IT规划师2 天前
MQTT实战 - MQTT高频面试题 + 我的8年实战答案(十一)
物联网·mqtt·面试·.net core·iot·mqttnet
硅基造物主5 天前
STM32 从 0 到 1 做智能家居(二):把开发环境一次配好
stm32·mqtt·智能家居·hal库·esp-12f
硅基造物主5 天前
STM32 从 0 到 1 做智能家居(一):先想清楚要做什么
stm32·mqtt·嵌入式·智能家居·hal库·esp-12f
IT规划师5 天前
MQTT实战 - MQTT Broker选型,EMQX vs Mosquitto,我用过的真实对比(八)
物联网·mqtt·.net core·mqttnet