跳到主要内容

Go MQTT 实战:从设备数据上报到自动重连与可靠消费

· 阅读需 18 分钟
ahKevinXy
作者

设备每隔几秒上报一次温度,服务端接收并记录;设备断网后,后台还能看到它已经离线。这类需求很适合用 MQTT 串起来。

真正接入项目时,需要处理的不只是 Publish 和 Subscribe:连接成功是否代表订阅已就绪?断线重连后谁来恢复订阅?发布超时还能不能重试?QoS 1 是否意味着数据库里只会出现一条记录?

这篇文章用一个可以复制运行的 Go 示例,把本地 Broker、温度上报、订阅处理、自动重连、设备状态与正常退出串成完整流程,再说明持久化、业务幂等和生产配置应该补在哪一层。

一、先明确这次要实现的链路​

我们用两个 Go 进程分别模拟设备和服务端:

设备进程(pub) ──发布温度──> Mosquitto Broker ──转发──> 订阅进程(sub)
│
└──保存每个状态 Topic 的最新 retained 消息

发布者不需要知道订阅者的地址,双方通过 Broker 和 Topic 联系。多个普通订阅者订阅同一主题时,各自都会收到消息,不能直接把它当成“多个 worker 竞争一条任务”的队列。

本文采用 MQTT 3.1.1,客户端固定为 github.com/eclipse/paho.mqtt.golang v1.5.1,方便复现。这个模块的 go.mod 要求 Go 1.24.0 或更高;开发和部署应使用仍受维护的 Go 工具链。这里的固定版本是示例基线,不代表最新版本。Paho 版本依赖声明

如果项目需要 MQTT 5 的消息过期时间、原因码等特性,应评估另一套客户端 github.com/eclipse/paho.golang;它和本文模块的 API 不能直接混用。Paho 客户端说明

Topic 和消息结构​

先约定两类主题:

Topic含义发布 QoSRetain
lab/devices/sensor-01/telemetry温度采样事件1false
lab/devices/sensor-01/status当前在线状态1true

温度消息使用 JSON:

{
"event_id": "c389ff02fa136a9e",
"device_id": "sensor-01",
"temperature": 23.5,
"time": "2026-11-03T01:00:00Z"
}

event_id 标识一次业务事件,后面用来做幂等;device_id 标识设备,不能用 MQTT 协议里会复用的 Packet ID 替代。

订阅过滤器 lab/devices/# 会匹配设备的温度和状态;如果只关心温度,可以改成 lab/devices/+/telemetry。+ 匹配一层,# 匹配多层且必须位于过滤器末尾;发布 Topic 不能带这两个通配符。Topic 区分大小写。MQTT 3.1.1 主题规则

二、用 Docker 启动本地 Mosquitto​

新建工作目录和配置:

mkdir go-mqtt-demo
cd go-mqtt-demo
mkdir -p mosquitto/config
cat > mosquitto/config/mosquitto.conf <<'EOF'
listener 1883
allow_anonymous true
persistence false
log_dest stdout
EOF

创建 compose.yaml:

services:
mqtt:
image: eclipse-mosquitto:2
ports:
- "127.0.0.1:1883:1883"
volumes:
- ./mosquitto/config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro

运行:

docker compose up -d
docker compose logs mqtt

这里显式开放容器内的监听,并把宿主机端口绑定在 127.0.0.1,供本机实验使用。匿名访问和 persistence false 是本地演示配置:Broker 重启后,内存里的 retained 状态和会话数据不会被这份配置保留下来。上线前需要配置认证、权限和持久化;eclipse-mosquitto:2 也是会更新的主版本标签,生产环境应锁定经过验证的补丁版本或镜像摘要。Mosquitto 监听与持久化配置

三、完整 Go 代码:一个程序,两种模式​

在刚才的目录初始化模块:

go mod init example.com/mqtt-demo
go get github.com/eclipse/paho.mqtt.golang@v1.5.1

创建下面的 main.go。程序默认订阅,也可以通过 -mode pub 模拟设备,每两秒上报一次固定温度。

main.go
package main

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"

mqtt "github.com/eclipse/paho.mqtt.golang"
)

const (
deviceID = "sensor-01"
telemetryTopic = "lab/devices/" + deviceID + "/telemetry"
statusTopic = "lab/devices/" + deviceID + "/status"
topicFilter = "lab/devices/#"
)

