MQTT 与 Sparkplug B——从车间到云端的最后一公里

1. 从"专线稳如狗"到"4G 飘如风"

上一篇文章拆解了 Modbus RTU 在 RS-485 总线上的字节级时序------3.5 字符间隔、半双工换向、波特率对采集周期的数学约束。在一个封闭的车间里,这些参数一旦调好,系统可以稳定运行数年不出问题。

但数据一旦离开工厂围墙,一切规则都变了。

2022 年我接手一个分布式的水务项目:30 个泵站散布在 50 公里范围内,每个泵站有一个西门子 S7-1200,需要把压力、流量、泵状态实时上报到中心调度室。现场没有光纤,只能用 4G DTU。

第一个方案很"工业"------在每个泵站部署 OPC UA 服务器,中心站通过 VPN 直连每个站点采集。结果惨不忍睹:OPC UA 的安全通道握手需要 4 次 RTT,加上 4G 网络 50-150ms 的延迟,单是建立连接就要 200-600ms。30 个站点循环断开重连,一半的 CPU 时间花在了握手和超时上。更致命的是,4G 基站切换时 IP 地址变化,OPC UA 连接直接断裂,恢复时间以分钟计。

最终方案换成了 MQTT:每个泵站作为一个 MQTT 客户端,向中心 Broker 发布数据。连接断开后自动重连、消息持久化、带宽占用仅是 OPC UA 的 1/10。这个方案稳定运行至今。

这个故事引出一个核心判断:MQTT 不是 OPC UA 或 Modbus 的替代品,而是填补了"数据出车间"这个场景的协议真空。

维度 车间内(Modbus/OPC UA) 车间外(MQTT)
网络假设 有线局域网,延迟 <1ms 无线/互联网,延迟 10-500ms
连接模型 Client-Server(请求-响应) Pub/Sub(发布-订阅)
状态管理 长连接,假设永远在线 短连接,默认离线
数据模型 地址空间/寄存器映射 主题(Topic)+ 键值对
安全边界 物理隔离/防火墙 TLS + 认证 + ACL

关键区别是思维模式的转变:从"拉"(polling)变成"推"(publish)。采集程序不再需要知道数据在哪里、怎么连接,只需要订阅感兴趣的主题。这种解耦在数十到数万个分散节点的场景下是架构级的优势。

2. MQTT QoS------你以为你懂了

MQTT 定义了三个 QoS 级别,文档上写得很简单:

  • QoS 0:至多一次(At most once)
  • QoS 1:至少一次(At least once)
  • QoS 2:恰好一次(Exactly once)

但工程上的代价远不是三行描述能概括的。

QoS 0------最快,但也最不可靠

Subscriber Broker Publisher Subscriber Broker Publisher #mermaid-svg-GyYC4o9IWBdxjaH1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GyYC4o9IWBdxjaH1 .error-icon{fill:#552222;}#mermaid-svg-GyYC4o9IWBdxjaH1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GyYC4o9IWBdxjaH1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GyYC4o9IWBdxjaH1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GyYC4o9IWBdxjaH1 .marker.cross{stroke:#333333;}#mermaid-svg-GyYC4o9IWBdxjaH1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GyYC4o9IWBdxjaH1 p{margin:0;}#mermaid-svg-GyYC4o9IWBdxjaH1 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GyYC4o9IWBdxjaH1 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GyYC4o9IWBdxjaH1 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GyYC4o9IWBdxjaH1 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GyYC4o9IWBdxjaH1 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GyYC4o9IWBdxjaH1 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GyYC4o9IWBdxjaH1 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GyYC4o9IWBdxjaH1 .sequenceNumber{fill:white;}#mermaid-svg-GyYC4o9IWBdxjaH1 #sequencenumber{fill:#333;}#mermaid-svg-GyYC4o9IWBdxjaH1 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GyYC4o9IWBdxjaH1 .messageText{fill:#333;stroke:none;}#mermaid-svg-GyYC4o9IWBdxjaH1 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GyYC4o9IWBdxjaH1 .labelText,#mermaid-svg-GyYC4o9IWBdxjaH1 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GyYC4o9IWBdxjaH1 .loopText,#mermaid-svg-GyYC4o9IWBdxjaH1 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GyYC4o9IWBdxjaH1 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GyYC4o9IWBdxjaH1 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GyYC4o9IWBdxjaH1 .noteText,#mermaid-svg-GyYC4o9IWBdxjaH1 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GyYC4o9IWBdxjaH1 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GyYC4o9IWBdxjaH1 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GyYC4o9IWBdxjaH1 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GyYC4o9IWBdxjaH1 .actorPopupMenu{position:absolute;}#mermaid-svg-GyYC4o9IWBdxjaH1 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GyYC4o9IWBdxjaH1 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GyYC4o9IWBdxjaH1 .actor-man circle,#mermaid-svg-GyYC4o9IWBdxjaH1 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GyYC4o9IWBdxjaH1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 转发给订阅者 无确认,无重试,无存储 PUBLISH (QoS 0)PUBLISH (QoS 0)

QoS 0 的本质是 UDP-like------发出去就是发出去,Broker 不确认、不存储、不保证任何订阅者能收到。适用于周期性的状态上报(比如温度每 5 秒上报一次,丢一两帧无所谓)。

工程陷阱:很多人认为"反正数据更新快,丢几帧不要紧",但在以下场景中 QoS 0 会系统性丢数据:

  1. Broker 过载:当发布速率超过 Broker 的转发能力时,QoS 0 消息直接被丢弃(没有背压机制)
  2. 订阅者离线:QoS 0 消息不会被存储,离线期间的更新全部丢失
  3. 网络抖动:TCP 层面的重传对 MQTT 透明,但重传延迟可能导致消息到达时已被应用层判为超时丢弃

QoS 1------工业场景的"默认选项"

