MQTT消息为什么会重复、丢失或延迟?QoS、离线消息与重连机制详解

在物联网项目中,设备成功连接MQTT Broker,并不代表数据链路已经可靠。

实际运行一段时间后,经常会遇到这些问题:

  • 同一条数据被平台收到两次;

  • 设备显示在线,但部分消息没有上报;

  • 网关断网后恢复连接,历史数据没有补传;

  • 设备重连后突然收到大量旧消息;

  • 控制指令已经过期,却在重连后被设备执行;

  • 消息发布成功,但业务系统没有收到;

  • 网络抖动时数据延迟明显增加。

这些现象通常不是单一故障造成的,而是与MQTT的QoS、会话状态、离线队列、保留消息和业务确认机制有关。

本文从实际工程角度梳理MQTT消息可靠性问题及常见处理方法。

一、MQTT的"发布成功"到底代表什么?

调用MQTT客户端的publish()方法后,很多开发者会认为消息已经成功写入业务系统。

实际上,一条设备消息通常要经过多层链路:

复制代码
设备或边缘网关
    ↓
MQTT客户端发送队列
    ↓
网络连接
    ↓
MQTT Broker
    ↓
订阅客户端
    ↓
消息消费程序
    ↓
数据库或业务系统

MQTT协议主要负责客户端与Broker之间的消息传输。

即使Broker已经收到消息,也不能直接证明:

  • 订阅客户端已经收到;

  • 消费程序已经处理;

  • 数据已经成功写入数据库;

  • 告警规则已经执行;

  • 下游业务系统没有报错。

因此,MQTT层面的确认与业务层面的成功,是两个不同概念。

对于普通遥测数据,确认Broker收到可能已经足够;对于远程控制、订单、告警等关键消息,还需要额外的业务回执机制。

二、QoS 0、QoS 1和QoS 2有什么区别?

MQTT提供三个消息服务质量等级。

1. QoS 0:最多发送一次

QoS 0的特点是发送方发送后不等待确认。

复制代码
Publisher → PUBLISH → Broker

如果发送过程中出现网络中断,消息可能丢失。

它的优点是:

  • 通信开销小;

  • 延迟低;

  • 适合高频数据;

  • 不需要维护复杂状态。

适用场景包括:

  • 每秒上报一次的温度;

  • 高频设备运行状态;

  • 允许少量数据缺失的环境监测;

  • 可以被下一条数据覆盖的实时属性。

如果温度每5秒上报一次,偶尔丢失一条通常不会影响整体趋势,此时没有必要为每条消息增加额外确认开销。

2. QoS 1:至少到达一次

QoS 1要求接收方返回PUBACK确认。

复制代码
Publisher → PUBLISH → Broker
Publisher ← PUBACK  ← Broker

如果发布方在规定时间内没有收到确认,就会重新发送消息。

因此,QoS 1能够降低消息丢失概率,但可能产生重复消息。

常见情况是:

  1. Broker已经收到并处理消息;

  2. Broker返回PUBACK

  3. 网络发生抖动,发布方没有收到确认;

  4. 发布方再次发送同一条消息;

  5. Broker或下游系统再次处理。

所以QoS 1保证的是"至少到达一次",不是"只处理一次"。

适用场景包括:

  • 设备告警;

  • 设备属性上报;

  • 工单状态变化;

  • 重要但允许做幂等处理的数据。

3. QoS 2:只到达一次

QoS 2通过四次交互减少重复投递:

复制代码
Publisher → PUBLISH → Broker
Publisher ← PUBREC  ← Broker
Publisher → PUBREL  → Broker
Publisher ← PUBCOMP ← Broker

QoS 2的协议开销最大,对客户端、Broker和网络资源的要求也更高。

它适合:

  • 对重复极其敏感的少量关键消息;

  • 无法通过业务幂等消除重复的场景;

  • 消息数量不大但可靠性要求很高的业务。

在大规模物联网项目中,不建议将所有遥测数据都设置为QoS 2。否则容易增加连接负担、消息积压和Broker压力。

