ControlRookie
返回文章

第6篇_Retain、Will、KeepAlive:工业现场为什么不能只会转发 PUBLISH

这一篇讲 Broker 的三个现场能力:Retain 保存主题最后值,Will 处理异常离线通知,KeepAlive 清理死连接。它们不是锦上添花,而是让 MQTT 在工业现场真正可用的基础能力。

这一篇讲 Broker 的三个现场能力:Retain 保存主题最后值,Will 处理异常离线通知,KeepAlive 清理死连接。它们不是锦上添花,而是让 MQTT 在工业现场真正可用的基础能力。

适合谁收藏

  • Retain 发布成功但新订阅收不到的人
  • 想用 MQTT 表示设备在线离线状态的人
  • 正在处理客户端死连接的人
  • 想把 Broker 从“能转发”提升到“现场可用”的人

一个 Broker 会转发 PUBLISH,只能说明它能跑通“实时消息”。

但工业现场还会问:

  • 新客户端上线后,能不能马上拿到设备当前状态?
  • 设备异常掉线,其他客户端能不能收到通知?
  • 客户端死了不发包,Broker 会不会一直占着槽位?

这三个问题对应:

Retain、Will、KeepAlive。

先给结论:

只会转发 PUBLISH 的 Broker,只能算半个 Broker。 工业现场要的是状态可恢复、异常可感知、死连接可清理。

但这里的 Retain、Will、KeepAlive 仍然服务于小型工业现场,不等同于带数据库持久化、集群复制和离线会话恢复的大型 Broker。


一、Retain 解决“最后值”问题

假设 PLC 发布:

text
Topic: line1/plc/status
Payload: RUN
Retain: TRUE

后来 HMI 才上线并订阅:

text
line1/plc/status

如果 Broker 支持 Retain,HMI 应该立刻收到 RUN。 如果不支持,HMI 只能等下一次发布。

Retain 生命周期:

图表说明
图表说明

Retain 表不是消息队列。 一个 Topic 只保存最后一条保留消息。


二、Retain 的操作表

操作Broker 行为
Retain=0 发布正常路由,不写 Retain 表
Retain=1 且 Payload 非空写入或覆盖该 Topic 的 Retain
Retain=1 且 Payload 为空清除该 Topic 的 Retain
新订阅命中 RetainSUBACK 后补发保留消息
Topic Filter 命中多个 Retain按预算逐条补发

现场最常见误区:

客户端勾选 Retain 发布成功,不代表 Broker 已经实现 Retain。

真正的验收方法是:

  1. 客户端 A 对 CodeSys 发布一条 Retain 消息。
  2. 客户端 B 重新连接并订阅 CodeSys。
  3. B 应该立即收到这条消息。

三、Will 解决异常离线问题

Will 是客户端在 CONNECT 时提前交给 Broker 的遗嘱消息。

如果客户端异常断线,Broker 代替它发布 Will。

图表说明
图表说明

关键区别:

断开方式是否触发 Will
客户端发送 DISCONNECT否
KeepAlive 超时是
TCP 异常断开是
协议错误导致关闭通常是

这在工业现场非常有用。比如:

text
device/plc01/online = offline

如果 Will 再配合 Retain,就能让新上线的 HMI 也看到“最后离线状态”。


四、KeepAlive 解决死连接问题

MQTT KeepAlive 不是“定时发心跳”这么简单。

它是客户端和 Broker 对连接存活的约定:

如果在 KeepAlive 时间内没有任何控制报文,客户端应该发 PINGREQ;Broker 收到后回 PINGRESP。

Broker 判断超时时通常按 1.5 倍宽限:

图表说明
图表说明

如果客户端声明 KeepAlive = 60,Broker 不应该 60 秒一到就立刻杀。 工业网络有抖动,1.5 倍宽限更稳。


五、Retain / Will / KeepAlive 三者会汇合

这三个功能不是孤立的。

图表说明
图表说明

这也是为什么 Broker 不能只在连接 FB 里“关掉 TCP”就结束。

异常关闭可能会触发 Will。 Will 可能会更新 Retain。 Retain 更新后还要路由给订阅者。


六、ST 代码入口

代码入口作用
FB_MqttBrokerRouter.M_UpdateRetain写入、覆盖或清除 Retain 表
FB_MqttBrokerRouter.M_FindNextRetain新订阅时查找匹配 Retain
FB_MqttBroker.M_ServiceConnections检查连接状态、KeepAlive、异常清理
FB_MqttBroker.M_HandlePublish处理普通发布和 Retain 更新
ST_MqttBrokerRetainedMessageRetain 表项结构