Broker Publisher Broker Publisher #mermaid-svg-JCBUVibaINMpiO5M{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JCBUVibaINMpiO5M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JCBUVibaINMpiO5M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JCBUVibaINMpiO5M .error-icon{fill:#552222;}#mermaid-svg-JCBUVibaINMpiO5M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JCBUVibaINMpiO5M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JCBUVibaINMpiO5M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JCBUVibaINMpiO5M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JCBUVibaINMpiO5M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JCBUVibaINMpiO5M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JCBUVibaINMpiO5M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JCBUVibaINMpiO5M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JCBUVibaINMpiO5M .marker.cross{stroke:#333333;}#mermaid-svg-JCBUVibaINMpiO5M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JCBUVibaINMpiO5M p{margin:0;}#mermaid-svg-JCBUVibaINMpiO5M .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JCBUVibaINMpiO5M text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-JCBUVibaINMpiO5M .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JCBUVibaINMpiO5M .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-JCBUVibaINMpiO5M .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-JCBUVibaINMpiO5M .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-JCBUVibaINMpiO5M #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-JCBUVibaINMpiO5M .sequenceNumber{fill:white;}#mermaid-svg-JCBUVibaINMpiO5M #sequencenumber{fill:#333;}#mermaid-svg-JCBUVibaINMpiO5M #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-JCBUVibaINMpiO5M .messageText{fill:#333;stroke:none;}#mermaid-svg-JCBUVibaINMpiO5M .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JCBUVibaINMpiO5M .labelText,#mermaid-svg-JCBUVibaINMpiO5M .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-JCBUVibaINMpiO5M .loopText,#mermaid-svg-JCBUVibaINMpiO5M .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-JCBUVibaINMpiO5M .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JCBUVibaINMpiO5M .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-JCBUVibaINMpiO5M .noteText,#mermaid-svg-JCBUVibaINMpiO5M .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-JCBUVibaINMpiO5M .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JCBUVibaINMpiO5M .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JCBUVibaINMpiO5M .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JCBUVibaINMpiO5M .actorPopupMenu{position:absolute;}#mermaid-svg-JCBUVibaINMpiO5M .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-JCBUVibaINMpiO5M .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JCBUVibaINMpiO5M .actor-man circle,#mermaid-svg-JCBUVibaINMpiO5M line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-JCBUVibaINMpiO5M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 收到 PUBACK → 删除本地缓存 未收到 PUBACK → 重传 PUBLISH (QoS 1, PacketID=1)PUBACK (PacketID=1)

QoS 1 保证消息至少到达 Broker 一次。代价是重复的可能性------如果 PUBACK 在网络中丢失,Publisher 重发,Broker 会收到两条相同的消息。

真实数据:我在水务项目中实测,4G 网络下 QoS 1 的重复率约 0.3%-1.2%(取决于网络质量)。这意味着每 1000 条消息中有 3-12 条是重复的,需要在应用层做去重。

QoS 2------"恰好一次"的四段手语

Broker Publisher Broker Publisher #mermaid-svg-k0Yo0E5KOlVdOXzh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k0Yo0E5KOlVdOXzh .error-icon{fill:#552222;}#mermaid-svg-k0Yo0E5KOlVdOXzh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k0Yo0E5KOlVdOXzh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k0Yo0E5KOlVdOXzh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k0Yo0E5KOlVdOXzh .marker.cross{stroke:#333333;}#mermaid-svg-k0Yo0E5KOlVdOXzh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k0Yo0E5KOlVdOXzh p{margin:0;}#mermaid-svg-k0Yo0E5KOlVdOXzh .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k0Yo0E5KOlVdOXzh text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-k0Yo0E5KOlVdOXzh .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-k0Yo0E5KOlVdOXzh .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-k0Yo0E5KOlVdOXzh .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-k0Yo0E5KOlVdOXzh .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-k0Yo0E5KOlVdOXzh #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-k0Yo0E5KOlVdOXzh .sequenceNumber{fill:white;}#mermaid-svg-k0Yo0E5KOlVdOXzh #sequencenumber{fill:#333;}#mermaid-svg-k0Yo0E5KOlVdOXzh #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-k0Yo0E5KOlVdOXzh .messageText{fill:#333;stroke:none;}#mermaid-svg-k0Yo0E5KOlVdOXzh .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k0Yo0E5KOlVdOXzh .labelText,#mermaid-svg-k0Yo0E5KOlVdOXzh .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-k0Yo0E5KOlVdOXzh .loopText,#mermaid-svg-k0Yo0E5KOlVdOXzh .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-k0Yo0E5KOlVdOXzh .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-k0Yo0E5KOlVdOXzh .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-k0Yo0E5KOlVdOXzh .noteText,#mermaid-svg-k0Yo0E5KOlVdOXzh .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-k0Yo0E5KOlVdOXzh .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k0Yo0E5KOlVdOXzh .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k0Yo0E5KOlVdOXzh .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k0Yo0E5KOlVdOXzh .actorPopupMenu{position:absolute;}#mermaid-svg-k0Yo0E5KOlVdOXzh .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-k0Yo0E5KOlVdOXzh .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k0Yo0E5KOlVdOXzh .actor-man circle,#mermaid-svg-k0Yo0E5KOlVdOXzh line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-k0Yo0E5KOlVdOXzh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 收到 PUBREC → 释放 PUBLISH 缓存 收到 PUBREL → 投递消息并释放 PUBLISH (QoS 2, PacketID=1)PUBREC (PacketID=1)PUBREL (PacketID=1)PUBCOMP (PacketID=1)

QoS 2 用 4 段交互(PUBLISH → PUBREC → PUBREL → PUBCOMP)保证消息的唯一性。代价是延迟和吞吐量。

QoS 交互次数 延迟(@50ms 网络 RTT) 吞吐损失(相对 QoS 0) 重复率
0 1 ~50ms 1x 0%(但可能丢)
1 2 ~100ms ~2x 0.3-1.2%
2 4 ~200ms ~4x 0%(理论)

关键结论:QoS 2 在工业场景中很少值得用。它消除的那 0.3-1.2% 重复率,在应用层用一个递增序列号就能解决,但代价是吞吐量下降 4 倍。我见过的生产系统 90% 使用 QoS 1 + 应用层去重。

最小可运行 MQTT 示例

下面是一个完整的 MQTT 发布-订阅示例,展示了 QoS 的实际行为:

python 复制代码
"""
mqtt_qos_demo.py --- MQTT QoS 0/1/2 行为对比
依赖: pip install paho-mqtt

启动方式:先启动一个 MQTT Broker(如 Mosquitto),然后分别运行:
  python mqtt_qos_demo.py sub    # 订阅端
  python mqtt_qos_demo.py pub    # 发布端
"""
import paho.mqtt.client as mqtt
import time
import sys
import json

BROKER = "localhost"
TOPIC = "plc/test/qos"

# ===== 订阅端 =====
received = {}  # 用 PacketID 去重

def on_message_sub(client, userdata, msg):
    """订阅端的消息处理------带去重逻辑"""
    try:
        payload = json.loads(msg.payload.decode())
        pid = payload.get("packet_id")
        qos = msg.qos

        if qos >= 1 and pid in received:
            print(f"[重复] QoS{qos} PacketID={pid} --- 已丢弃")
            return

        received[pid] = time.time()
        print(f"[收到] QoS{qos} | PID={pid} | 数据={payload['data']} | "
              f"延迟={payload.get('ts', 0):.1f}ms")
    except Exception as e:
        print(f"解析错误: {e}")


def run_subscriber():
    client = mqtt.Client(client_id="plc-sub-demo")
    client.on_message = on_message_sub
    client.connect(BROKER, 1883, 60)

    # 分别订阅三个 QoS
    for qos in [0, 1, 2]:
        client.subscribe(f"{TOPIC}/qos{qos}", qos=qos)

    print(f"订阅端已启动 -> {BROKER}:1883")
    client.loop_forever()


