多实例 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 故障降级,避免可用性被去重组件绑架。
相关推荐
荣合技术服务8 天前
EMQX mqtt在 Windows 系统上的安装教程
windows·mqtt·emqx
J_bean11 天前
IOT接入层-阐述物联网四种设备接入方式
mqtt·iot·设备接入·设备直连·网关透传
程序媛之Lemon11 天前
FluxMQ|面向海量终端的云原生MQTT物联网网关
物联网·mqtt
SL-staff18 天前
MQTT Topic权限越界排查指南:JVS-IOT中系统Topic与自定义Topic的鉴权机制与实操验证
mqtt·嵌入式开发·权限控制·物联网开发·协议分析·鉴权机制·jvs-iot
StevenSurpass22 天前
打印业务彻底解耦:FastPrintAgent 统一 JSON 模型 + FastReport 引擎的设计与落地
mqtt·http·中间件·打印·fastreport
物联网IoT小易1 个月前
物联网设备数据异常怎么处理?重复、乱序、断点续传与脏数据治理
物联网·mqtt·数据治理·时序数据库·物联网设备·物联网设备接入·物联网设备连接
sibo_yzm1 个月前
谁动了底层的工艺参数?Kepware+OPM-820 双剑合璧实现数据权限管控
mqtt·iot·modbus tcp·opc·kepware·上海泗博
物联网IoT小易1 个月前
MQTT消息为什么会重复、丢失或延迟?QoS、离线消息与重连机制详解
物联网·mqtt·消息队列·边缘计算·mqtt协议·物联网平台
StevenSurpass1 个月前
智能工厂场景:FastPrintAgent 分布式打印中间件落地应用方案
分布式·mqtt·http·中间件·打印·fastreport