三、为什么使用QoS 1后仍然需要幂等处理?

QoS 1是物联网项目中最常见的选择,但必须接受一个事实:

消息可能重复。

因此,每条重要消息最好携带唯一标识。

复制代码
{
  "messageId": "gw01-1788489600123-0008",
  "deviceId": "meter-001",
  "timestamp": 1788489600123,
  "type": "property",
  "properties": {
    "voltage": 220.5,
    "current": 3.6
  }
}

消费端收到消息后,可以检查messageId是否已经处理。

伪代码如下:

复制代码
if (messageRepository.exists(messageId)) {
    return;
}

processMessage(message);
messageRepository.save(messageId);

为了避免幂等记录无限增长,可以根据业务时效设置过期时间。

例如:

  • 实时属性消息保留1小时的去重记录;

  • 告警消息保留7天;

  • 控制指令保留30天;

  • 关键业务记录长期保存。

除了messageId,还可以使用以下字段组合生成幂等键:

复制代码
deviceId + timestamp + messageType + sequence

但需要保证设备时间和序列号的可靠性。

四、设备断网后为什么没有收到离线消息?

MQTT是否保存离线消息,与多个条件有关:

  • 客户端是否使用持久会话;

  • 订阅是否已经建立;

  • 消息QoS是否大于0;

  • Broker是否启用离线队列;

  • 队列容量和消息有效期如何设置;

  • 客户端是否使用固定Client ID。

只要其中一个条件不满足,断网期间的消息就可能无法恢复。

五、Clean Session与Clean Start有什么作用?

在MQTT 3.1.1中,常见参数是Clean Session

Clean Session = true

客户端每次连接都创建新会话。

断开连接后,Broker通常会清除:

  • 原有订阅关系;

  • 未确认消息;

  • 离线消息;

  • 会话状态。

适合:

  • 临时客户端;

  • 调试工具;

  • 不需要恢复历史消息的应用;

  • 每次连接都会重新订阅的客户端。

Clean Session = false

Broker会保留客户端会话。

客户端重连后,可以恢复:

  • 原有订阅;

  • 部分未确认消息;

  • 符合条件的离线消息。

在MQTT 5.0中,相关机制被拆分为Clean StartSession Expiry Interval,能够更精细地控制会话生命周期。

例如:

复制代码
Clean Start = false
Session Expiry Interval = 86400

表示尝试恢复已有会话,并在客户端离线后保留会话24小时。

需要注意:持久会话通常依赖稳定的Client ID。如果设备每次重连都生成一个新的Client ID,Broker会把它识别为新的客户端,原有离线消息也就无法恢复。

六、Client ID重复会产生什么问题?

MQTT Broker通常要求同一时刻一个Client ID只对应一个连接。

如果两个客户端使用相同Client ID:

  1. 客户端A连接Broker;

  2. 客户端B使用相同Client ID连接;

  3. Broker断开客户端A;

  4. 客户端A自动重连;

  5. Broker又断开客户端B;

  6. 两个客户端不断互相挤下线。

日志中可能看到频繁的连接和断开,但现场人员会误以为是网络不稳定。

因此,Client ID应具备全局唯一性,并与设备身份稳定绑定,例如:

复制代码
park01-gateway-0001
park01-meter-0235
buildingA-edge-02

不建议仅使用随机数,也不要只使用可能重复的设备型号。

七、保留消息为什么会让设备收到"旧指令"?

MQTT的Retained Message用于保存某个主题的最后一条消息。

发布时设置Retain标志后,Broker会保存该消息。新的订阅者订阅主题时,会立即收到这条历史消息。

它适合保存:

  • 设备最近状态;

  • 当前配置版本;

  • 在线状态;

  • 当前模式;

  • 最新属性快照。

但如果将远程控制指令设置为保留消息,就可能产生危险。

例如,平台向以下主题发布一条"启动设备"的保留消息:

复制代码
park/device/pump-01/command/set

设备当时处于离线状态。数小时后设备重新上线并订阅该主题,Broker会立即把旧的启动指令发送给设备,导致过期指令被执行。