# ===== 发布端 =====
def run_publisher():
    client = mqtt.Client(client_id="plc-pub-demo")
    client.connect(BROKER, 1883, 60)
    client.loop_start()

    print("发布端已启动,每 2 秒发送一组消息")
    for i in range(10):
        payload = json.dumps({
            "packet_id": i,
            "data": f"sensor_value_{i}",
            "ts": time.time() * 1000  # 毫秒时间戳
        })

        # 同时发布到三个 QoS 主题
        for qos in [0, 1, 2]:
            info = client.publish(f"{TOPIC}/qos{qos}", payload, qos=qos)
            if qos > 0:
                # QoS 1/2 可以等待发布完成
                info.wait_for_publish()
                print(f"[发布] QoS{qos} PID={i} --- mid={info.mid}")
            else:
                print(f"[发布] QoS{qos} PID={i} --- 无确认")

        time.sleep(2)

    client.loop_stop()
    client.disconnect()


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法: python mqtt_qos_demo.py [pub|sub]")
        sys.exit(1)
    if sys.argv[1] == "pub":
        run_publisher()
    else:
        run_subscriber()

运行这个 demo,你会看到:

  • QoS 0:发布端无确认,订阅端可能丢失消息(在 Broker 日志中能看到)
  • QoS 1:偶现重复(断开网络重试时容易触发)
  • QoS 2:无重复,但消息到达延迟明显高于 QoS 1

3. Sparkplug B------工业 MQTT 的事实标准

裸 MQTT 有一个根本问题:它不知道数据长什么样子 。一个温度值是 65.3 还是 0x42 0x82 0x99 0x9A,MQTT 不关心。这就导致不同厂商的设备虽然都用 MQTT,但数据格式各自为政------上层平台要为每种设备写不同的解析器。

Sparkplug B(由 Cirrus Link 提出,现为 Eclipse 基金会项目)解决了这个问题。它在 MQTT 之上定义了三件事:

  1. 主题命名空间:统一的 Topic 层级结构
  2. 状态机:BIRTH/DEATH 证书机制
  3. 数据序列化:用 Protocol Buffers(Protobuf)定义标准载荷格式

3.1 主题命名空间

复制代码
spBv1.0/<group_id>/<message_type>/<edge_node_id>[/<device_id>]
|--------| |---------| |-----------| |------------| |--------|
  版本      分组ID      消息类型      边缘节点ID     设备ID(可选)

消息类型(message_type)

类型 全称 方向 说明
NBIRTH Node Birth 节点→Broker 边缘节点上线声明
NDEATH Node Death Broker→订阅者 边缘节点离线通知
DBIRTH Device Birth 节点→Broker 子设备上线声明
DDEATH Device Death Broker→订阅者 子设备离线通知
NDATA Node Data 节点→Broker 节点数据更新
DDATA Device Data 节点→Broker 子设备数据更新
NCMD Node Command 订阅者→Broker→节点 下行指令到节点
DCMD Device Command 订阅者→Broker→节点 下行指令到设备
STATE State SCADA→Broker SCADA 主站状态声明

3.2 BIRTH/DEATH 状态机------Sparkplug B 最核心的发明

传统 MQTT 中,一个传感器掉线了,你没办法知道------因为收不到消息和传感器死了,在 Broker 看来是一样的(都是零数据到达)。Sparkplug B 用 LWT(Last Will and Testament) 机制解决了这个问题。
#mermaid-svg-FtmqTlrhrloBBTlk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FtmqTlrhrloBBTlk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FtmqTlrhrloBBTlk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FtmqTlrhrloBBTlk .error-icon{fill:#552222;}#mermaid-svg-FtmqTlrhrloBBTlk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FtmqTlrhrloBBTlk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FtmqTlrhrloBBTlk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FtmqTlrhrloBBTlk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FtmqTlrhrloBBTlk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FtmqTlrhrloBBTlk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FtmqTlrhrloBBTlk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FtmqTlrhrloBBTlk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FtmqTlrhrloBBTlk .marker.cross{stroke:#333333;}#mermaid-svg-FtmqTlrhrloBBTlk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FtmqTlrhrloBBTlk p{margin:0;}#mermaid-svg-FtmqTlrhrloBBTlk defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-FtmqTlrhrloBBTlk g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-FtmqTlrhrloBBTlk g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-FtmqTlrhrloBBTlk g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-FtmqTlrhrloBBTlk g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-FtmqTlrhrloBBTlk g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-FtmqTlrhrloBBTlk .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-FtmqTlrhrloBBTlk .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-FtmqTlrhrloBBTlk .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-FtmqTlrhrloBBTlk .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-FtmqTlrhrloBBTlk .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-FtmqTlrhrloBBTlk .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-FtmqTlrhrloBBTlk .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-FtmqTlrhrloBBTlk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FtmqTlrhrloBBTlk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FtmqTlrhrloBBTlk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FtmqTlrhrloBBTlk .edgeLabel .label text{fill:#333;}#mermaid-svg-FtmqTlrhrloBBTlk .label div .edgeLabel{color:#333;}#mermaid-svg-FtmqTlrhrloBBTlk .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-FtmqTlrhrloBBTlk .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-FtmqTlrhrloBBTlk .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-FtmqTlrhrloBBTlk .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-FtmqTlrhrloBBTlk .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-FtmqTlrhrloBBTlk .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FtmqTlrhrloBBTlk .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FtmqTlrhrloBBTlk #statediagram-barbEnd{fill:#333333;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FtmqTlrhrloBBTlk .cluster-label,#mermaid-svg-FtmqTlrhrloBBTlk .nodeLabel{color:#131300;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-FtmqTlrhrloBBTlk .note-edge{stroke-dasharray:5;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-note text{fill:black;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram-note .nodeLabel{color:black;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagram .edgeLabel{color:red;}#mermaid-svg-FtmqTlrhrloBBTlk #dependencyStart,#mermaid-svg-FtmqTlrhrloBBTlk #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-FtmqTlrhrloBBTlk .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FtmqTlrhrloBBTlk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 网络连接建立
发送 BIRTH 成功
有数据更新
发布 NDATA/DDATA
断开连接
LWT 触发(非正常离线)
主站确认 DEATH
主站要求重新注册
OFFLINE
BIRTH_PENDING
ONLINE
DATA
DEATH
节点在这个状态时不发布任何数据
发布 NBIRTH + DBIRTH

包含所有数据点的元信息
Broker 自动发布 NDEATH/DDEATH

告知所有订阅者该节点已离线

BIRTH 消息包含的内容

  • 节点的所有数据点定义(名称、类型、单位)
  • 当前值快照
  • 节点属性(序列号、固件版本、地理位置等)

