设备端 MQTT 断线重连:别只写个 while 里 sleep 3 秒
先看一段典型的错误实现
设备端连 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 的两个前提:
- clientId 必须稳定且唯一。用设备序列号或 IMEI 派生,不要用随机数,也不要用 IP。客户端 ID 冲突时 broker 会把前一个连接踢掉,两个设备就开始互相踢、无限重连;
- 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,意味着一定会重复。重复的原因至少三种:
- 设备发出 PUBLISH,broker 回了 PUBACK 但设备没收到(网络抖动),重连后设备重发同一消息;
- 设备收到 PUBLISH,回了 PUBACK 但 broker 没收到,broker 重发;
- 会话恢复时 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% 的时间都处在某种非正常状态里。
- 点赞
- 收藏
- 关注作者
评论(0)