因此,控制指令通常不应该使用Retain。

如果需要清除某个主题的保留消息,可以向该主题发布一条空Payload并设置Retain标志。具体行为需要结合Broker实现验证。

八、遗嘱消息能否准确判断设备离线?

MQTT Last Will用于客户端异常断开时,由Broker代替客户端发布一条预设消息。

连接时可以配置:

复制代码
Will Topic: park/device/gateway-01/status
Will Payload: offline
Will QoS: 1
Will Retain: true

客户端正常上线后主动发布:

复制代码
Topic: park/device/gateway-01/status
Payload: online
Retain: true

这样,新订阅者能够获得设备最近的在线状态。

但遗嘱消息也不是绝对实时。

设备是否被判断为离线,还受到以下因素影响:

  • Keep Alive时间;

  • 网络中间设备的连接超时;

  • Broker检测连接失效的速度;

  • 客户端是否正常发送心跳;

  • TCP连接是否处于半开状态。

如果Keep Alive设置为60秒,平台通常不可能在设备断网后的1秒内准确判断离线。

在线状态判断应结合MQTT连接状态、最后数据时间和业务心跳,而不是只依赖一个字段。

九、断网缓存应该放在客户端还是Broker?

需要区分两个方向。

设备到云端的数据上报

设备或边缘网关断网时,消息还没有到达Broker。

这种情况下,Broker无法帮助缓存,必须由设备端或边缘网关保存数据。

常见实现方式包括:

  • 内存队列;

  • 本地文件;

  • SQLite;

  • 嵌入式时序数据库;

  • 轻量消息队列。

缓存数据至少需要包含:

复制代码
{
  "messageId": "gw01-000123",
  "deviceId": "sensor-08",
  "collectTime": 1788489600123,
  "payload": {
    "temperature": 25.6
  },
  "retryCount": 0
}

网络恢复后按照采集时间补传。

云端到设备的消息下发

设备离线时,控制指令已经到达Broker。

是否进入离线队列,取决于持久会话、QoS和Broker配置。

即使Broker支持离线消息,也应为控制指令设置有效期:

复制代码
{
  "commandId": "cmd-10086",
  "createTime": 1788489600000,
  "expireTime": 1788489660000,
  "action": "start"
}

设备收到后首先检查:

复制代码
currentTime > expireTime ?

如果指令已经过期,应拒绝执行并返回明确结果。

十、为什么消息会出现明显延迟?

MQTT消息延迟可能来自多个环节。

客户端发送队列积压

设备采集速度大于网络发送速度时,消息会在本地排队。

需要监测:

  • 当前队列长度;

  • 最老消息等待时间;

  • 每秒产生消息数量;

  • 每秒成功发送数量;

  • 消息丢弃数量。

Broker性能不足

大量连接、通配符订阅、高QoS消息和持久会话都会增加Broker负担。

应关注:

  • 当前连接数;

  • 每秒消息吞吐;

  • 消息队列长度;

  • 磁盘写入延迟;

  • 订阅匹配耗时;

  • 节点之间的同步延迟。

消费端处理缓慢

MQTT消息已经到达订阅程序,但数据库写入、规则计算或外部接口调用耗时过长,也会造成业务延迟。

消费端应避免在MQTT回调线程中执行长时间操作。

不推荐:

复制代码
public void messageArrived(String topic, MqttMessage message) {
    callSlowHttpApi(message);
    writeDatabase(message);
    runComplexRule(message);
}

更合理的方式是快速接收后写入内部队列:

复制代码
public void messageArrived(String topic, MqttMessage message) {
    internalQueue.offer(new MessageTask(topic, message));
}

再由独立工作线程完成解析、存储和业务处理。

十一、如何设计关键控制指令?

对于远程开关、参数设置和设备复位等操作,不能只依赖MQTT QoS。

建议设计完整的请求与响应机制。

下发指令:

复制代码
{
  "commandId": "cmd-20260904-001",
  "deviceId": "pump-01",
  "action": "start",
  "createTime": 1788489600000,
  "expireTime": 1788489660000
}