这意味着订阅者不需要预先配置------只要收到一条 BIRTH 消息,就知道这个节点有什么数据、是什么类型、当前值多少。这在设备即插即用(Plug & Play)的场景下极为强大。

DEATH 消息的触发机制

复制代码
连接流程:
  1. 客户端连接 Broker
  2. 客户端发送 CONNECT 报文,其中包含 Will Topic 和 Will Message
  3. Broker 存储 Will 消息
  4. 客户端发送 BIRTH 消息
  5. 正常通信...

断开流程(正常):
  1. 客户端发送 DISCONNECT
  2. Broker 清除 Will 消息
  3. 不触发 DEATH

断开流程(异常):
  1. 网络中断 / 断电 / 进程崩溃
  2. Broker 检测到 TCP 连接断开
  3. Broker 发布 Will Topic(即 NDEATH/DDEATH)
  4. 订阅者收到 DEATH → 标记该节点离线

3.3 Protobuf 序列化------为什么不用 JSON

Sparkplug B 使用 Google Protocol Buffers(Protobuf)作为载荷编码格式。这不是偶然的。

维度 JSON Protobuf
1000 个数据点编码后大小 ~15-25 KB ~2-4 KB
编码/解码 CPU 开销 中(需解析 schema)
可读性 人类可读 二进制,需工具查看
Schema 约束 无(可随意增减字段) 有(严格定义)
多语言支持 原生 需要 .proto 编译

带宽实测(我用 4G DTU 在真实网络中的测试数据):

复制代码
场景:边缘节点上报 50 个数据点(温度、压力、流量混合)

JSON 方式:
  Topic: spBv1.0/water/NDATA/pump01
  Payload: {"timestamp": 1680000000000, "metrics": [
    {"name": "temp_1", "value": 65.3, "type": "float"},
    {"name": "press_1", "value": 2.4, "type": "float"},
    ... 48 more ...
  ]}
  大小: 3,847 字节
  每月流量(每 10s 上报一次): ~995 MB

Protobuf 方式(Sparkplug B):
  Topic: spBv1.0/water/NDATA/pump01
  Payload: <二进制,相同数据>
  大小: 874 字节
  每月流量(每 10s 上报一次): ~226 MB

节省: 77%

当你有 30 个泵站、每个泵站 10s 上报一次时:

编码方式 单站月流量 30 站月流量 年流量成本(4G 流量 ¥10/GB)
JSON ~995 MB ~29.9 GB ~¥299
Protobuf ~226 MB ~6.8 GB ~¥68

在 NB-IoT 等窄带物联网场景下,这个差距更大。这也是为什么 Sparkplug B 强制使用 Protobuf------不是炫技,是算过账的。

4. 完整实战:从 Modbus RTU 到 MQTT Edge-to-Cloud

下面实现一个完整的边缘网关:从 Modbus RTU 采集数据 → 封装成 Sparkplug B 消息 → 发布到 MQTT Broker。

4.1 Sparkplug B 的 Protobuf 定义

首先定义 Sparkplug B 的 .proto 文件(简化版,仅含核心类型):

protobuf 复制代码
// sparkplug_b.proto --- Sparkplug B 核心消息定义
syntax = "proto2";

package org.eclipse.tahu.protobuf;

message Payload {
    // 64 位时间戳(Unix 毫秒)
    optional uint64 timestamp = 1;
    // 序列号(用于去重和排序)
    optional uint64 seq = 2;
    // metric 列表
    repeated Metric metrics = 3;

    message Metric {
        optional string name = 1;          // metric 名称
        optional uint64 timestamp = 2;     // 数据时间戳
        optional uint32 datatype = 3;      // 数据类型(见下表)

        oneof value {
            bool boolean_value = 4;
            uint64 long_value = 5;
            double float_value = 6;
            string string_value = 7;
            bytes bytes_value = 8;
        }

        optional string unit = 9;          // 单位
    }
}

数据类型编号(datatype)

编号 类型 说明 C/Python 对应
1 Int8 有符号 8 位 int8 / ctypes.c_int8
2 Int16 有符号 16 位 int16 / struct 'h'
3 Int32 有符号 32 位 int32 / struct 'i'
4 Int64 有符号 64 位 int64 / struct 'q'
8 Float 32 位浮点 float / struct 'f'
9 Double 64 位浮点 double / struct 'd'
11 Boolean 布尔 bool
12 String UTF-8 字符串 str
14 DateTime 64 位毫秒时间戳 int64 / datetime

4.2 完整边缘网关代码

python 复制代码
"""
sparkplug_gateway.py --- Sparkplug B 边缘网关
从 Modbus RTU(或模拟数据)采集 → 封装 Sparkplug B → 发布到 MQTT

依赖: pip install paho-mqtt protobuf pyserial
编译 protobuf: protoc --python_out=. sparkplug_b.proto

在真实使用中,建议直接用 sparkplugb-python 库:
  pip install sparkplugb-python
但本示例手动构建 protobuf 消息,以便于理解内部结构。
"""
import time
import json
import struct
import random
import logging
import socket
from typing import Dict, List, Optional
from datetime import datetime
from enum import IntEnum

import paho.mqtt.client as mqtt

# 假设已经用 protoc 编译了 sparkplug_b.proto
# from sparkplug_b_pb2 import Payload
# 为保持本示例可直接运行,这里用 Python dict 模拟结构
# 实际使用时请替换为编译后的 protobuf 类

logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger("SparkplugGateway")


class DataType(IntEnum):
    """Sparkplug B 数据类型枚举"""
    INT16 = 2
    INT32 = 3
    FLOAT = 8
    DOUBLE = 9
    BOOLEAN = 11
    STRING = 12
    DATETIME = 14


class MessageType:
    """Sparkplug B 消息类型"""
    NBIRTH = "NBIRTH"
    NDEATH = "NDEATH"
    DBIRTH = "DBIRTH"
    DDEATH = "DDEATH"
    NDATA = "NDATA"
    DDATA = "DDATA"
    NCMD = "NCMD"
    DCMD = "DCMD"
    STATE = "STATE"


