设备端 MQTT 断线重连:别只写个 while 里 sleep 3 秒

举报
yd_282927746 发表于 2026/11/06 09:01:23 2026/11/06
【摘要】 固定间隔重试会踩两个极端:弱网下慢、长断网时刷爆日志和电量。拆解退避(指数+抖动防惊群)、会话(cleanSession 与丢失的订阅)、离线队列(上限与丢弃策略)、幂等(QoS1 必然重复),外加 keepAlive 取值、Android 后台冻结、时钟回拨四个现场坑。

先看一段典型的错误实现

设备端连 MQTT,很多人的第一版是这样的:

fun connectLoop() {
    while (true) {
        try {
            client.connect(options)
            break
        } catch (e: Exception) {
            Thread.sleep(3000)      // 固定 3 秒后重试
        }
    }
}

在办公室的 WiFi 下它能跑好几天。装到现场——商场后厨、工厂车间、地下车库——问题就全出来了:

  • 网络断了 8 小时,设备一直在 3 秒一次地重连,日志刷了几十兆,电量掉了一半;
  • 平台侧看到的是每秒几十个连接请求,同批几百台设备同时上线时直接把 broker 的连接数打满;
  • 明明网络恢复了,设备却要再等好几轮才连上,用户在屏幕上看到"离线";
  • 重连之后发现订阅丢了、离线期间的指令全没了、QoS1 的消息还重复处理了一遍。

断线重连不是"重试几次"那么简单。它是四件事:退避策略、会话策略、离线队列、重复消息处理。少一样,现场就会出问题。

一、退避:指数 + 抖动 + 上限

固定间隔重试的错误在于两个极端都会被踩到:网络闪断时嫌太慢,长时间断网时嫌太密。

正确做法是指数退避:

delay = min(base * 2^attempt, maxDelay)

base 取 1 秒,maxDelay 取 30–60 秒。这样前几次快速恢复,长时间断网也不会把日志和电量烧掉。

但只有指数退避还不够,必须加抖动(jitter)。 想象一下:产线一次断电,500 台设备同时掉线,同时开始退避,退避序列完全一致,于是 1s、2s、4s、8s……每一轮都是 500 台同时打过来。这叫惊群,broker 会在每次重连波峰上抖一下,然后进入下一轮。

抖动就是给每个设备的延时加一个随机量:

val backoff = min(baseMs * (1L shl attempt), maxDelayMs)
val jittered = (backoff * (0.5 + Random.nextDouble() * 0.5)).toLong()

也就是在 [0.5×backoff, backoff] 之间随机。500 台设备的重连请求就被摊平到一个区间里,broker 的压力从"尖峰"变成"缓坡"。

如果设备有其他随机源(比如首次开机时间、序列号),也可以用序列号做确定性抖动:jitter = base * (serial.hashCode() % 100) / 100。这在压测和复现时更好用,因为同一台设备的行为是稳定的。

还要加一条:退避只应该被"连接成功并稳定运行一段时间"重置。如果连上 200 毫秒又掉,然后你把 attempt 清零重新 1 秒重试,就会退化成 1 秒一次的死循环。稳妥的做法是连接成功且稳定超过 60 秒后,才把 attempt 归零。

二、会话:cleanSession 该开还是关

这是最容易被忽略、影响又最大的一项。

MQTT 3.1.1 的 cleanSession(5.0 里叫 cleanStart + Session Expiry):

  • cleanSession = true:每次连接都是全新会话,broker 不保存订阅和离线消息;
  • cleanSession = false:会话持久化,重连后订阅还在,QoS1/2 的离线消息会在重连后补发。

设备端怎么选?

如果你的设备是"指令型"(收到命令就执行),选 false,并用固定 clientId。 否则会出现这种事故:断网 10 分钟,期间平台下发了"停止加热"的指令,设备重连后订阅恢复但那条消息永远丢了,机器继续加热。

如果设备是"上报型"(只发数据不收命令),true 也够用,因为上报走 QoS1 且你在本地有队列,丢的是平台的接收,不是设备侧的数据。

但注意 cleanSession = false 的两个前提:

  1. clientId 必须稳定且唯一。用设备序列号或 IMEI 派生,不要用随机数,也不要用 IP。客户端 ID 冲突时 broker 会把前一个连接踢掉,两个设备就开始互相踢、无限重连;
  2. broker 侧要限制会话保留时长。会话永久保留,几万台设备的会话元数据会占满内存。MQTT 5.0 支持 Session Expiry Interval,可以设成 24 小时或 7 天;3.1.1 通常靠 broker 的 persistent_client_expiration 配置。

还有个细节:订阅要在每次连接成功后重新确认(subscribe 的回调里校验 granted QoS)。持久会话下如果 broker 重启丢了会话,你的设备以为自己订阅着,其实没有——这在现场表现为"设备在线但收不到指令"。

三、离线队列:必须有上限

cleanSession = false 只保证平台→设备的离线消息不丢。设备→平台的方向没有这个保障,你要自己做本地队列。

趋势数据、报警、状态变更,断网时应该先写本地,连上后再补发。但队列一定要有上限:

class OfflineQueue(private val capacity: Int = 500) {
    private val deque = ArrayDeque<Record>()