type Telemetry struct {
EventID string `json:"event_id"`
DeviceID string `json:"device_id"`
Temperature float64 `json:"temperature"`
Time string `json:"time"`
}

func randomID() (string, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}

func waitToken(t mqtt.Token, operation string) error {
if !t.WaitTimeout(5 * time.Second) {
return fmt.Errorf("%s: 等待超时,最终结果未知", operation)
}
if err := t.Error(); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}

func onMessage(_ mqtt.Client, m mqtt.Message) {
if !strings.HasSuffix(m.Topic(), "/telemetry") {
log.Printf("状态 topic=%s retained=%t payload=%s", m.Topic(), m.Retained(), m.Payload())
return
}
var event Telemetry
if err := json.Unmarshal(m.Payload(), &event); err != nil {
log.Printf("无效 JSON topic=%s: %v", m.Topic(), err)
return
}
if event.EventID == "" || event.DeviceID == "" {
log.Printf("缺少业务标识 topic=%s", m.Topic())
return
}
log.Printf("收到 event_id=%s device=%s temperature=%.1f time=%s",
event.EventID, event.DeviceID, event.Temperature, event.Time)
}

func run() error {
mode := flag.String("mode", "sub", "sub 或 pub")
broker := flag.String("broker", "tcp://127.0.0.1:1883", "MQTT broker 地址")
id := flag.String("id", "", "唯一的 MQTT Client ID,默认随机生成")
flag.Parse()
if *mode != "pub" && *mode != "sub" {
return fmt.Errorf("mode 必须为 pub 或 sub")
}
if *id == "" {
suffix, err := randomID()
if err != nil {
return err
}
*id = "go-" + *mode + "-" + suffix
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

opts := mqtt.NewClientOptions().AddBroker(*broker).SetClientID(*id)
opts.SetProtocolVersion(4) // MQTT 3.1.1 的协议级别是 4。
opts.SetCleanSession(true)
opts.SetAutoReconnect(true)
opts.SetConnectRetry(false) // 首次连接失败直接返回;断线后自动重连。
opts.SetConnectTimeout(5 * time.Second)
opts.SetWriteTimeout(5 * time.Second)
opts.SetKeepAlive(20 * time.Second)
opts.SetPingTimeout(5 * time.Second)
opts.SetMaxReconnectInterval(10 * time.Second)
opts.SetDefaultPublishHandler(onMessage)
if user := os.Getenv("MQTT_USERNAME"); user != "" {
opts.SetUsername(user)
opts.SetPassword(os.Getenv("MQTT_PASSWORD"))
}
if *mode == "pub" {
opts.SetWill(statusTopic, `{"online":false}`, 1, true)
}
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Printf("连接断开,将尝试重连: %v", err)
})
ready := make(chan error, 1)
opts.SetOnConnectHandler(func(c mqtt.Client) {
var err error
if *mode == "sub" {
t := c.Subscribe(topicFilter, 1, onMessage)
err = waitToken(t, "订阅")
if err == nil {
// SUBACK 可能返回订阅拒绝,不能只检查 Token.Error。
if st, ok := t.(*mqtt.SubscribeToken); ok {
qos, exists := st.Result()[topicFilter]
if !exists || qos > 2 {
err = fmt.Errorf("订阅未获授权: %s", topicFilter)
}
}
}
} else {
err = waitToken(c.Publish(statusTopic, 1, true, `{"online":true}`), "发布在线状态")
}
if err != nil {
log.Printf("连接初始化失败: %v", err)
} else {
log.Printf("已就绪 mode=%s client_id=%s", *mode, *id)
}
select {
case ready <- err:
default:
}
})