class SparkplugNode:
    """
    Sparkplug B 边缘节点
    管理 BIRTH/DEATH 状态机,负责所有 Sparkplug B 消息的组装和发布
    """

    def __init__(self, group_id: str, node_id: str,
                 broker_host: str = "localhost",
                 broker_port: int = 1883,
                 use_tls: bool = False,
                 username: Optional[str] = None,
                 password: Optional[str] = None):
        self.group_id = group_id
        self.node_id = node_id
        self.broker_host = broker_host
        self.broker_port = broker_port
        self.seq = 0  # 序列号,用于消息去重

        # 设备注册表:device_id -> {metric_name: (value, type, unit)}
        self.devices: Dict[str, Dict] = {}

        # 构造 MQTT 客户端
        self.client = mqtt.Client(
            client_id=f"sparkplug_{node_id}",
            protocol=mqtt.MQTTv311
        )

        if username and password:
            self.client.username_pw_set(username, password)

        # 注册回调
        self.client.on_connect = self._on_connect
        self.client.on_disconnect = self._on_disconnect
        self.client.on_message = self._on_message

    def register_device(self, device_id: str,
                        metrics: Dict[str, tuple]):
        """
        注册子设备及其指标

        Args:
            device_id: 设备 ID(如 "pump_01")
            metrics: 指标字典
                { "metric_name": (初始值, DataType, "单位") }
                例: { "temperature": (25.0, DataType.FLOAT, "℃") }
        """
        self.devices[device_id] = {
            "metrics": metrics,
            "last_values": {k: v[0] for k, v in metrics.items()}
        }
        logger.info(f"注册设备 {device_id},含 {len(metrics)} 个指标")

    def _topic(self, msg_type: str, device_id: str = None) -> str:
        """构造 Sparkplug B 主题"""
        if device_id:
            return f"spBv1.0/{self.group_id}/{msg_type}/{self.node_id}/{device_id}"
        return f"spBv1.0/{self.group_id}/{msg_type}/{self.node_id}"

    def _build_metric(self, name: str, value, dtype: DataType,
                      unit: str = "", ts: int = None) -> dict:
        """构造一个 metric 字典(模拟 protobuf 结构)"""
        return {
            "name": name,
            "timestamp": ts or int(time.time() * 1000),
            "datatype": dtype.value,
            "value": value,
            "unit": unit
        }

    def _build_payload(self, metrics: list, is_birth: bool = False) -> dict:
        """构造 Sparkplug B Payload"""
        self.seq += 1
        return {
            "timestamp": int(time.time() * 1000),
            "seq": self.seq,
            "metrics": metrics,
            "is_birth": is_birth  # 标记是否为 BIRTH 消息
        }

    def _publish(self, topic: str, payload: dict, qos: int = 1):
        """发布消息(JSON 序列化,真实环境用 protobuf)"""
        data = json.dumps(payload)
        info = self.client.publish(topic, data, qos=qos)
        logger.debug(f"发布 -> {topic} | seq={payload.get('seq')} | "
                     f"metrics={len(payload.get('metrics', []))}")
        return info

    def birth(self):
        """
        发送 BIRTH 消息(节点上线声明)

        必须严格按照 Sparkplug B 规范顺序:
        1. 先发送 NBIRTH(节点本身的信息)
        2. 对每个设备发送 DBIRTH(设备的信息)
        """
        # 构造节点级别的 metric(节点自身的状态信息)
        node_metrics = [
            self._build_metric("NodeControl/NextServer", "localhost",
                               DataType.STRING),
            self._build_metric("NodeControl/RebootBirth", False,
                               DataType.BOOLEAN),
            self._build_metric("NodeControl/ScanRate", 5000,
                               DataType.INT32, "ms"),
        ]

        # 发布 NBIRTH
        nb_topic = self._topic(MessageType.NBIRTH)
        nb_payload = self._build_payload(node_metrics, is_birth=True)
        self._publish(nb_topic, nb_payload, qos=1)

        # 对每个设备发布 DBIRTH
        for dev_id, dev_info in self.devices.items():
            dev_metrics = []
            for metric_name, (init_val, dtype, unit) in dev_info["metrics"].items():
                dev_metrics.append(
                    self._build_metric(metric_name, init_val, dtype, unit)
                )

            db_topic = self._topic(MessageType.DBIRTH, dev_id)
            db_payload = self._build_payload(dev_metrics, is_birth=True)
            self._publish(db_topic, db_payload, qos=1)

        logger.info(f"BIRTH 完成:{self.node_id} -> "
                     f"{len(self.devices)} 个设备")

    def publish_data(self, device_id: str,
                     updates: Dict[str, float or bool or str]):
        """
        发布设备数据更新(DDATA)

        Args:
            device_id: 设备 ID
            updates: 更新的指标 { "metric_name": 新值 }
        """
        if device_id not in self.devices:
            logger.error(f"设备 {device_id} 未注册")
            return

        device = self.devices[device_id]
        metrics_def = device["metrics"]
        last_values = device["last_values"]

        # 构建更新的 metric 列表
        metric_list = []
        for name, new_val in updates.items():
            if name not in metrics_def:
                logger.warning(f"未知指标 {name},跳过")
                continue

            _, dtype, unit = metrics_def[name]
            metric_list.append(
                self._build_metric(name, new_val, dtype, unit)
            )
            last_values[name] = new_val

        if not metric_list:
            return

        # 发布 DDATA
        topic = self._topic(MessageType.DDATA, device_id)
        payload = self._build_payload(metric_list)
        self._publish(topic, payload, qos=1)

    def _on_connect(self, client, userdata, flags, rc):
        """MQTT 连接成功回调------发送 BIRTH"""
        if rc == 0:
            logger.info(f"MQTT 已连接 -> {self.broker_host}")
            self.birth()

            # 订阅 NCMD/DCMD(接收下行指令)
            self.client.subscribe(
                self._topic(MessageType.NCMD), qos=1
            )
            for dev_id in self.devices:
                self.client.subscribe(
                    self._topic(MessageType.DCMD, dev_id), qos=1
                )
        else:
            logger.error(f"MQTT 连接失败,rc={rc}")

    def _on_disconnect(self, client, userdata, rc):
        """MQTT 断开回调"""
        if rc != 0:
            logger.warning("MQTT 非正常断开,等待重连...")
        else:
            logger.info("MQTT 正常断开")

    def _on_message(self, client, userdata, msg):
        """处理下行指令"""
        logger.info(f"收到命令: {msg.topic} -> {msg.payload}")

    def set_will(self):
        """
        设置遗嘱消息(LWT)
        Broker 在网络断开时自动发布 NDEATH
        """
        # NDEATH 消息(节点离线)
        death_topic = self._topic(MessageType.NDEATH)
        death_payload = json.dumps({
            "timestamp": int(time.time() * 1000),
            "seq": -1,
            "metrics": []
        })
        self.client.will_set(death_topic, death_payload, qos=1, retain=False)

        # 对每个设备设置 DDEATH
        for dev_id in self.devices:
            ddeath_topic = self._topic(MessageType.DDEATH, dev_id)
            ddeath_payload = json.dumps({
                "timestamp": int(time.time() * 1000),
                "seq": -1,
                "metrics": []
            })
            self.client.will_set(ddeath_topic, ddeath_payload, qos=1, retain=False)

        logger.info("遗嘱消息已设置")

    def start(self):
        """启动网关"""
        self.set_will()
        self.client.connect(self.broker_host, self.broker_port, keepalive=30)
        self.client.loop_start()
        logger.info("Sparkplug 网关已启动")

    def stop(self):
        """停止网关"""
        self.client.loop_stop()
        self.client.disconnect()
        logger.info("Sparkplug 网关已停止")