设备执行后返回:

复制代码
{
  "commandId": "cmd-20260904-001",
  "deviceId": "pump-01",
  "result": "success",
  "executeTime": 1788489601250,
  "message": "device started"
}

平台根据commandId关联请求和结果,并维护状态:

复制代码
待发送 → 已发送 → 设备已接收 → 执行成功
                       ↘ 执行失败
                       ↘ 执行超时

这样才能区分:

  • 消息是否发出;

  • Broker是否收到;

  • 设备是否收到;

  • 指令是否执行;

  • 执行是否成功。

十二、MQTT消息可靠性排查清单

当项目出现重复、丢失或延迟时,可以依次检查:

连接层

  • Client ID是否唯一且稳定;

  • 是否频繁断线重连;

  • Keep Alive是否合理;

  • TLS握手是否失败;

  • 网络是否存在高延迟和丢包。

会话层

  • Clean Session或Clean Start如何设置;

  • 会话过期时间是否合理;

  • 重连后是否重新订阅;

  • Broker是否保留离线队列。

消息层

  • QoS等级是否符合业务;

  • 是否错误使用Retain;

  • 消息是否包含唯一ID;

  • 是否设置消息有效期;

  • Payload大小是否过大。

客户端

  • 本地发送队列是否积压;

  • 断网是否缓存;

  • 重连策略是否采用退避机制;

  • 重发时是否保持原始消息ID;

  • 本地时间是否准确。

Broker

  • 连接数和吞吐量是否达到瓶颈;

  • 持久化是否正常;

  • 队列长度是否超限;

  • 消息是否因过期或策略限制被丢弃。

消费端

  • 回调线程是否被阻塞;

  • 是否存在重复消费;

  • 数据库写入是否失败;

  • 是否实现幂等处理;

  • 下游接口是否超时。

总结

MQTT协议并不会自动解决物联网系统中的所有可靠性问题。

QoS解决的是客户端与Broker之间的消息交付等级;持久会话和离线队列解决的是特定条件下的消息恢复;Retain用于保存主题的最后状态;Last Will用于辅助判断异常断线。

真正稳定的物联网数据链路还需要:

复制代码
稳定Client ID
+ 合理QoS
+ 持久会话
+ 端侧断网缓存
+ 消息唯一标识
+ 消费端幂等
+ 指令有效期
+ 业务结果回执
+ 全链路监控

对于周期性遥测数据,可以接受少量丢失;对于告警和控制指令,则必须补充业务层确认机制。

只有根据消息类型选择不同的可靠性策略,才能在系统性能、通信成本和数据完整性之间取得平衡。

相关推荐
物联通信量讯说3 小时前
5G会取代4G吗?物联网设备出海正在走向多网络融合
网络·物联网·5g
wtblszn3 小时前
智慧水利:排水泵站物联网解决方案
物联网
pnoker4 小时前
从工业软件到 AI 智能体:工业 AIoT 技术路线的系统梳理
java·人工智能·物联网·microsoft·开源·工业互联网
D_codingXuChu4 小时前
IoT智能硬件物联网开发公司:D-coding系统建设
物联网·智能硬件·iot·开发经验
做萤石二次开发的哈哈5 小时前
萤石蓝海 AIoT 一站式工作台:海康LCD会议平板接入,多端大屏运维应用一站生成
运维·物联网·电脑·萤石开放平台·会议室管理·蓝海aiot一站式工作台·otap物模型
李永奉5 小时前
中科蓝讯SDK开发-提示音音频文件格式转换和压缩教程
网络·单片机·嵌入式硬件·mcu·物联网
TDengine (老段)5 小时前
TDengine 错误处理 — 错误码、异常传播、故障恢复
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
虎王物联6 小时前
Docker BuildKit多阶段构建:IoT固件交叉编译流水线实战
运维·物联网·ci/cd·docker·容器·物联网嵌入式
一只鹿鹿鹿6 小时前
水利信息化建设项目物联网(视频监控系统)施工方案(Word)
物联网·安全·web安全·系统安全