ControlRookie
返回文章

第7篇_MQTT 现场排障:为什么会超时、掉线、重连、订阅丢失

这一篇完全从工程排障视角切入,把最常见的 MQTT 现场故障按连接层、ACK 链、状态机和会话恢复几条主线拆开,帮助你快速定位到底是网络、Broker 还是客户端时序出了问题。

这一篇完全从工程排障视角切入,把最常见的 MQTT 现场故障按连接层、ACK 链、状态机和会话恢复几条主线拆开,帮助你快速定位到底是网络、Broker 还是客户端时序出了问题。

适合谁收藏

  • 正在做 CODESYS / PLC / MQTT 项目的人
  • 想把 MQTT 从报文真正看到 ST 代码的人
  • 正在排查 QoS1 / QoS2 超时、掉线、重连问题的人

前面几篇,我们一直在讲:

  • 报文怎么长
  • ACK 怎么走
  • 状态机怎么分

但对现场工程师来说,最有价值的问题其实是:

出问题了,我该先查哪?

这一篇就完全站在排障视角来说。

先给结论:

MQTT 现场大多数问题,最后都能落到 4 条主线里:

一、先给一张排障总图

Mermaid
flowchart TD
    A[现象] --> B{连不上还是连上后出问题}
    B -->|连不上| C[TCP层 / CONNECT层]
    B -->|连上后出问题| D{QoS0还是QoS1/QoS2}
    D -->|QoS0也异常| E[接收链 / Topic / Broker配置]
    D -->|QoS1 QoS2异常| F[ACK链 / inflight / 状态机]
    F --> G{是否重连后恢复}
    G -->|没有恢复| H[会话与订阅恢复逻辑]
    G -->|恢复但不稳定| I[高频调度 / 即时ACK / 超时参数]

这张图的意思很简单:

不要一出问题就一锅端。

二、TcpConnect timeout 先查什么

如果报的是:

text
TcpConnect timeout

先不要上来怀疑 MQTT 报文。 因为这时候大概率还没进 MQTT 层。

优先检查 4 项

检查项说明
Broker IP 是否可达ping 通不代表端口一定可连,但至少先排 IP
Broker 端口是否正确1883 / 自定义端口
Windows 防火墙 / 网卡网络配置这类环境问题非常常见
PLC 到 Broker 的 TCP 路是否真通能在线监控 PLC,不等于 PLC 到 Broker 的 TCP 也通

结论

TcpConnect timeout 大概率属于:

  • TCP 层问题
  • 端口连通性问题
  • Broker 没监听
  • PC 防火墙 / 网卡配置问题

而不是 MQTT 报文问题。


三、CONNACK 失败先查什么

如果 TCP 已经通了,但卡在连接确认阶段,就往 CONNECT / CONNACK 这条链看。

常见原因

现象优先怀疑点
协议版本不支持eVersion 配错,Broker 不支持
Client ID 被拒绝Client ID 非法或冲突
用户名密码错误凭据问题
5.0 能连,3.1.1 不连,或反过来Broker 配置与协议版本不匹配

排障思路

  1. 看 M_HandleConnAck 是否拿到非 0 原因码
  2. 看 sDiagMsg 最后落的是什么
  3. 先排 Broker 是否真支持你选择的 MQTT 版本

四、为什么 Immediate response timeout 很容易让人误判

这是现场特别容易把人绕进去的一个问题。

因为用户常常会说:

但消息明明收到了啊。

这句话通常是真的。 但它只说明:

  • 数据帧进来了
  • 业务层可能也看到了

它不说明:

  • 你的协议响应发及时了

这类超时最常见的根因是:

场景必须及时发的响应
收到 QoS1 PUBLISHPUBACK
收到 QoS2 PUBLISHPUBREC
收到 PUBRELPUBCOMP

也就是说:

你可以“收到消息”,但只要 ACK 回晚了,对端照样可以按超时处理你。

五、为什么 QoS0 正常而 QoS1 / QoS2 容易炸

这类现象基本可以直接判:

重点不要再查 Topic 和普通 TCP 收发了,直接查 ACK 链和 inflight。

原因很简单

维度QoS0QoS1 / QoS2
需要 ACK否是
需要 inflight否是
需要超时重发否是
需要状态释放简单复杂

所以一旦出现:

  • QoS0 全绿
  • QoS1 / QoS2 高频就掉线

优先去看:

  1. 等待状态有没有及时释放
  2. inflight 有没有积压
  3. ACK 有没有被接收链及时消化
  4. 超时参数是不是过紧