# ===== 使用示例 =====
def simulate_sensor_readings() -> Dict[str, float]:
    """
    模拟从 Modbus RTU 读取传感器数据
    真实环境中替换为 ModbusRTUMaster.read_holding_registers()
    """
    return {
        "temperature": round(random.uniform(20.0, 80.0), 1),
        "pressure": round(random.uniform(0.5, 5.0), 2),
        "flow_rate": round(random.uniform(10.0, 100.0), 1),
        "motor_speed": round(random.uniform(1000, 3000), 0),
        "vibration": round(random.uniform(0.1, 5.0), 2),
    }


if __name__ == "__main__":
    # 创建 Sparkplug B 边缘节点
    gateway = SparkplugNode(
        group_id="water_plant",
        node_id="gw_pump_station_01",
        broker_host="localhost",
        broker_port=1883,
        # 真实环境启用 TLS 和认证
        # use_tls=True,
        # username="edge_node",
        # password="your_password_here"
    )

    # 注册设备(模拟 3 台水泵)
    for i in range(1, 4):
        pump_id = f"pump_{i:02d}"
        gateway.register_device(pump_id, {
            "temperature": (25.0, DataType.FLOAT, "℃"),
            "pressure": (1.0, DataType.FLOAT, "MPa"),
            "flow_rate": (50.0, DataType.FLOAT, "m³/h"),
            "motor_speed": (1450, DataType.INT32, "rpm"),
            "vibration": (0.5, DataType.FLOAT, "mm/s"),
            "running": (True, DataType.BOOLEAN, ""),
        })

    # 启动网关
    gateway.start()

    try:
        # 模拟数据采集与发布循环
        while True:
            for i in range(1, 4):
                readings = simulate_sensor_readings()
                gateway.publish_data(f"pump_{i:02d}", readings)

            logger.info("一轮采集完成,等待 5 秒...")
            time.sleep(5)

    except KeyboardInterrupt:
        logger.info("用户中断")
    finally:
        gateway.stop()

4.3 云平台订阅端

python 复制代码
"""
sparkplug_subscriber.py --- Sparkplug B 云平台订阅端
接收并解析所有边缘节点的数据
"""
import paho.mqtt.client as mqtt
import json
import time
import logging

logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s | %(message)s')
logger = logging.getLogger("CloudSubscriber")

# 设备在线状态
device_online: dict = {}

def on_message(client, userdata, msg):
    """统一的 Sparkplug B 消息处理"""
    try:
        # 解析主题
        parts = msg.topic.split("/")
        # spBv1.0/<group_id>/<msg_type>/<node_id>[/<device_id>]
        if len(parts) < 4:
            return

        version = parts[0]
        group_id = parts[1]
        msg_type = parts[2]
        node_id = parts[3]
        device_id = parts[4] if len(parts) > 4 else None

        payload = json.loads(msg.payload.decode())

        # 根据消息类型处理
        if msg_type == "NBIRTH":
            logger.info(f"[节点上线] {node_id} (组: {group_id})")

        elif msg_type == "NDEATH":
            logger.warning(f"[节点离线] {node_id}")
            # 清除该节点下所有设备的在线状态
            for key in list(device_online.keys()):
                if key.startswith(f"{node_id}/"):
                    del device_online[key]

        elif msg_type == "DDATA":
            # 更新在线状态
            dev_key = f"{node_id}/{device_id}"
            device_online[dev_key] = time.time()

            # 记录数据
            metrics = payload.get("metrics", [])
            for m in metrics:
                logger.info(f"  {dev_key}/{m['name']} = "
                            f"{m['value']} {m.get('unit', '')}")

        elif msg_type == "DBIRTH":
            logger.info(f"[设备上线] {node_id}/{device_id}")
            dev_key = f"{node_id}/{device_id}"
            device_online[dev_key] = time.time()

    except Exception as e:
        logger.error(f"解析消息失败: {e} | topic={msg.topic}")


def check_offline_devices(threshold_sec: float = 15.0):
    """定期检查设备是否离线(超过阈值未上报数据)"""
    now = time.time()
    for dev_key, last_seen in list(device_online.items()):
        if now - last_seen > threshold_sec:
            logger.warning(f"[设备超时] {dev_key} --- "
                           f"已 {now - last_seen:.0f}s 无数据")
            # 可选:触发报警或标记离线


if __name__ == "__main__":
    client = mqtt.Client(client_id="cloud_scada_01")
    client.on_message = on_message

    # 连接 Broker
    client.connect("localhost", 1883, 60)

    # 订阅所有 Sparkplug B 消息
    client.subscribe("spBv1.0/#", qos=1)

    logger.info("云平台订阅端已启动,等待数据...")
    client.loop_start()

    # 后台监控线程(简化:在主循环中做)
    try:
        while True:
            check_offline_devices(20.0)
            time.sleep(5)
    except KeyboardInterrupt:
        pass
    finally:
        client.loop_stop()
        client.disconnect()

5. 工程部署的关键参数

5.1 Keep Alive 和重连

MQTT 的 Keep Alive 机制是工业场景中最常被误配的参数:

python 复制代码
# 错误示范:keepalive 设得太大
client.connect("broker", 1883, keepalive=300)  # 5 分钟!
# 后果:Broker 需要 300 秒才能检测到客户端离线
# 这段时间内的数据全部丢失,DEATH 消息延迟 5 分钟触发

# 正确做法
# 4G 网络:keepalive=10-30s,WiFi:keepalive=30-60s
# 计算公式:keepalive = 期望的离线检测时间 × 1.5
client.connect("broker", 1883, keepalive=30)
网络类型 推荐 Keep Alive 离线检测延迟 月流量开销
有线以太网 60s ~90s 极低
WiFi 30-60s ~45-90s
4G 10-30s ~15-45s
NB-IoT 600-3600s ~900-5400s 极低(省电模式)

5.2 Clean Session vs Persistent Session

python 复制代码
# 持久会话------Broker 存储订阅关系和离线消息
client = mqtt.Client(client_id="fixed_id", clean_session=False)
# 
# clean_session=False 的效果:
# 1. Broker 记录该客户端的订阅关系
# 2. Broker 缓存 QoS 1/2 的离线消息
# 3. 重连后不需要重新订阅,不丢失离线期间的消息
#
# 代价:Broker 需要维护会话状态,大量客户端会消耗内存

# 干净会话------每次连接都是全新的
client = mqtt.Client(client_id="fixed_id", clean_session=True)
# 重连后需要重新订阅,不保留离线消息

工业场景建议

  • 边缘网关clean_session=False --- 保证离线消息不丢
  • 云平台订阅端clean_session=False --- 避免断线后遗漏报警
  • 临时调试工具clean_session=True --- 不留垃圾会话

5.3 Topic 层级与通配符权限