    @Synchronized
    fun offer(r: Record) {
        if (deque.size >= capacity) {
            // 满了丢最旧的:趋势数据宁可丢历史,也要保住最新的
            deque.pollFirst()
            dropped++
        }
        deque.addLast(r)
    }
}

几个判断:

  • 数据类型决定丢弃策略。温度曲线这类高频数据,丢最旧的没问题;但"报警已触发""门已上锁"这类状态,必须只保留最后一条(用 map 覆盖而不是队列堆积);
  • 补发要限速。断网 8 小时攒了 500 条,重连后一次性并发发出去,会把 broker 和带宽打满,还可能触发平台限流。正确做法是分批,比如每次 20 条、间隔 200ms;
  • 补发要带时间戳。补发的数据不是"现在"的数据,payload 里必须有采集时间,否则平台侧的时序全乱。

四、重复消息:QoS1 是"至少一次"

QoS1 的语义是 at least once,意味着一定会重复。重复的原因至少三种:

  1. 设备发出 PUBLISH,broker 回了 PUBACK 但设备没收到(网络抖动),重连后设备重发同一消息;
  2. 设备收到 PUBLISH,回了 PUBACK 但 broker 没收到,broker 重发;
  3. 会话恢复时 inflight 消息被重新投递。

所以接收侧必须幂等。做法很简单:每条消息带一个业务 ID(msgId 或 bizNo),接收方维护一个最近 N 条的 ID 集合:

private val seen = object : LinkedHashMap<String, Long>(256, 0.75f, false) {
    override fun removeEldestEntry(e: MutableMap.MutableEntry<String, Long>) = size > 256
}

@Synchronized
fun isDuplicate(id: String): Boolean {
    if (seen.containsKey(id)) return true
    seen[id] = System.currentTimeMillis()
    return false
}

用 LinkedHashMap 的 LRU 特性做定长去重表,几十行代码就能挡掉 99% 的重复执行。真正需要严格去重的(比如扣款、开关机),还得在服务端做唯一约束。

发送侧同理:QoS1 重发时不要重新生成 msgId,否则对端没法识别这是同一条。

五、几个现场才会遇到的坑

坑一:keepAlive 设太小,弱网下频繁掉线。

Android 上很多实现把 keepAlive 设成 30 秒甚至 15 秒,觉得"检测快一点好"。但在 2G/弱 WiFi 环境下,一个 PUBLISH 的往返就可能超过 10 秒,心跳没按时发出,broker 判定离线,触发重连——结果是网络明明可用,设备却在反复重连。

经验值:60–120 秒。同时把客户端的 connectTimeout 和 socketTimeout 设成 keepAlive 的 1.5–2 倍,避免把"慢"误判成"断"。

坑二:Android 后台限制。

App 切到后台或锁屏后,Doze 会冻结网络和定时器,心跳发不出去 → 连接被 broker 断开;设备唤醒后又重连。如果设备是常供电的触屏机,正确的做法是:

  • 前台服务(startForeground)+ 常驻通知,避免进程被冻结;
  • 关闭电池优化(REQUEST_IGNORE_BATTERY_OPTIMIZATIONS),并在系统设置里加白名单;
  • 用 WifiManager 的 WifiLock 防止 WiFi 休眠(部分设备在息屏后会关 WiFi 省电)。

如果这些都不做,只靠 setKeepAlive 调参,是治不好的——休眠是系统行为,不是网络问题。

坑三:时钟回拨导致证书校验失败。

设备长时间离线,开机后时间可能还是出厂时间(1970 或几个月前),TLS 握手校验服务端证书有效期直接失败,表现为"连不上"但错误日志里只有一句 SSL 异常。

处理方式:先做 NTP 校时,再建连接;或者允许在时间明显异常时,用"记录服务器返回时间→本地偏移量"的方式修正,而不是依赖系统时钟。

坑四:重连风暴把日志写满磁盘。

退避没做抖动 + 每次都打印完整异常堆栈,一天能写几百兆。除了退避,还应该对日志做限流:同一类错误 1 分钟内只记一次,其余计数合并。

一张对照表

问题 症状 修法
固定间隔重试 弱网下重连过慢、断网时刷日志 指数退避 + 抖动 + 上限
惊群 broker 周期性连接抖动 抖动区间 [0.5×, 1×],或用序列号派生
会话丢失 重连后收不到指令 cleanSession=false + 稳定 clientId
订阅丢失 在线但收不到消息 每次连接后重新 subscribe 并校验
消息重复 同一条命令执行两次 业务 ID + LRU 去重表
离线数据爆量 重连后平台被淹 本地队列有上限 + 分批限速补发
频繁掉线 网络正常却反复断 keepAlive 60–120s,超时给 1.5–2 倍
后台连不上 锁屏后掉线 前台服务 + 电池优化白名单 + WifiLock

小结

设备端 MQTT 的稳定性,80% 不取决于 broker 多强,而取决于客户端有没有把这四件事做对:退避(别打死自己和别人)、会话(别丢指令)、队列(别撑爆)、幂等(别执行两次)。

这四件事每件都只有几十行代码,但它们决定了设备在断网、弱网、断电重启这些"非正常路径"下的表现——而现场 90% 的时间都处在某种非正常状态里。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。