PAYATHON 2026

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 服务端在同一时间的日志,但目前都没有记录。

建议优先排查:

  1. MQTT 心跳接收器是否因主线程阻塞、Binder 阻塞或系统后台限制而延迟。
  2. 断线后是否存在并发或重复调用 connect() 的情况。
  3. Broker 是否拒绝重连,例如 Client ID 重复、认证失败或连接数耗尽。
  4. TCP/TLS 连接具体停在哪个阶段。
  5. 重启计算机究竟恢复了 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 数据,需要按照实际安全要求妥善保管。

建议的排查顺序

  1. 在 connectionLost()、connect().onFailure() 和 connectComplete() 中记录完整异常。
  2. 对齐 Android 与 Mosquitto 的系统时间,确认断线时服务端记录的原因。
  3. 确认 Client ID 唯一,排除多个客户端互相踢线。
  4. 在超时发生时抓取线程栈,检查主线程、Binder 或 MQTT 回调线程是否阻塞。
  5. 检查是否存在并发 connect()、重复创建客户端或没有退避的重连。
  6. 验证问题是否只在熄屏、休眠或网络切换后出现。
  7. 分别测试重启应用、重启 Mosquitto 服务和重启计算机,逐步缩小故障范围。
  8. 如果仍无法定位,结合 Broker 日志和抓包确认连接停在 TCP、TLS 还是 MQTT 协议阶段。

现有证据更支持“心跳广播长时间没有得到处理,导致 Keep Alive 失效”这一判断,但仅凭这两段日志还无法确定最终根因。补齐异常回调、服务端日志和故障现场的线程栈后,通常就能判断问题来自 Android 调度阻塞、网络或 TLS,还是 Broker 拒绝连接。

备注:内容仅供参考。