复制代码
# 推荐的分级 Topic 设计
工厂/区域/产线/设备类型/设备ID/指标

# 安全最佳实践:最小权限原则
# 边缘节点 A 只能发布自己的主题
ACL:
  user: node_a
  topic: write spBv1.0/factory/NBIRTH/node_a
  topic: write spBv1.0/factory/NDATA/node_a/+
  topic: write spBv1.0/factory/DBIRTH/node_a/+
  topic: write spBv1.0/factory/DDATA/node_a/+

# 云平台可以订阅所有主题
ACL:
  user: cloud_scada
  topic: read spBv1.0/#

6. 常见深坑与根本原因分析

深坑 1:Broker 的"五指山"------Session 爆炸

现象:Broker 在运行数月后突然 OOM 崩溃。

根因 :每个边缘节点使用 clean_session=False 连接时,Broker 都会为其维护会话状态。当节点频繁重连(4G 网络不稳定导致每天重连数十次),Broker 上积累了大量僵尸会话。Mosquitto 默认的 persistent_client_expiration1w(一周不活跃才清除)。

解决

bash 复制代码
# mosquitto.conf
# 缩短持久会话的超时时间
persistent_client_expiration 1d

# 限制最大客户端数
max_connections 10000

# 限制每个客户端的队列大小
max_queued_messages 1000

深坑 2:Retained 消息的幽灵

现象:一个已经下线的传感器,其最后一次读数仍然被新订阅者接收到。

根因retained=True 的消息被 Broker 永久存储。新订阅者订阅该主题时会立即收到这条保留消息------即使传感器已经离线 3 个月。

python 复制代码
# 容易出错的模式
client.publish("sensor/temp", "25.3", retain=True)
# 后果:即使传感器已经报废,任何人订阅 sensor/temp 都会看到 25.3

# Sparkplug B 的正确做法:BIRTH 消息使用 retain,
# 但 DATA 消息永不 retain。
# 因为 BIRTH 包含了完整的设备元信息,新订阅者需要知道设备存在;
# 而 DATA 是瞬态值,用 retain 会制造"幽灵数据"。

深坑 3:Sparkplug B 的 SEQ 回绕

现象:边缘节点持续运行 200 天后,SEQ 从 2^64-1 回绕到 0,导致云平台判定消息乱序并丢弃。

根因:Sparkplug B 的 SEQ 是 uint64,如果从 0 开始每 5 秒一条消息:

  • 回绕时间 ≈ 2^64 × 5s ≈ 2.9 × 10^12 年(不用担心 uint64 回绕)

但如果你用的是 uint16(某些轻量实现错误地截断了 SEQ):

  • 回绕时间 ≈ 65535 × 5s ≈ 91 小时(不到 4 天!)

解决:确保 SEQ 使用完整 64 位,或在应用层处理回绕:

python 复制代码
def is_newer(seq_new: int, seq_old: int, window: int = 2**31) -> bool:
    """
    处理 SEQ 回绕的比较函数
    假设 SEQ 不会在 window 内回绕超过一次
    """
    if seq_new == seq_old:
        return False
    diff = (seq_new - seq_old) % (2 * window)
    return 0 < diff < window

深坑 4:TLS 握手在弱网下的灾难

现象:4G 信号弱时,MQTT 连接需要 10-30 秒才能建立。

根因:TLS 1.2 完整握手需要 2 次 RTT。如果网络 RTT 是 200ms,TLS 握手就要 400ms。但如果网络丢包率 5%(4G 弱信号下的典型值),TLS 握手可能因为证书分片丢失而反复重传。
Broker MQTT Client Broker MQTT Client #mermaid-svg-m0FJwmcyHuCHKla5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-m0FJwmcyHuCHKla5 .error-icon{fill:#552222;}#mermaid-svg-m0FJwmcyHuCHKla5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-m0FJwmcyHuCHKla5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-m0FJwmcyHuCHKla5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-m0FJwmcyHuCHKla5 .marker.cross{stroke:#333333;}#mermaid-svg-m0FJwmcyHuCHKla5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-m0FJwmcyHuCHKla5 p{margin:0;}#mermaid-svg-m0FJwmcyHuCHKla5 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m0FJwmcyHuCHKla5 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-m0FJwmcyHuCHKla5 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-m0FJwmcyHuCHKla5 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-m0FJwmcyHuCHKla5 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-m0FJwmcyHuCHKla5 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-m0FJwmcyHuCHKla5 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-m0FJwmcyHuCHKla5 .sequenceNumber{fill:white;}#mermaid-svg-m0FJwmcyHuCHKla5 #sequencenumber{fill:#333;}#mermaid-svg-m0FJwmcyHuCHKla5 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-m0FJwmcyHuCHKla5 .messageText{fill:#333;stroke:none;}#mermaid-svg-m0FJwmcyHuCHKla5 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m0FJwmcyHuCHKla5 .labelText,#mermaid-svg-m0FJwmcyHuCHKla5 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-m0FJwmcyHuCHKla5 .loopText,#mermaid-svg-m0FJwmcyHuCHKla5 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-m0FJwmcyHuCHKla5 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-m0FJwmcyHuCHKla5 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-m0FJwmcyHuCHKla5 .noteText,#mermaid-svg-m0FJwmcyHuCHKla5 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-m0FJwmcyHuCHKla5 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m0FJwmcyHuCHKla5 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m0FJwmcyHuCHKla5 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m0FJwmcyHuCHKla5 .actorPopupMenu{position:absolute;}#mermaid-svg-m0FJwmcyHuCHKla5 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-m0FJwmcyHuCHKla5 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m0FJwmcyHuCHKla5 .actor-man circle,#mermaid-svg-m0FJwmcyHuCHKla5 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-m0FJwmcyHuCHKla5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} TCP 三次握手 (1.5 RTT) TLS 1.2 完整握手 (2 RTT) MQTT CONNECT (1 RTT) 总计 4.5 RTT @200ms RTT = 900ms @5% 丢包率 ≈ 3-5s SYNSYN-ACKACKClientHelloServerHello + CertificateClientKeyExchange + ChangeCipherSpecChangeCipherSpec + FinishedCONNECT(含 Will Topic)CONNACK

解决

  1. 使用 TLS 1.3(仅 1 RTT 握手,0-RTT 模式甚至可以复用之前的会话)
  2. 启用 MQTT 会话持久化(避免每次重连都走完整 TLS 握手)
  3. 在边缘网关本地做数据缓存,网络恢复后再补传

7. Edge-to-Cloud 架构全景

