Android MQTT Application 断开后无法自动重连
结论
第一组日志已经暴露出最值得排查的故障点:Paho 的 MQTT 心跳广播 MqttService.pingSender... 持续 60 秒没有处理完,Android 因此判定 BroadcastReceiver 超时。心跳未能按时发送或处理,Broker 就可能在超过 Keep Alive 时间后关闭连接。
第二组日志中的 permitsImpl、getPCIFlag failed 和 RSA minSize 更像是设备加密组件初始化 TLS 时输出的信息。仅凭这些内容,还不能认定 RSA 或证书造成了重连失败。更有诊断价值的是 connect() 的失败异常、connectionLost(Throwable cause) 收到的参数,以及 Mosquitto 服务端在同一时间的日志,但目前都没有记录。
建议优先排查:
- MQTT 心跳接收器是否因主线程阻塞、Binder 阻塞或系统后台限制而延迟。
- 断线后是否存在并发或重复调用
connect()的情况。 - Broker 是否拒绝重连,例如 Client ID 重复、认证失败或连接数耗尽。
- TCP/TLS 连接具体停在哪个阶段。
- 重启计算机究竟恢复了 Mosquitto、网络设备,还是 Android 客户端状态。
第一组日志说明了什么
日志中的关键信息是:
Timeout of broadcast ... MqttService.pingSender.paho27313692436
started 60001ms ago
这不是普通的网络超时。Android 检测到一条广播运行了约 60 秒仍未结束。广播名称包含 MqttService.pingSender,通常与旧版 Eclipse Paho Android Service 使用的 AlarmPingSender 心跳机制有关。
可能的原因包括:
- 应用主线程正在执行数据库、文件、网络或复杂计算任务,广播无法及时处理。
- 某个同步锁、Binder 调用或回调发生阻塞。
BroadcastReceiver.onReceive()或它间接调用的代码执行了耗时操作。- 设备进入 Doze、休眠或厂商省电模式,定时器和后台 Service 被延迟。
- MQTT Service 所在进程的线程耗尽或发生死锁。
- 应用频繁连接、断开或创建多个客户端,引发内部状态竞争。
MQTT Keep Alive 依靠客户端定期发送 PINGREQ 并接收 PINGRESP。如果 Android 端的心跳调度被阻塞,Broker 通常会把客户端视为失活并关闭连接。
不过,这份日志只能证明心跳广播发生了超时,无法单独判断阻塞发生在主线程、Paho 内部线程还是 Binder 调用中。要确认具体原因,还需要结合 ANR、线程栈和 Broker 日志分析。
为什么 silent reconnect 没有恢复
日志显示“执行了重连”,不代表连接已经恢复。当前只有:
Attempting MQTT connection
却没有记录任何后续结果,例如:
- TCP 连接是否建立。
- TLS 握手是否成功。
- Broker 是否返回
CONNACK。 connect()是否进入onFailure()。- 是否抛出
MqttException。 - Broker 是否拒绝了 Client ID 或账号。
- Paho 是否仍将客户端标记为
CONNECTING。
如果旧连接卡在半开状态,或者多个线程同时调用 connect(),客户端可能一直停留在错误的内部状态。重复使用 Client ID 也会引起一种常见的断线循环:新连接建立后,Broker 踢掉旧连接;旧客户端随即重连,又把新连接踢掉。
每台设备和每个 MQTT 实例都应使用稳定且唯一的 Client ID。多台 PCB 或多个应用进程不能共用同一个值。
先补齐客户端错误日志
日志不能只记录“正在重连”,还要保留完整的异常和连接状态:
private val connecting = AtomicBoolean(false)
private val options = MqttConnectOptions().apply {
isAutomaticReconnect = true
isCleanSession = false
connectionTimeout = 15
keepAliveInterval = 60
userName = mqttUser
password = mqttPassword.toCharArray()
}
private fun connectMqtt() {
if (client.isConnected || !connecting.compareAndSet(false, true)) {
return
}
Log.i(TAG, "Connecting MQTT: clientId=$clientId server=$serverUri")
try {
client.connect(options, null, object : IMqttActionListener {
override fun onSuccess(asyncActionToken: IMqttToken) {
connecting.set(false)
Log.i(TAG, "MQTT connected")
// cleanSession=false 时仍应根据业务确认订阅是否存在,
// 或在 connectComplete 中统一恢复订阅。
subscribeTopics()
}
override fun onFailure(
asyncActionToken: IMqttToken?,
exception: Throwable?
) {
connecting.set(false)
Log.e(
TAG,
"MQTT connect failed: connected=${client.isConnected}",
exception
)
scheduleReconnect()
}
})
} catch (exception: MqttException) {
connecting.set(false)
Log.e(TAG, "MQTT connect threw exception", exception)
scheduleReconnect()
}
}
同时注册完整的回调:
client.setCallback(object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
connecting.set(false)
Log.i(
TAG,
"MQTT connectComplete: reconnect=$reconnect serverURI=$serverURI"
)
subscribeTopics()
}
override fun connectionLost(cause: Throwable?) {
connecting.set(false)
Log.e(TAG, "MQTT connection lost", cause)
scheduleReconnect()
}
override fun messageArrived(topic: String?, message: MqttMessage?) {
// 不要在回调线程执行耗时工作,转交给工作线程处理。
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {
Log.d(TAG, "MQTT delivery complete")
}
})
重连需要设置退避,并避免多个定时任务同时调用 connect():
private var reconnectDelayMs = 1_000L
private fun scheduleReconnect() {
if (client.isConnected) {
reconnectDelayMs = 1_000L
return
}
handler.removeCallbacks(reconnectRunnable)
handler.postDelayed(reconnectRunnable, reconnectDelayMs)
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(60_000L)
}
private val reconnectRunnable = Runnable {
connectMqtt()
}
启用 isAutomaticReconnect = true 后,通常不应再无条件运行另一套高频手动重连循环。可以保留看门狗,但调用前要确认客户端不在连接或自动重连过程中,以免出现状态竞争。
cleanSession = false 只决定 Broker 是否保存会话和订阅,无法解决心跳阻塞。使用持久会话时,Client ID 必须稳定且唯一,还要根据实际使用的 MQTT 版本配置会话过期策略。
检查主线程和广播超时
问题发生时就应抓取线程栈,不要等到重启后再检查:
adb shell pidof com.example.deadbolt_frontend
adb shell kill -3 <PID>
adb logcat -v threadtime
重点查看:
main线程正在执行什么。- 是否正在等待锁、Binder、数据库或网络操作。
- MQTT 回调线程是否卡住。
- 是否出现大量相同的连接线程或定时任务。
- 超时前后是否有
ANR in com.example.deadbolt_frontend。 - 是否有
Input dispatching timed out、Broadcast of Intent等 ANR 信息。
以下操作不应放在应用主线程或 MQTT 回调线程中执行:
- 同步网络请求。
- 大量数据库查询或事务。
- 文件读写。
- 等待
Future.get()、Thread.join()或锁。 - 在回调中同步等待另一个 MQTT 操作完成。
- 耗时的消息解析和业务计算。
BroadcastReceiver.onReceive() 也必须尽快返回。如果业务确实需要异步处理,应将任务交给合适的后台执行机制,不能阻塞广播回调。
检查 Android 后台限制
如果故障经常出现在熄屏后、长时间无人操作时或每天固定时段,应重点检查 Doze 和厂商省电策略:
adb shell dumpsys power
adb shell dumpsys deviceidle
adb shell dumpsys activity services com.example.deadbolt_frontend
需要长期保持 MQTT 连接的应用,通常应根据业务场景使用前台 Service,并显示持续通知。只依赖普通后台 Service,在较新的 Android 系统或定制系统上并不可靠。
还要检查:
- 应用是否属于电池优化对象。
- Wi-Fi 是否会在休眠时关闭。
- 网络是否在 Wi-Fi、移动网络或以太网之间切换。
- 系统是否杀死并重新创建过应用进程。
- 设备时间是否发生跳变。
- Paho Android Service 及其心跳实现是否适配目标 Android 版本。
不要把加入电池优化白名单当作唯一解决方案。应先验证断线是否确实与 Doze 或后台限制有关。
检查 Mosquitto 服务端
在 Mosquitto 端开启足够详细的连接日志,并与 Android 日志对齐时间。需要确认断开时服务端记录的具体原因,例如:
- Keep Alive 超时。
- Socket 被客户端关闭。
- TLS 错误。
- 认证或 ACL 拒绝。
- 重复 Client ID 导致旧连接被替换。
- 达到最大连接数或文件描述符上限。
- 网络层连接重置。
如果有权限,应先检查 Mosquitto 状态,不要直接重启整台计算机:
systemctl status mosquitto
journalctl -u mosquitto --since "2022-03-29 08:00:00"
具体命令取决于操作系统和 Mosquitto 的部署方式。如果只重启 Mosquitto 服务就能恢复,问题更可能出在 Broker 状态、连接资源或网络监听端。如果结束并重启 Android 应用就能恢复,则更可能是客户端内部状态异常或线程阻塞。
还应检查系统资源:
ss -antp
ulimit -n
观察是否存在大量 CLOSE_WAIT、SYN_SENT 或长时间未释放的 MQTT 连接。如果必须重启计算机才能恢复,还要排查资源泄漏、网络栈异常、防火墙状态和 Broker 进程故障。
确认重连卡在哪一层
一次 MQTT TLS 连接大致会经过以下阶段:
DNS → TCP connect → TLS handshake → MQTT CONNECT → CONNACK → SUBSCRIBE
应为每个阶段设置合理的超时并记录结果。如果日志停在 RSA 相关信息之后,可能说明系统正在初始化 TLS,但这不足以证明 TLS 失败。只有拿到具体异常,才能继续判断,例如:
java.net.SocketTimeoutException
java.net.ConnectException
java.net.UnknownHostException
javax.net.ssl.SSLHandshakeException
org.eclipse.paho.client.mqttv3.MqttException
如果服务端完全看不到新连接,应先检查 DNS、路由、防火墙和 TCP 端口。如果服务端能看到 TCP/TLS 连接,但没有收到 MQTT CONNECT,再检查 TLS、证书、客户端线程和 Paho 状态。如果服务端收到 CONNECT 后立即拒绝连接,就要查看 CONNACK 返回码,并检查认证、ACL、协议版本和 Client ID。
必要时,可以在 Broker 所在机器上抓包:
tcpdump -i any -nn -s 0 -w mqtt-reconnect.pcap port 1883 or port 8883
抓包内容可能包含业务地址、Client ID 或未加密的 MQTT 数据,需要按照实际安全要求妥善保管。
建议的排查顺序
- 在
connectionLost()、connect().onFailure()和connectComplete()中记录完整异常。 - 对齐 Android 与 Mosquitto 的系统时间,确认断线时服务端记录的原因。
- 确认 Client ID 唯一,排除多个客户端互相踢线。
- 在超时发生时抓取线程栈,检查主线程、Binder 或 MQTT 回调线程是否阻塞。
- 检查是否存在并发
connect()、重复创建客户端或没有退避的重连。 - 验证问题是否只在熄屏、休眠或网络切换后出现。
- 分别测试重启应用、重启 Mosquitto 服务和重启计算机,逐步缩小故障范围。
- 如果仍无法定位,结合 Broker 日志和抓包确认连接停在 TCP、TLS 还是 MQTT 协议阶段。
现有证据更支持“心跳广播长时间没有得到处理,导致 Keep Alive 失效”这一判断,但仅凭这两段日志还无法确定最终根因。补齐异常回调、服务端日志和故障现场的线程栈后,通常就能判断问题来自 Android 调度阻塞、网络或 TLS,还是 Broker 拒绝连接。
备注:内容仅供参考。