Retain 更新逻辑大概是:

text
IF stPublish.xRetain THEN
    fbRouter.M_UpdateRetain(
        sTopicName := stPublish.sTopicName,
        pPayload := stPublish.pPayload,
        uiPayloadLen := stPublish.uiPayloadLen,
        eQoS := stPublish.eQoS);
END_IF

KeepAlive 检查逻辑大概是:

text
udiTimeoutMs := TO_UDINT(uiKeepAliveSec) * 1500;

IF (udiNowMs - udiLastActivityMs) > udiTimeoutMs THEN
    xNeedClose := TRUE;
    xNeedPublishWill := xWillEnabled;
END_IF

七、现场排障表

现象优先检查可能原因
Retain 勾选后新订阅收不到uiRetainCount、sLastRetainTopic没写 Retain 表或没做订阅补发
Retain 清不掉Payload 长度是否为 0清除规则未实现
正常断开也发 Will是否收到 DISCONNECT正常断开标志没设置
异常断开不发 WillCONNECT 是否解析 WillWill 字段没保存
客户端死了还占槽位udiLastActivityMsKeepAlive 未检查或宽限过大

模型边界与验证路径

Retain、Will、KeepAlive 看起来是三个功能点,往上看其实是会话生命周期模型。

功能解决的边界验证路径
RetainTopic 最后状态由谁保存新客户端订阅后是否立即收到最后值
Will异常离线由谁声明拔掉客户端或异常断开后是否发布 Will
KeepAlive死连接由谁清理停止客户端发送后是否按 1.5 倍宽限清理

结论分级:

结论可信度边界
Retain 必须通过新订阅补发验证high只看发布成功不够
正常 DISCONNECT 不应触发 Willhigh这是 MQTT 会话语义
KeepAlive 超时要结合扫描周期和网络抖动设置medium不同 PLC 任务周期和客户端行为会影响观测结果

这里先不要把 Retain 理解成持久化数据库。当前轻量 Broker 保存的是 PLC 内存中的最后值,断电、下载或工程重启后的行为要按实际工程配置重新验证。


八、这一篇你最该记住的 5 句话

  1. Retain 解决新订阅者获取主题最后值的问题。
  2. Will 解决异常离线通知问题。
  3. KeepAlive 解决死连接清理问题。
  4. 正常 DISCONNECT 不应该触发 Will。
  5. Retain、Will、KeepAlive 最后都会回到 Broker 的路由和连接清理调度里。

下篇预告

下一篇讲性能优化。

重点是:

为什么 PLC Broker 会有明显延迟,以及为什么从“一帧一写”改成“批量粘包写出”能明显改善实时性。

完整 ST 代码

先看 Retain 表项的数据结构,来自 ST_MqttBrokerRetainedMessage.st。它把 Topic、Payload、QoS 和更新时间固定在一条静态表项里,符合 PLC 侧“容量先定死、运行不动态扩张”的工程习惯。

iecst
/// =======================================================================
/// 名称      : ST_MqttBrokerRetainedMessage
/// 功能      : Broker Retain 保留消息表项
/// 说明      : 按 Topic Name 保存最近一条保留消息,新订阅命中后补发。
/// 编程人员  : ControlRookie
/// 时间      : 2026-05-08
/// 版本      : V1.0
/// =======================================================================
TYPE ST_MqttBrokerRetainedMessage :
STRUCT
    xUsed           : BOOL; // 当前 Retain 表项是否有效
    eQoS            : E_MqttQoS; // Retain 消息保存时的 QoS 等级
    uiTopicLen      : UINT; // Topic Name 有效长度[byte]
    uiPayloadLen    : UINT; // Payload 有效长度[byte]
    udiUpdatedAtMs  : ULINT; // 最近一次更新该 Retain 表项的系统时间戳[ms]
    sTopic          : STRING(GVL_MqttBroker.cnMaxTopicLen); // Retain 消息主题名,不允许包含通配符
    sPayload        : STRING(GVL_MqttBroker.cnMaxPayloadLen); // Retain 消息载荷文本;空载荷表示应清除表项
END_STRUCT
END_TYPE