六、如果状态机掉进 iTcpDisconnect,怎么看

很多人一看到 iTcpDisconnect 就以为:

网络断了。

其实不一定。

前面我们已经讲过,iTcpDisconnect 是统一异常收口。 也就是说,下面这些问题都可能把你送进去:

  • TCP 真的断了
  • CONNACK 超时
  • PUBACK / PUBREC / PUBCOMP 超时
  • SUBACK / UNSUBACK 超时
  • 即时 ACK 发送失败

所以正确问法不是:

为什么进了 iTcpDisconnect?

而是:

进 iTcpDisconnect 之前,我最后卡在哪个等待状态?

这个顺序非常重要。


七、为什么退订有时会“看起来像卡死”

退订链的问题很典型:

  • 订阅能成功
  • 退订一触发,状态机卡在 iUnsubAck
  • 拉低 connect 和 enable 都没立刻恢复

这类问题通常优先看:

  1. UNSUBSCRIBE 报文 Packet ID 是否正确
  2. 发送后是否真的进入了等待 UNSUBACK
  3. 接收路径是否正确识别了 UNSUBACK
  4. 收到 UNSUBACK 后等待标志是否释放

也就是说,别把它当“退订字符串问题”。 它本质上还是 ACK 等待链问题。


八、为什么高频多客户端同主题场景最容易把问题打出来

这个场景之所以值钱,是因为它同时压了几层:

  1. 接收面吞吐
  2. 即时 ACK 时序
  3. inflight 管理
  4. iConnected 调度能力
  5. KeepAlive 与业务流的共存

如果这个场景能长期稳定跑:

  • 多客户端
  • 同主题
  • QoS1 / QoS2
  • 高频

那基本说明你的 MQTT 客户端已经不是“演示级”,而是进入可用级了。


九、为什么重连后订阅会丢

这个问题现场非常常见,而且很多人第一反应会怪 Broker。

实际上要分两层看:

1. 协议层

重连后订阅要不要还在,跟下面这些有关:

  • Clean Session / Clean Start
  • Session Expiry
  • Broker 的会话保留策略

2. 客户端实现层

就算 Broker 会话没保住,客户端要不要自动重建订阅,也取决于你本地有没有:

  • 订阅列表
  • 重连后重放策略

所以“重连后订阅丢失”不是一个单点问题,而是:

协议语义 + Broker 行为 + 客户端恢复策略

十、排障时最推荐的 6 个观察点

这个清单很实用,建议你真正在现场用。

观察点你要看什么
当前主状态卡在哪个状态
等待标志xWaitingForAck 是否释放
期待报文类型byExpectedMsgType 对不对
Packet ID发出去和收回来的是否匹配
inflight 队列是否堆积、是否反复超时
诊断信息sDiagMsg 最后写了什么

这 6 个点比“盲抓包 + 猜原因”效率高很多。


十一、现场排障最容易犯的 3 个错误

错误 1:一看到超时就怀疑网络

网络当然可能有问题。 但 MQTT 里很多超时,本质是协议时序没跑顺。

错误 2:只看收没收到消息,不看 ACK 有没有回

尤其是 QoS1 / QoS2,这个误判非常多。

错误 3:只看最后掉到 iTcpDisconnect,不看它之前卡在哪

这会让你把很多不同根因都误归成“断线问题”。


十二、给一张现场快速定位表

现象最短定位路径
TcpConnect timeout先查 TCP 路由、端口、防火墙
TCP 通但连不上 MQTT查 CONNECT / CONNACK
QoS0 正常,QoS1 炸查 PUBACK 等待链
QoS1 正常,QoS2 炸查 PUBREC/PUBREL/PUBCOMP
收到消息但仍超时查即时 ACK 是否及时回发
退订卡死查 UNSUBACK 等待链
重连后订阅没了查会话语义和本地恢复策略

十三、这一篇你最该记住的 6 句话

  1. 排 MQTT 故障,先分层,不要一上来全怀疑。
  2. TcpConnect timeout 大概率还是 TCP 层问题,不是 MQTT 报文问题。
  3. Immediate response timeout 很多时候是 ACK 回慢了,不是消息没收到。
  4. QoS0 正常而 QoS1 / QoS2 异常,优先查 ACK 链和 inflight。
  5. iTcpDisconnect 是统一异常收口,不等于根因就是网络断了。
  6. 重连后订阅丢失,必须同时从协议语义、Broker 行为、客户端恢复策略三层看。

十四、下篇预告

下一篇我们收回到用户最关心的落地视角:

