第 2 篇:MQTT 协议深度解析——物联网通信为什么选它

别急着写业务代码。不理解 MQTT,后面所有关于消息路由、流控、离线缓存的设计你都会觉得"为什么这么绕"。本文带你从第一性原理吃透 MQTT,并写一个最小可运行的 Go Broker 嵌入你的边缘系统。


一、开篇场景:假如你不用 MQTT

你接了一个活------智慧工厂数据采集。200 个温度传感器,每 500ms 上报一条数据,要汇聚到网关,网关再转发到云端,同时本地 AI 模块也要消费这些数据做异常检测。

你打开 IDE 开始设计通信方案。第一个想法:

方案 A:HTTP REST

复制代码
传感器 ──POST /api/data──▶ 网关 ──POST /api/forward──▶ 云端

问题很快暴露:

  • 200 个设备每 500ms 各发一个 POST,网关每秒处理 400 个 HTTP 请求,每个请求带 TLS 握手 2KB+ header。有效载荷才 50 字节,带宽利用率不到 3%。
  • 设备怎么收云端下发的指令?轮询?200 个设备每 1 秒轮询一次,又是 200 QPS 的无意义请求。
  • 本地 AI 模块也要消费温度数据。再开一个 HTTP 接口让 AI 模块轮询?

方案 B:gRPC

比 HTTP 好,二进制编码更轻量。但:

  • 需要每个设备都集成 protobuf 编译器。你的传感器跑在 STM32 上,只有 64KB RAM。
  • gRPC 的 streaming 模式可以做推送,但断连后重连需要业务层自己实现状态恢复。
  • 这是一个点对点的通信模型。你需要"一条数据被多个消费者订阅"的能力,gRPC 没有原生支持。

两个方案都走到了死胡同。问题不是 HTTP 或 gRPC 不好,而是它们的设计目标和物联网场景天生的矛盾。这时候你终于理解了------为什么整个物联网世界都在用那个 1999 年发明的、报文头最小只有 2 字节的协议。


二、概念铺垫:MQTT 的四个核心设计

2.1 发布/订阅模型------解耦生产者和消费者

通用原理 :发布/订阅(Pub/Sub)是观察者模式 (Observer Pattern)的分布式版本------你在学校学 GoF 设计模式时见过它。一个 Subject(Topic)有 N 个 Observer(Subscriber),Subject 变化时所有 Observer 自动收到通知。MQTT 把这个模式从单进程内存调用扩展到了跨网络的多进程消息系统。Topic 的分层命名本质上是命名空间 (Namespace)设计------和文件系统的目录树(/usr/local/bin)、DNS 的域名层次(www.example.com)是同一种思想。

HTTP 是"请求-响应"模型:客户端发请求,服务端回响应。这是一对一、同步的。

MQTT 是"发布-订阅"模型:

复制代码
                     ┌─────────────┐
         发布        │             │       订阅
温度传感器 ──────────▶│   Broker    │◀────────── AI 异常检测模块
  (Publisher)        │  (消息中间人)│           (Subscriber)
                     │             │
         发布        │             │       订阅
  PLC 控制器 ────────▶│             │◀────────── 云端 Client
                     └─────────────┘

核心规则:

  • 发布者和订阅者不直接认识彼此,只跟 Broker 通信
  • 一个发布者可以有 N 个订阅者,一个订阅者可以接收 M 个发布者的消息
  • Topic(主题) 是消息的"地址"------发布者往某个 Topic 发,订阅者订阅某个 Topic 收
  • Broker 自己不生产消息,只负责路由消息

这个模型直接解决了我们的场景:传感器只负责"发"(发布到 /factory/area1/temperature),AI 模块和云端只负责"收"(订阅同一个 Topic),三者完全解耦。

2.2 Topic 设计规范------分层命名

MQTT 的 Topic 用 / 分隔层级,像一个文件路径:

复制代码
正确的 Topic 设计                    错误的设计
─────────────────                    ─────────
/factory/area1/cnc01/temperature     /factory_area1_cnc01_temperature
/factory/area1/cnc01/vibration       /sensor_data
/factory/area2/plc01/status          /12345