至此,整个 PLC 数据采集的链路已经完整了:
#mermaid-svg-8lOYtaQN2CKBGmVk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8lOYtaQN2CKBGmVk .error-icon{fill:#552222;}#mermaid-svg-8lOYtaQN2CKBGmVk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8lOYtaQN2CKBGmVk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8lOYtaQN2CKBGmVk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8lOYtaQN2CKBGmVk .marker.cross{stroke:#333333;}#mermaid-svg-8lOYtaQN2CKBGmVk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8lOYtaQN2CKBGmVk p{margin:0;}#mermaid-svg-8lOYtaQN2CKBGmVk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8lOYtaQN2CKBGmVk .cluster-label text{fill:#333;}#mermaid-svg-8lOYtaQN2CKBGmVk .cluster-label span{color:#333;}#mermaid-svg-8lOYtaQN2CKBGmVk .cluster-label span p{background-color:transparent;}#mermaid-svg-8lOYtaQN2CKBGmVk .label text,#mermaid-svg-8lOYtaQN2CKBGmVk span{fill:#333;color:#333;}#mermaid-svg-8lOYtaQN2CKBGmVk .node rect,#mermaid-svg-8lOYtaQN2CKBGmVk .node circle,#mermaid-svg-8lOYtaQN2CKBGmVk .node ellipse,#mermaid-svg-8lOYtaQN2CKBGmVk .node polygon,#mermaid-svg-8lOYtaQN2CKBGmVk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8lOYtaQN2CKBGmVk .rough-node .label text,#mermaid-svg-8lOYtaQN2CKBGmVk .node .label text,#mermaid-svg-8lOYtaQN2CKBGmVk .image-shape .label,#mermaid-svg-8lOYtaQN2CKBGmVk .icon-shape .label{text-anchor:middle;}#mermaid-svg-8lOYtaQN2CKBGmVk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8lOYtaQN2CKBGmVk .rough-node .label,#mermaid-svg-8lOYtaQN2CKBGmVk .node .label,#mermaid-svg-8lOYtaQN2CKBGmVk .image-shape .label,#mermaid-svg-8lOYtaQN2CKBGmVk .icon-shape .label{text-align:center;}#mermaid-svg-8lOYtaQN2CKBGmVk .node.clickable{cursor:pointer;}#mermaid-svg-8lOYtaQN2CKBGmVk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8lOYtaQN2CKBGmVk .arrowheadPath{fill:#333333;}#mermaid-svg-8lOYtaQN2CKBGmVk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8lOYtaQN2CKBGmVk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8lOYtaQN2CKBGmVk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8lOYtaQN2CKBGmVk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8lOYtaQN2CKBGmVk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8lOYtaQN2CKBGmVk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8lOYtaQN2CKBGmVk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8lOYtaQN2CKBGmVk .cluster text{fill:#333;}#mermaid-svg-8lOYtaQN2CKBGmVk .cluster span{color:#333;}#mermaid-svg-8lOYtaQN2CKBGmVk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8lOYtaQN2CKBGmVk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8lOYtaQN2CKBGmVk rect.text{fill:none;stroke-width:0;}#mermaid-svg-8lOYtaQN2CKBGmVk .icon-shape,#mermaid-svg-8lOYtaQN2CKBGmVk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8lOYtaQN2CKBGmVk .icon-shape p,#mermaid-svg-8lOYtaQN2CKBGmVk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8lOYtaQN2CKBGmVk .icon-shape .label rect,#mermaid-svg-8lOYtaQN2CKBGmVk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8lOYtaQN2CKBGmVk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8lOYtaQN2CKBGmVk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8lOYtaQN2CKBGmVk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 反向控制
云平台 Cloud
传输层 Transport
边缘层 Edge
现场层 Field
MQTT QoS 1
DCMD
Modbus RTU 写
传感器4-20mA
PLCModbus RTU
执行器
边缘网关Sparkplug B
本地缓存SQLite / 环形缓冲区
MQTT BrokerMosquitto / EMQX
Sparkplug 订阅端
时序数据库InfluxDB / TDengine
报警引擎
可视化Grafana
下发指令

每一层解决一个特定的问题:

层级 协议 核心挑战 本文覆盖
现场层 Modbus RTU/TCP 扫描周期冲突、地址映射 第 1-5 篇
边缘层 Sparkplug B 数据封装、离线缓存 本篇
传输层 MQTT QoS 权衡、Keep Alive、TLS 本篇
云平台 各种 数据存储、报警、展现 本篇(订阅端)

8. 对比总结:四种协议的完整图景

经过 6 篇文章,四种协议的定位已经清晰了:

维度 Modbus RTU Modbus TCP OPC UA MQTT + Sparkplug B
最佳距离 <1200m(RS-485) <100m(以太网) 局域网/跨网段 跨互联网
实时性 确定性强 受以太网抖动影响 受安全开销影响 不保证实时(秒级)
数据模型 寄存器地址 寄存器地址 丰富地址空间 灵活 Topic + KV
安全 依赖网络隔离 内置(认证+加密+签名) TLS + ACL
离线处理 BIRTH/DEATH 状态机
适用场景 传感器/执行器 控制器级互联 系统级集成 跨区域数据汇聚

我的工程建议 :不要把这四种协议看作"选一个",而是分层部署。一条典型产线的数据流应该是:

复制代码
传感器 ↔ Modbus RTU ↔ PLC ↔ Modbus TCP ↔ 边缘网关 ↔ MQTT ↔ 云平台
                                      ↕ OPC UA(可选)
                                    SCADA 系统

每一层做每一层该做的事。越多项目经验告诉我:试图用一种协议打通所有层的,最后都被迫回到混合架构


👉 下一篇预告:PLC 数采系列 7 采集网关的离线缓存与断点续传------当网络不可靠时,数据一条都不能丢。 前面 6 篇假设网络总是好的,但现实是 4G 断流、机房掉电、光纤被挖断才是常态。下一篇深入:环形缓冲区实现、SQLite 边缘存储、时间戳仲裁策略、以及"先写后读"的一致性保证------现场崩溃过的网关,才是成熟的网关。

相关推荐
颜酱2 小时前
LangChain 输出解析器:把模型回复变成你要的数据
python·langchain
2401_873479402 小时前
企业安全运营中,如何用IP离线库提前发现失陷主机?三步实现风险画像
网络·数据库·python·tcp/ip·ip
weixin_523185323 小时前
Java基础知识总结(四):引用数据类型与参数传递机制
java·开发语言·python
码农飞哥3 小时前
我把RAG召回率从60%提到90%,就改了这两件事
python·知识库·向量检索·rag·效果提示
宸津-代码粉碎机3 小时前
Spring AI企业级实战|从RAG优化到Agent多工具调度
java·大数据·人工智能·后端·python·spring
yuhuofei20213 小时前
【Python入门】Python中的字典dict
python
Jinkxs4 小时前
Python基础 - 文件的写入操作 write与writelines方法
android·服务器·python
初学Python的小明4 小时前
Python格式化输出、运算符、分支&循环
python
代码中介商4 小时前
HTTP 完全指南(最终篇):CORS 跨域资源共享深度详解
网络·网络协议·http