client := mqtt.NewClient(opts)
defer client.Disconnect(1000) // 单位是毫秒;业务停机逻辑在下面执行。
if err := waitToken(client.Connect(), "连接"); err != nil {
return err
}
select {
case err := <-ready:
if err != nil {
return err
}
case <-ctx.Done():
return nil
case <-time.After(12 * time.Second):
return fmt.Errorf("等待连接初始化超时")
}
if *mode == "sub" {
<-ctx.Done()
return nil
}

ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
if client.IsConnectionOpen() {
if err := waitToken(client.Publish(statusTopic, 1, true, `{"online":false}`), "发布离线状态"); err != nil {
log.Printf("离线状态未确认: %v", err)
}
}
return nil
case now := <-ticker.C:
if !client.IsConnectionOpen() {
log.Print("尚未恢复连接,跳过本次采样")
continue
}
eventID, err := randomID()
if err != nil {
return err
}
event := Telemetry{eventID, deviceID, 23.5, now.UTC().Format(time.RFC3339Nano)}
payload, err := json.Marshal(event)
if err != nil {
return err
}
if err := waitToken(client.Publish(telemetryTopic, 1, false, payload), "发布温度"); err != nil {
log.Printf("event_id=%s: %v", eventID, err)
continue
}
log.Printf("发布流程完成 event_id=%s", eventID)
}
}
}

func main() {
if err := run(); err != nil {
log.Print(err)
os.Exit(1)
}
}

然后整理依赖并编译:

go mod tidy
go build -o mqtt-demo .

示例保留了 JSON 编码错误、连接错误、订阅返回码和发布等待结果的检查。main 在 run 返回后退出,保证 run 内的 defer 有机会执行。

四、启动订阅和发布,观察完整过程​

在第一个终端启动订阅者:

./mqtt-demo -mode sub -id backend-01

等日志出现 已就绪 mode=sub,再在第二个终端启动设备:

./mqtt-demo -mode pub -id device-01

订阅端会看到类似输出,随机 ID 和时间以实际运行为准:

已就绪 mode=sub client_id=backend-01
状态 topic=lab/devices/sensor-01/status retained=false payload={"online":true}
收到 event_id=c389ff02fa136a9e device=sensor-01 temperature=23.5 time=2026-11-03T01:00:00Z

当前订阅者收到实时状态时,retained 可以是 false;如果新开一个订阅进程,Broker 会把保存的最新状态发给它,这时会看到 retained=true。温度事件没有设置 Retain,新订阅者不会因此获得全部历史采样。MQTT 保留消息语义

本例的 deviceID 固定为 sensor-01,同一时间只运行一个设备发布进程。-id 只改变连接身份,不会改变业务设备和 Topic;扩展多设备时,应同时把设备 ID 和 Topic 参数化。

试验一:正常退出​

在发布进程所在终端按 Ctrl+C。程序停止采样,尝试发布 retained 的 online=false,随后调用 Disconnect(1000) 等待客户端退出。

这个等待参数的单位是毫秒。它只约束客户端的退出等待,不代表订阅端已经处理完全部消息,也不能替代数据库事务或业务任务排空。

试验二:异常退出与遗嘱​

重新启动发布者,再从另一个终端查找该 mqtt-demo 进程的 PID,发送 kill -9 <PID>。Broker 感知连接异常后,会发布连接建立时登记的遗嘱:{"online":false}。

遗嘱可能在 TCP 断开时较快触发,也可能需要等待 Keep Alive 检测到失联。正常发送 MQTT DISCONNECT 时,Broker 不会再发送该连接的遗嘱,因此正常退出路径要主动更新状态。遗嘱只反映 Broker 对连接的判断;健康检查还应结合最近上报时间。若正常退出前的离线发布失败,retained 状态也可能暂时不准确。MQTT 遗嘱与 Keep Alive

试验三:断线后恢复订阅​

保持两个进程运行,执行:

docker compose restart mqtt

预期能观察到断线日志、重新就绪和新的温度消息。这是推荐读者执行的故障演练步骤;重连时间取决于 Broker 启动和网络情况。

这里订阅者使用 CleanSession=true,连接恢复后必须重新订阅。程序将 Subscribe 放在 OnConnect 中,每次连接成功都会执行。只在 main 中调用一次订阅,通常会导致“连接恢复了,但不再收到消息”。Paho 连接回调说明

五、代码里最容易漏掉的几个细节​

1. 连接成功和订阅就绪是两件事​

Connect 完成后,还需要发送 SUBSCRIBE 并等待 SUBACK。示例用 ready channel 等待第一次初始化结果,订阅成功后才输出就绪。

另外,SUBACK 可能携带拒绝码 0x80,所以不能只看 Token.Error();代码还会检查 SubscribeToken.Result()。Broker 也可以授予低于申请值的 QoS,应以实际返回值和业务要求为准。Paho SubscribeToken