怎么把这套开源 MQTT 客户端真正用起来

会讲:

  • 接入思路
  • 参数怎么配
  • 用 PLC 内置 Broker、EMQX、Mosquitto 分别要注意什么
  • 真正上线前至少该过哪些测试项

完整 ST 代码

复制使用说明

  • 这部分给出的是与本篇主题直接对应的完整 ST 代码,不是零碎片段。
  • 如果你只是想先跑通,优先整段复制,不要只摘几行变量或几条赋值语句。
  • 如果是 METHOD,请确认它仍然属于 FB_MqttClient;如果是 PROGRAM,请确认相关 DUT、GVL、FB 已一并导入。

代码阅读重点

  • 先按 报文结构 -> 状态机入口 -> 关键变量 -> 返回结果 的顺序看。
  • 再把正文里的十六进制拆解和这里的字节写入、字节解析语句一行行对上。
  • 最后回到在线调试,重点盯 uiTxLength、uiRxLength、eState、xWaitingForAck 这类状态量。

完整代码 1:M_TcpClient

  • 对应源码路径:10 MQTT/MqttClient_V1_0/Device/Application/MQTT/POUs/MqttClient NBS/FB_MqttClient.M_TcpClient.st
  • 复制使用说明:这是 TCP 连接层的完整封装,现场一旦报 TcpConnect timeout,首先就该看这里。
  • 阅读重点:重点看 NBS TCP 客户端的使能、句柄、连接结果回传,先把“底层通不通”和“MQTT 通不通”分开。
iecst
/// =======================================================================
/// 名称      : M_TcpClient
/// 功能      : 控制 TCP 客户端的连接与断开
/// 说明      : 封装 NBS TCP 客户端连接调用,输出连接状态与错误信息。
/// 编程人员  : ControlRookie
/// 时间      : 2026-01-10
/// 版本      : V1.1
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD M_TcpClient
VAR_INPUT
    bEnable                : BOOL;           // TCP 连接使能标志
    sIP                    : STRING;         // 服务器 IP 地址
    uiPortNum              : UINT;           // 服务器端口号
END_VAR
VAR_OUTPUT
    bIsConnected           : BOOL;           // 连接成功标志
    bError                 : BOOL;           // 错误标志
    eError                 : NBS.ERROR;      // NBS 错误码
END_VAR
VAR
    stIP                   : NBS.IP_ADDR;    // 服务器地址结构
END_VAR

// === IMPLEMENTATION ===
stIP.sAddr := sIP;

fbTcpClient(
    xEnable     := bEnable,
    ipAddr      := stIP,
    uiPort      := uiPortNum,
    eError      => eError,
    hConnection => hConnection);

bError := fbTcpClient.xError;

IF fbTcpClient.xActive AND fbTcpClient.hConnection <> 0 THEN
    bIsConnected := TRUE;
ELSE
    bIsConnected := FALSE;
END_IF

完整代码 2:M_TcpRead

  • 对应源码路径:10 MQTT/MqttClient_V1_0/Device/Application/MQTT/POUs/MqttClient NBS/FB_MqttClient.M_TcpRead.st
  • 复制使用说明:这是收包入口,很多“明明网络通但总超时”的问题最后都能追到读取节拍上。
  • 阅读重点:重点看执行条件、读取完成标志和字节数回传,这里会直接影响接收状态机是否误判超时。
iecst
/// =======================================================================
/// 名称      : M_TcpRead
/// 功能      : 从 TCP 连接读取数据到接收缓冲区
/// 说明      : 包装 NBS.TCP_Read,读取成功后更新 uiRxLength。
/// 编程人员  : ControlRookie
/// 时间      : 2026-05-05
/// 版本      : V1.1
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD M_TcpRead
VAR_INPUT
    bEnable                : BOOL;              // TCP 读取使能标志
    pDataReceive           : POINTER TO BYTE;   // 接收数据区首地址
    udiDataSize            : UDINT;             // 接收数据区大小
END_VAR
VAR_OUTPUT
    bDone                  : BOOL;              // 读取完成标志
    bError                 : BOOL;              // 错误标志
    eError                 : NBS.ERROR;         // NBS 错误码
    udiBytesRead           : UDINT;             // 本次读取到的字节数
END_VAR

// === IMPLEMENTATION ===
fbTcpRead(
    xEnable      := bEnable,
    xError       => bError,
    hConnection  := hConnection,
    szSize       := udiDataSize,
    pData        := pDataReceive,
    eError       => eError);