分层命名的好处:

  • 通配符订阅/factory/area1/+/temperature 订阅 area1 下所有设备的温度(单层通配符 +
  • 子树订阅/factory/area1/# 订阅 area1 下所有 topic(多层通配符 #
  • 可读性:不用查字典就知道 topic 的含义

我们平台使用的 Topic 设计:

Topic 模式 方向 含义
/devices/{device_id}/telemetry 设备→Hub 设备遥测数据上报
/devices/{device_id}/events 设备→Hub 设备事件上报
/devices/{device_id}/commands Hub→设备 云端命令下发
/modules/{module_id}/output 模块→Hub 模块业务消息输出
/modules/{module_id}/input Hub→模块 消息路由到模块
/modules/{module_id}/shadow/notify Hub→模块 模块配置影子变更通知

2.3 QoS------三种送达保证等级

这是 MQTT 最核心也最容易被误解的概念。

复制代码
QoS 0:最多一次(At most once)
─────────────────────────────────
发布者                     Broker
  │──PUBLISH(qos0)──────────▶│   发了就忘
  │                          │   不管是否收到
  │                          │
  
QoS 1:至少一次(At least once)  
─────────────────────────────────
发布者                     Broker
  │──PUBLISH(qos1, pktId)───▶│   保存 pktId
  │                          │   处理消息
  │◀──PUBACK(pktId)──────────│   确认收到
  │  删除 pktId              │   重发窗口期内没收到 PUBACK → 重发


QoS 2:确保一次(Exactly once)
─────────────────────────────────
发布者                     Broker
  │──PUBLISH(qos2, pktId)───▶│
  │◀──PUBREC(pktId)──────────│   收到,准备处理
  │──PUBREL(pktId)──────────▶│   确认释放
  │◀──PUBCOMP(pktId)─────────│   处理完成

关键认知 :QoS 是发布者和 Broker 之间Broker 和订阅者之间 分别协商的。最终送达的 QoS 是这两段中较低的那个。也就是说,你发布用 QoS 2,但订阅者用 QoS 0 订阅,最终收到的是 QoS 0。

2.4 为什么我们选 QoS 1 而不是 QoS 2?

这是个经典选择题。我们在 MessageHub 中的所有云侧消息传输,都用了 QoS 1 + 业务层幂等,而不是 QoS 2。原因:

维度 QoS 1 QoS 2
握手次数 2 次(PUBLISH + PUBACK) 4 次(+PUBREC+PUBREL+PUBCOMP)
开销 约 2 倍
延迟 1 个 RTT 2 个 RTT
是否可能重复
适用场景 高性能 + 业务去重 严格的恰好一次

结论 :QoS 2 多出来的两次握手,保障的是"不重复"。但在物联网场景下,消息本身带时间戳和序列号,接收端做幂等去重只需要几行代码------用业务层的几行代码换掉协议层的一倍性能开销,稳赚不赔。

2.5 MQTT 5.0------2019 年的关键升级

上面的讨论基于 MQTT 3.1.1(2014 年发布)。2019 年 OASIS 发布了 MQTT 5.0,引入了几个对边缘场景至关重要的新特性:

共享订阅(Shared Subscriptions)------负载均衡

MQTT 3.1.1 中,N 个客户端订阅同一个 Topic,每条消息 N 个客户端全收到 。MQTT 5.0 引入了共享订阅语法 $share/{group}/{topic},同组内的客户端负载均衡------每条消息只投递给其中一个客户端。

复制代码
传统订阅(多副本消费):
  sensor/+/telemetry → 3 个客户端全收到同一条消息

共享订阅(负载均衡):
  $share/workers/sensor/+/telemetry → 3 个客户端轮流处理

在边缘场景下,这个特性对水平扩展模块至关重要。比如你有 3 个数据清洗模块实例,不希望同一条温度数据被清洗 3 遍------共享订阅保证每条数据只被一个实例处理。

会话过期(Session Expiry)------灵活的状态管理

MQTT 3.1.1 中 Clean Session 是一个布尔值:要么全记住,要么全忘掉。MQTT 5.0 把 Clean Start(连接时)和 Session Expiry Interval(断开后)分开:

场景 Clean Start Session Expiry
设备重启后不需要旧消息 true 0
设备断连 5 分钟内恢复,消息不能丢 false 300
设备长期离线,消息缓存 24 小时 false 86400

这对我们的离线缓存设计(第 17 篇)是一个重要补充:Broker 侧可以设置合理的 Session Expiry,让短暂断连的设备恢复后自动收到离线期间的消息,而不需要业务层自己维护"断连补偿"逻辑。

用户属性(User Properties)------消息元数据通道

MQTT 5.0 的每条消息可以携带零个或多个 User Property(键值对),这在消息路由场景(第 13 篇)中非常有用:

复制代码
PUBLISH topic=/sensor/temp payload=25.6
  User-Property: data-type=telemetry
  User-Property: qos-level=high
  User-Property: source-factory=area1

消息路由引擎可以根据 User Property 做路由决策,而不需要解析 Payload。减少解包开销,路由性能提升数倍。

原因码(Reason Codes)------精确的错误反馈

MQTT 3.1.1 断开连接时只有一个"连接被拒绝"的状态。MQTT 5.0 每个操作都有独立的 Reason Code。比如 CONNACK 可以返回:

  • 0x80 不支持的协议版本(别再重试了)
  • 0x87 未授权(检查凭证)
  • 0x8A 服务器不可用(3 分钟后重试)

这让我们在 NodeCore 的连接重试逻辑中可以做差异化处理------凭证问题不回退等待直接告警,服务器暂时不可用就走指数退避。

MQTT 5.0 在我们的平台中如何使用

我们的嵌入式 Broker 同时兼容 MQTT 3.1.1 和 5.0 客户端。核心模块(MessageHub、EdgeRuntimeSDK)使用 MQTT 5.0 客户端连接,利用共享订阅和 User Properties 做高效消息路由;存量设备仍用 MQTT 3.1.1,Broker 自动降级适配。

go 复制代码
// MQTT 5.0 客户端连接示例
opts := mqtt.NewClientOptions()
opts.SetProtocolVersion(5) // 启用 MQTT 5.0
opts.SetCleanStart(false)
opts.SetSessionExpiry(300) // 5 分钟断连恢复窗口

// 共享订阅
token := client.Subscribe("$share/workers/sensor/+/telemetry", 1, handler)

三、方案设计:嵌入式 Broker 的选型和定制

3.1 为什么要把 Broker 嵌入进程而不是单独部署?

部署一个独立的 MQTT Broker(如 Mosquitto、EMQX)是常见做法,但在我们的边缘平台中,Broker 是内嵌在 MessageHub 进程里的。理由:

独立 Broker 内嵌 Broker
独立进程,通过 TCP 通信 同进程,零拷贝
定制困难:需要改 Broker 源码或写插件 直接在代码里拦截每条消息
多一个进程要管理(启动、升级、监控) 随 MessageHub 一起启停
适合标准化部署场景 适合需要深度定制的边缘场景

我们选了 Go 生态中的一个轻量 MQTT Broker(代码量小、方便嵌入),然后做了二次定制:

本文涉及的 Go 包"time""github.com/eclipse/paho.mqtt.golang"(MQTT 客户端)

3.2 定制的四个关键点

定制一:认证插件------设备连接时验证身份

原生 Broker 通常只有简单的用户名密码认证。我们需要验证设备的签名令牌:

go 复制代码
// Broker 认证插件
type AuthPlugin struct{
    deviceMgr *DeviceManager
}

// 每次有客户端 CONNECT,Broker 回调此方法
func (p *AuthPlugin) OnConnect(clientID, username string, password []byte) bool {
    // password 中携带 HMAC-SHA256 签名或 AES-GCM 加密的令牌
    device, err := p.deviceMgr.Authenticate(clientID, password)
    if err != nil {
        return false // 认证失败,拒绝连接
    }
    // 认证通过,把设备信息注入连接上下文
    // 后续消息处理时可以通过 clientID 查到设备信息
    p.deviceMgr.BindSession(clientID, device)
    return true
}

定制二:发布拦截------在入口做流控和路由

每一条设备发布的消息,都要先过流控检查,再进入路由引擎:

go 复制代码
type PublishInterceptor struct{
    flowCtrl   *FlowCtrlManager     // 令牌桶流控
    router     *MessageRouter        // 消息路由引擎
    cache      *OfflineCache         // 离线缓存
}

func (p *PublishInterceptor) OnPublish(clientID, topic string, payload []byte) error {
    // 第一步:流控------令牌桶检查
    if !p.flowCtrl.IsAllow(clientID, topic) {
        return p.handleFlowCtrl(clientID, topic, payload) // 触发背压或丢弃
    }

    // 第二步:路由------匹配路由规则,确定消息要去哪
    targets := p.router.Match(topic, payload)
    for _, target := range targets {
        switch target.Type {
        case TargetCloud:
            p.sendToCloud(target, payload)   // 发往云端
        case TargetModule:
            p.sendToModule(target, payload)  // 发往本地模块
        case TargetDataBridge:
            p.sendToBridge(target, payload)  // 发往外部系统
        }
    }
    return nil
}

定制三:ACK 延迟控制------实现背压

Broker 收到一条 QoS 1 消息后,默认立即回 PUBACK。我们要实现"慢 ACK"背压:

go 复制代码
func (p *PublishInterceptor) handleFlowCtrl(clientID, topic string, payload []byte) error {
    // 根据消息的可靠性级别决定行为
    level := p.getReliabilityLevel(topic)

    switch level {
    case ReliabilityLow:
        // LOW 级别:丢弃消息,但立即回 ACK(不断连)
        return nil

    case ReliabilityMedium, ReliabilityHigh:
        // MEDIUM/HIGH:处理消息,但延迟 100ms 回 ACK
        // 客户端等 ACK 才发下一条 → 自然降速 → 背压闭环
        p.processMessage(clientID, topic, payload)

        // 返回一个特殊错误,通知 Broker 层延迟 PUBACK
        return &SlowAckError{Delay: 100 * time.Millisecond}
    }
    return nil
}

定制四:连接/断开钩子------设备在线状态追踪

go 复制代码
type SessionTracker struct{
    deviceMgr *DeviceManager
}

func (s *SessionTracker) OnConnect(clientID string) {
    s.deviceMgr.UpdateStatus(clientID, StatusOnline)
    // 向云端上报设备上线
    s.deviceMgr.ReportDeviceStatus(clientID, StatusOnline)
}

func (s *SessionTracker) OnDisconnect(clientID string) {
    s.deviceMgr.UpdateStatus(clientID, StatusOffline)
    // 向云端上报设备离线
    s.deviceMgr.ReportDeviceStatus(clientID, StatusOffline)
}

四、Go 核心骨架:设备数据从上报到云端的完整链路

下面是一段完整的伪代码,串联从设备 MQTT 发布消息到最终到达云端的全过程:

go 复制代码
// ========== 设备侧(如一个温度传感器)==========
func deviceReportLoop(client mqtt.Client) {
    ticker := time.NewTicker(500 * time.Millisecond)
    for range ticker.C {
        temp := readTemperature()
        payload := marshal(map[string]any{
            "device_id": "temp_sensor_01",
            "value":     temp,
            "timestamp": time.Now().UnixMilli(),
        })
        token := client.Publish(
            "/factory/area1/temperature", // Topic
            1,                             // QoS 1
            false,                         // 不保留
            payload,
        )
        token.Wait() // 等 PUBACK
    }
}


// ========== MessageHub 侧(嵌入式 Broker + 业务处理)==========

// Hub 启动时初始化 Broker 并注册插件
func (hub *MessageHub) Start() {
    broker := mqtt.NewBroker()

    // 注册定制插件
    broker.RegisterAuthPlugin(hub.authPlugin)
    broker.RegisterPublishInterceptor(hub.publishInterceptor)
    broker.RegisterSessionTracker(hub.sessionTracker)

    // 监听 1883 端口(TLS)
    go broker.ListenAndServe(":1883")

    // 启动云端 MQTT Client,连接 IoT 平台
    hub.cloudClient.Connect()
}

// 设备消息处理的核心逻辑
func (hub *MessageHub) handleDeviceMessage(clientID, topic string, payload []byte) {
    // === 阶段一:入口流控 ===
    if !hub.flowCtrl.IsAllow(clientID, topic) {
        // 令牌桶空,触发流控策略
        hub.executeFlowCtrl(clientID, topic, payload)
        return
    }

    // === 阶段二:消息路由 ===
    targets := hub.router.Match(topic, payload)

    // === 阶段三:分发 ===
    for _, target := range targets {
        hub.dispatch(target, topic, payload)
    }
}

// 分发到不同目标
func (hub *MessageHub) dispatch(target RouteTarget, topic string, payload []byte) {
    switch target.Type {

    case TargetCloud:
        // 发往云端 IoT 平台
        hub.cloudClient.Publish(topic, payload)

    case TargetModule:
        // 发往本地模块(Broker 内部转发,零拷贝)
        // 模块订阅了消息路由引擎匹配的 input topic
        hub.broker.InternalPublish(target.ModuleInputTopic, payload)

    case TargetDataBridge:
        // 转发给 DataBridge,由它推送到外部系统
        hub.pushChannel <- &PushMessage{Topic: topic, Payload: payload}
    }
}

// ========== 云端接收侧 ==========
// 云端 IoT 平台通过 MQTT 收到数据后,路由到存储、分析等后端服务
// 这部分不在边缘平台范围内,略

五、边界与反模式

反模式一:用 QoS 2 解决所有问题

错误做法:所有消息都用 QoS 2 发送,"反正能保证不丢不重,省心"。

为什么错:QoS 2 的四次握手开销巨大。200 个设备发 QoS 2,Broker 要维护每个设备每条消息的 PUBREC/PUBREL 状态机,内存和 CPU 呈指数增长。大多数"不重复"需求用业务层的序列号去重就能解决。

正确做法 :默认 QoS 1,消息里带 msg_id 序列号,消费端维护一个最近 10000 条的 msg_id 集合做幂等。

反模式二:Topic 设计过于扁平

错误做法/data/cmd/status,把所有设备数据混在一个 topic 里。

为什么错:订阅方被迫接收不关心的数据,带宽和 CPU 浪费在做无用过滤上。

正确做法 :按设备 ID、数据类型分层:/factory/{area}/{device_id}/{data_type}。订阅方可以精确订阅自己关心的范围。

反模式三:在 Broker 里做重业务逻辑

错误做法:在 Broker 的 OnPublish 回调里直接写数据库、调外部 API、做复杂计算。

为什么错:Broker 的回调是同步的。如果回调耗时 500ms,Broker 在这 500ms 内不能处理其他消息。200 个设备并发,整个 Broker 瞬间阻塞。

正确做法:OnPublish 只做轻量决策(放行/流控/丢弃),然后把消息丢进 channel,由专门的 goroutine 异步处理。


六、小结

MQTT 不是一个"简单的协议"------它是一组经过 20 年物联网场景检验的设计模式:

  • 发布/订阅:解耦生产者和消费者,一个消息 N 方受益
  • Topic 分层 :从 /factory/area1/temperature/factory/+/temperature,用命名给消息选址
  • QoS 分级:QoS 0 快但不可靠,QoS 1 是工程最优解,QoS 2 留给真正需要的 1% 场景
  • MQTT 5.0 增强:共享订阅负载均衡、Session Expiry 灵活断连恢复、User Properties 高效路由元数据、Reason Codes 精确错误反馈
  • Broker 嵌入:不是炫技,是深度定制的必然------你需要在消息的"必经之路"上做流控、做路由、做背压

理解了 MQTT,再看后面的内容就顺了:消息路由是基于 Topic 和 Payload 的二次分发,三级可靠性是 QoS 的业务层扩展,令牌桶和背压是 Broker 的"守门人",离线缓存是 QoS 1 的持久化补丁。

下一篇,我们进入 NodeCore 运行时底座。从一个最基础的问题开始:一台裸 Linux 机器,怎么不用人动手,自动变成受管理的边缘节点?


本文是《边缘平台架构沉思录:Go 架构推演与工程决策》系列的第 2 篇。

相关推荐
久爱物联网5 小时前
个人站点(博客)开发案例 - 功能齐全,在线客服-即时通讯-物联网控制、广告位管理、按钮级权限控制
物联网·个人博客·即时通讯在线客服·物联网控制·博客发布系统·新闻发布系统
兆龙电子单片机设计7 小时前
【STM32项目开源】STM32单片机语音分类垃圾桶-蓝牙APP
stm32·单片机·嵌入式硬件·物联网·开源·自动化·毕业设计
厦门辰迈智慧科技8 小时前
管网遥测终端机赋能城市安全的核心价值
物联网·遥测终端机·管网遥测终端机·排水管网遥测终端·管网遥测终端
七仔啊8 小时前
modbus快速入门
物联网
论迹复利14 小时前
PSA Certified 是什么——物联网芯片安全认证的三级体系
物联网·struts·安全
MetrixAeroCore15 小时前
AI玩具出海用物联网卡还是eSIM?200国漫游和本地落地卡成本差多少?
物联网
数智顾问19 小时前
(84页PPT)PWC海科集团生物医药业务5年发展战略项目建议书(附下载方式)
大数据·人工智能·物联网
MetrixAeroCore20 小时前
智能快递柜格口监控物联网卡在老旧小区弱网环境下多运营商切换组网方案
物联网
腻害兔20 小时前
【若依项目-产品经理视角】深度拆解 RuoYi-Vue-Pro IoT 物联网模块:从协议网关到物模型,一文讲透开源 IoT 平台架构
前端·vue.js·物联网·产品经理·ai编程
北京晶数信息科技20 小时前
加油站成品油智慧监管系统项目建设实施汇报(一)
大数据·物联网·产品经理·需求分析