下面这段来自 FB_MqttBrokerRouter.M_UpdateRetain.st。Retain 的规则很直接:Retain PUBLISH 且 Payload 为空时清除保留消息,否则新增或覆盖同 Topic 的保留记录。

text
IF aRetained[uiIndex].sTopic = stPublish.sTopic THEN
    IF stPublish.uiPayloadLen = 0 THEN
        aRetained[uiIndex].xUsed := FALSE;
        aRetained[uiIndex].sTopic := '';
        aRetained[uiIndex].sPayload := '';
        aRetained[uiIndex].uiTopicLen := 0;
        aRetained[uiIndex].uiPayloadLen := 0;
        aRetained[uiIndex].eQoS := E_MqttQoS.byQoS0;
        aRetained[uiIndex].udiUpdatedAtMs := udiNowMs;
        IF uiRetainCount > 0 THEN
            uiRetainCount := uiRetainCount - 1;
        END_IF
    ELSE
        aRetained[uiIndex].eQoS := stPublish.eQoS;
        aRetained[uiIndex].uiTopicLen := stPublish.uiTopicLen;
        aRetained[uiIndex].uiPayloadLen := stPublish.uiPayloadLen;
        aRetained[uiIndex].udiUpdatedAtMs := udiNowMs;
        aRetained[uiIndex].sTopic := stPublish.sTopic;
        aRetained[uiIndex].sPayload := stPublish.sPayload;
    END_IF
    eLastError := E_MqttBrokerError.uiNoError;
    M_UpdateRetain := TRUE;
    RETURN;
END_IF

新订阅命中 Retain 表时,Broker 生成一条带 xRetain := TRUE 的普通投递任务,后续仍复用 PUBLISH 投递队列。

text
IF F_MqttTopicMatch(sFilter := sTopicFilter, sTopic := aRetained[uiIndex].sTopic) THEN
    stDelivery.xValid := TRUE;
    stDelivery.uiSourceSlot := 0;
    stDelivery.uiTargetSlot := uiTargetSlot;
    stDelivery.uiPacketId := 0;
    stDelivery.eQoS := F_MqttMinQoS(ePublishQoS := aRetained[uiIndex].eQoS, eMaxQoS := eMaxQoS);
    stDelivery.xDup := FALSE;
    stDelivery.xRetain := TRUE;
    stDelivery.uiTopicLen := aRetained[uiIndex].uiTopicLen;
    stDelivery.uiPayloadLen := aRetained[uiIndex].uiPayloadLen;
    stDelivery.sTopic := aRetained[uiIndex].sTopic;
    stDelivery.sPayload := aRetained[uiIndex].sPayload;
    xFound := TRUE;
    eLastError := E_MqttBrokerError.uiNoError;
    M_FindNextRetain := TRUE;
    RETURN;
END_IF

Will 的实现不需要另起一套路由机制。异常清理时把 Will 组装成标准 ST_MqttBrokerPublishFrame,再交给 M_HandlePublish,这样 Retain、QoS 和订阅路由都会走同一套逻辑。

text
IF aConnectionStates[uiIndex].xWillFlag AND NOT aConnectionStates[uiIndex].xGracefulDisconnect THEN
    stWill.xValid := TRUE;
    stWill.uiSourceSlot := uiIndex;
    stWill.uiTargetSlot := 0;
    stWill.uiPacketId := 0;
    stWill.eQoS := aConnectionStates[uiIndex].eWillQoS;
    stWill.xDup := FALSE;
    stWill.xRetain := aConnectionStates[uiIndex].xWillRetain;
    stWill.uiTopicLen := TO_UINT(LEN(aConnectionStates[uiIndex].sWillTopic));
    stWill.uiPayloadLen := TO_UINT(LEN(aConnectionStates[uiIndex].sWillPayload));
    stWill.sTopic := aConnectionStates[uiIndex].sWillTopic;
    stWill.sPayload := aConnectionStates[uiIndex].sWillPayload;
    M_HandlePublish(uiSourceSlot := uiIndex, stPublish := stWill);
    stMetrics.udiWillPublished := stMetrics.udiWillPublished + 1;
END_IF

系列导航

  • 系列定位:第 6 篇
  • 上一篇:PUBLISH 不是收到就转发:Broker 怎么处理 QoS、PacketId 和多客户端 fanout
  • 下一篇:为什么 PLC Broker 会有延迟?从 TCP_Write 一帧一写到批量粘包写出
评论和回复区

评论区预留

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

↑ ↓