IF fbTcpRead.xReady AND NOT fbTcpRead.xError THEN
    bDone := TRUE;
    udiBytesRead := TO_UDINT(fbTcpRead.szCount);
ELSE
    bDone := FALSE;
    udiBytesRead := 0;
END_IF

IF NOT bEnable THEN
    bDone := FALSE;
    udiBytesRead := 0;
END_IF

完整代码 3:M_TcpWrite

  • 对应源码路径:10 MQTT/MqttClient_V1_0/Device/Application/MQTT/POUs/MqttClient NBS/FB_MqttClient.M_TcpWrite.st
  • 复制使用说明:这是发包入口,出现 Immediate response timeout、发送超时、QoS 回包跟不上时要优先看这里。
  • 阅读重点:重点看执行沿、完成锁存和发送长度,理解为什么“报文已经组好了”不等于“报文已经真正发出去了”。
iecst
/// =======================================================================
/// 名称      : M_TcpWrite
/// 功能      : 向 TCP 连接发送缓冲区数据
/// 说明      : 包装 NBS.TCP_Write,发送成功后置位完成标志。
/// 编程人员  : ControlRookie
/// 时间      : 2026-05-05
/// 版本      : V1.1
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD M_TcpWrite
VAR_INPUT
    bExecute               : BOOL;              // TCP 写入执行标志
    pDataSend              : POINTER TO BYTE;   // 发送数据区首地址
    udiDataSize            : UDINT;             // 发送数据区大小
END_VAR
VAR_OUTPUT
    bDone                  : BOOL;              // 发送完成标志
    bError                 : BOOL;              // 错误标志
    eError                 : NBS.ERROR;         // NBS 错误码
END_VAR

// === IMPLEMENTATION ===
fbTcpWrite(
    xExecute     := bExecute,
    xError       => bError,
    hConnection  := hConnection,
    szSize       := udiDataSize,
    pData        := pDataSend,
    eError       => eError);

IF NOT bExecute THEN
    bWriteDoneLatched := FALSE;
ELSIF fbTcpWrite.xError THEN
    bWriteDoneLatched := FALSE;
ELSIF fbTcpWrite.xDone THEN
    bWriteDoneLatched := TRUE;
END_IF

bDone := bWriteDoneLatched;

完整代码 4:M_SetError

  • 对应源码路径:10 MQTT/MqttClient_V1_0/Device/Application/MQTT/POUs/MqttClient NBS/FB_MqttClient/辅助功能/M_SetError.st
  • 复制使用说明:这是统一错误上报入口,现场诊断时最好和状态机、TCP 方法一起看。
  • 阅读重点:重点看错误码和诊断字符串是怎么被统一落到输出引脚上的,这直接决定了排障效率。
iecst
/// =======================================================================
/// 名称      : M_SetError
/// 功能      : 设置错误状态和诊断信息
/// 说明      : 写入内部错误码与诊断文本,并置位错误标志。
/// 编程人员  : ControlRookie
/// 时间      : 2026-05-05
/// 版本      : V1.0
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD M_SetError : BOOL
VAR_INPUT
    uiErrorCode         : UINT;          // 内部错误码
    sMessage            : STRING(2048);  // 诊断信息文本
END_VAR

// === IMPLEMENTATION ===
bError := TRUE;
eErrorID := TO_INT(uiErrorCode);
sDiagMsg := sMessage;
M_SetError := TRUE;

完整代码 5:M_ResetError

  • 对应源码路径:10 MQTT/MqttClient_V1_0/Device/Application/MQTT/POUs/MqttClient NBS/FB_MqttClient/辅助功能/M_ResetError.st
  • 复制使用说明:这是错误复位入口,和 M_SetError 配套使用。
  • 阅读重点:重点看哪些状态量会被清掉,理解为什么有些问题不是简单拉低 bConnect 就一定能恢复。
iecst
/// =======================================================================
/// 名称      : M_ResetError
/// 功能      : 复位错误状态
/// 说明      : 清除错误标志、错误码与诊断信息并返回成功状态。
/// 编程人员  : ControlRookie
/// 时间      : 2026-05-05
/// 版本      : V1.0
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD M_ResetError : BOOL

// === IMPLEMENTATION ===
bError := FALSE;
eErrorID := 0;
sDiagMsg := '';
M_ResetError := TRUE;

系列导航

  • 系列定位:第 7 篇
  • 上一篇:第6篇 PLC 里写 MQTT,最难的是状态机
  • 下一篇:第8篇 怎么把这套开源 MQTT 客户端真正用起来
评论和回复区

评论区预留

这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。

↑ ↓