本例首次连接或订阅初始化失败会退出;建立过连接后的网络中断由客户端自动重连处理。重连后的订阅初始化失败目前只记录日志,没有额外的订阅重试循环。生产服务应把订阅状态纳入 readiness,并对临时失败做有界退避重试,不能仅凭 TCP 已连接就宣布服务可用。

2. 等待超时不等于操作被取消​

Paho 的连接、发布、订阅返回 Token。WaitTimeout 返回 false 只表示调用方停止等待,Token 不会因此自动变成错误,底层操作也不一定停止。Paho Token 接口

因此,发布超时时应该记录“结果未知”,不能断言消息没有发出去。如果业务决定重试同一事件,必须复用原来的 event_id;重新生成 ID 会把同一业务动作伪装成另一条事件。

示例中的每次 tick 都代表一次新采样,失败仅记录,不自动补发。IsConnectionOpen 也只是调用瞬间的状态检查,不能保证随后的网络写入成功。Paho 在自动重连路径上的 Token 完成还有版本相关的处理细节,所以日志使用“发布流程完成”,不把它当成端到端业务确认。Paho 自动重连注意事项

3. Client ID 必须避免冲突​

同一个 Broker 上同时连接的客户端应使用不同 Client ID。两个进程使用同一个 ID,会相互顶掉连接,表现为不断断线、重连。

示例默认生成随机 ID,适合临时实验。需要恢复持久会话的订阅者应使用稳定且唯一的 ID;部署多个副本时,要为每个副本分配身份,不能复制同一个固定值。

4. 回调里不要直接执行慢业务​

示例回调只解析 JSON 并记录日志,方便观察。默认有序回调下,不要在处理函数里等待数据库慢查询、HTTP 请求,或同步等待另一次 MQTT 发布,否则可能阻塞后续消息和确认。Paho 回调阻塞说明

真实项目可将校验后的事件送入有界工作队列,再由固定 worker 处理。队列满了要明确选择背压、拒绝或落盘,并记录指标,不能无限启动 goroutine。

还要注意确认时机:默认自动 ACK 下,回调将消息放进内存队列后就返回,MQTT 确认可能早于业务落库。此时进程崩溃,队列里的消息仍可能丢失。若要求处理成功后才确认,需要结合 Paho 的 SetAutoAckDisabled(true)、Message.Ack() 以及 Broker 会话恢复策略设计,并验证所用 QoS 的确认流程;不能仅移动一行 ACK 就声称端到端可靠。Paho 手动确认配置

六、QoS 1 为什么仍然需要业务幂等​

QoS 描述 MQTT 协议链路的投递行为:

级别协议含义常见取舍
QoS 0至多一次,不做协议级重传确认高频、允许偶发丢失的采样
QoS 1至少一次,可能重复有幂等处理的事件上报
QoS 2协议层恰好一次,交互更多需要该协议语义且接受额外开销的场景

发布者到 Broker、Broker 到订阅者是两个阶段。订阅者收到的 QoS 还受订阅授予值限制。QoS 2 也不等于数据库事务只提交一次,更不会自动让业务代码在崩溃后恢复。MQTT QoS 定义

例如 PostgreSQL 中可以建立事件唯一约束:

CREATE TABLE telemetry_events (
event_id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
temperature DOUBLE PRECISION NOT NULL,
sampled_at TIMESTAMPTZ NOT NULL
);

INSERT INTO telemetry_events (event_id, device_id, temperature, sampled_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id) DO NOTHING;

这段 SQL 是生产接入思路,未集成到本文的日志示例中。若插入事件后还要更新设备状态,应把去重判断和状态更新放在同一事务里,只有新事件插入成功才执行后续变更。调用支付、发送通知等外部系统时,还需要对方支持幂等键,或采用 outbox 等可恢复的投递流程。

生产事件 ID 应使用有足够碰撞空间、作用域明确的标识方案。本文的随机 64 位值用于演示;大规模系统可以使用 UUID,或使用“设备 ID + 持久化序列号”,并明确设备重启后的序列恢复方式。

七、离线消息与 retained 消息各管什么​

容易混淆的三个配置分别作用在不同位置:

机制保存的是什么需要注意什么
Retain每个 Topic 的最新一条保留消息适合当前状态,不是历史事件列表
持久会话订阅关系及符合条件的待投递消息依赖固定 Client ID、会话配置与 Broker 队列限制
客户端本地持久化客户端侧协议状态或业务待发送事件要明确磁盘写入、崩溃恢复和重试规则

本文 CleanSession=true,且 Broker 未开启磁盘持久化,所以不提供订阅者离线补发和进程崩溃后的事件恢复;设备断线期间的采样会被主动跳过。自动重连只帮助恢复连接。

如果需要订阅者断线后补收消息,至少要使用稳定 Client ID、CleanSession=false、合适的 QoS,并核对 Broker 的持久化、消息数和字节数上限。持久会话恢复时,消息可能在重新执行 Subscribe 前到达,因此要在连接前注册处理路由或默认回调。发布端要保证业务事件可恢复,还需要把待发送事件持久化;Paho 的协议存储不是完整的业务 outbox。Paho 会话与队列注意事项

清除一个 retained 状态,可以对相同 Topic 发布 retained 的零长度载荷:

docker compose exec mqtt mosquitto_pub   -h 127.0.0.1 -t lab/devices/sensor-01/status -q 1 -r -n

此时在线订阅者也可能看到空消息,业务处理应识别“状态被清除”,不要直接当作有效 JSON。

八、接入真实环境:认证、TLS 和权限​

完整示例已经支持从 MQTT_USERNAME、MQTT_PASSWORD 读取凭据;Broker 端仍需配置对应用户和权限。密码通过明文 TCP 传输没有保密性,跨主机部署应使用 TLS。Mosquitto 认证说明

如果 Broker 使用受系统信任的服务端证书,在创建 client 之前为 opts 增加以下配置,并给 import 补上 crypto/tls:

opts.SetTLSConfig(&tls.Config{
MinVersion: tls.VersionTLS12,
})

然后连接已配置 TLS 的 Broker:

export MQTT_USERNAME='device-user'
# 由运行环境或密钥管理系统注入 MQTT_PASSWORD。
./mqtt-demo -mode pub -id device-01 -broker ssl://mqtt.example.com:8883

这个命令需要真实可访问的 Broker;本地 Compose 仅提供 1883 明文监听。私有 CA 要加载进 RootCAs,双向 TLS 要配置客户端证书;连接主机名必须匹配证书。不要通过 InsecureSkipVerify 绕过证书校验。Paho TLS 配置接口

权限应按设备隔离:设备只能发布自己的 telemetry、status,服务端账号按需要订阅。服务端还应校验 Topic 中的设备身份与载荷一致,并限制消息大小、校验时间戳和数值范围,不能只信任 JSON 中的 device_id。

九、遇到问题时按这个顺序排查​

现象优先检查
connection refusedBroker 是否启动、端口映射和连接地址是否正确
Docker 容器里连不上 127.0.0.1它指向当前容器;同一 Compose 网络应使用服务名 mqtt:1883
连接成功但收不到数据是否等待订阅就绪、Topic 大小写、过滤器、ACL 和 SUBACK 返回码
不断断线、重连Client ID 是否冲突,回调是否阻塞,Keep Alive 和代理超时是否匹配
重连后没有新消息是否在 OnConnect 恢复订阅,恢复订阅是否失败
新订阅就收到旧状态检查 retained 标志,这是保存的最新状态
数据库出现重复记录是否按稳定事件 ID 做唯一约束和事务内幂等
发布等待超时记录为结果未知,查 Broker 和网络日志,避免用新事件 ID 盲目重试

可以先绕过 Go 程序,用容器内的客户端观察消息:

docker compose exec mqtt mosquitto_sub   -h 127.0.0.1 -t 'lab/devices/#' -q 1 -v

若命令行能收到数据,再检查应用回调和业务处理;若命令行也收不到,先检查 Broker、认证和主题。停止实验环境可执行 docker compose down。

十、从演示程序走向业务服务​

这份程序已经串起连接、订阅、上报、重连和状态通知,但它的消费结果只是日志。接入实际业务时,下一步应围绕具体要求补齐:订阅就绪状态、持久化事件、幂等事务、有界处理队列,以及断网、Broker 重启、进程崩溃后的恢复验证。

MQTT 帮助我们把设备和服务端通过主题连接起来;事件是否能恢复、是否会重复扣款、是否已经处理成功,仍需要业务系统给出明确答案。