先看一段真实的事故现场
下面这段日志摘自我自己项目的真实运行日志(app-run.log,一字未改):
08:47:53.028 INFO --- [main] 正在连接 MQTT Broker: tcp://localhost:1883 08:47:53.695 WARN --- [thub-server-001] MQTT 连接被拒绝: 无权连接 08:48:03.610 INFO --- [scheduling-1] 正在连接 MQTT Broker: tcp://localhost:1883 08:48:04.443 INFO --- [thub-server-001] MQTT 连接成功 08:48:04.451 INFO --- [thub-server-001] MQTT 已订阅: up/+/+, st/+/+ (reconnect=false)注意第二行的线程名[thub-server-001]——这是 Paho 客户端自己的连接线程;第三行的[scheduling-1]是 Spring@Scheduled定时任务线程池的线程。两条"正在连接"来自两个不同的发起方,这正是很多 MQTT 连接抖动问题的根源:不止一个人在抢着连。
MQTT 客户端反复重连的四种典型原因
按我踩过和见过的频率排序:
1. 手动重连和自动重连打架(最容易踩)setAutomaticReconnect(true)开了自动重连,又自己起一个定时任务轮询调connect()。Paho 的自动重连线程和你手动发起的连接并发抢同一个 client——同一个 clientId,谁连上谁把对方踢下线(MQTT 同 clientId 互踢规则),表现出来就是"连接成功 → 立刻断开 → 再连接"的死循环。
2. 同一个 clientId 被顶号
两个进程(比如本地调试的服务没关、又启动了一个)用了同一个 clientId 连同一个 Broker,双方互相踢,表现和第 1 种几乎一样。排查方法:打开 EMQX Dashboard → 监控 → 客户端,看同一个 clientId 是不是频繁上下线、或者出现两条会话记录。
3. 认证 / ACL 被拒
密码错、HTTP 认证回调返回格式不对(EMQX 要求{"result":"allow"},返回自己的统一响应体会被一律判拒)、ACL fail closed。日志特征是稳定报"连接被拒绝"——这种不会抖动,是匀速失败,每 10 秒被拒一次那种。
4. Broker 压根没起来
应用比 EMQX 先启动是开发机的常态(电脑重启后启动顺序不定)。特征是连接超时异常,而不是"被拒绝"。
第 3、4 种都好办,真正让人摸不着头脑的是 1 和 2——因为它们的表现是"明明连上了,怎么又断了"。
我的踩坑与修复:重连权收归一家
我的第一版就是第 1 种:automaticReconnect开着,同时又写了个@Scheduled任务每 10 秒检查连接、没连上就connect()。平时没事,一旦断线,两个重连机制同时启动,互相把对方踢下线,日志里连接成功和连接断开交替刷屏。
修复思路就一句话:重连权只能有一个主人,且分阶段交接。
- 还没连上过(应用刚启动、Broker 还没就绪)→ 定时任务兜底重试
- 成功连上过一次之后 → 定时任务永久退场,断线重连完全交给 Paho
真实代码如下(来源:iot-device-platform/src/main/java/com/iothub/mqtt/MqttConnection.java):
/** 只要成功连上过一次,后续重连就完全交给 Paho 的 automaticReconnect */privatevolatilebooleaneverConnected=false;/** 上次发起连接的时间,防止定时任务和自动重连同时抢(同 clientId 会被 EMQX 互踢) */privatevolatilelonglastConnectAttempt=0;/** 只在"从来没连上过"时兜底;连上过之后 automaticReconnect 负责一切 */@Scheduled(fixedDelayString="${mqtt.reconnect-interval-ms:10000}")publicvoidensureConnected(){if(everConnected){return;}connect();}privatesynchronizedvoidconnect(){if(client!=null&&client.isConnected()){return;}longnow=System.currentTimeMillis();if(now-lastConnectAttempt<props.getReconnectIntervalMs()){return;// 节流:两次连接尝试强制间隔,防止高频轰炸 Broker}lastConnectAttempt=now;try{if(client==null){client=newMqttAsyncClient(props.getBroker(),props.getClientId(),newMemoryPersistence());client.setCallback(callback());}MqttConnectOptionsoptions=newMqttConnectOptions();options.setAutomaticReconnect(true);// 连上之后掉线,交给 Pahooptions.setCleanSession(true);options.setMaxInflight(100);options.setConnectionTimeout(10);// ... 用户名密码略client.connect(options,null,newIMqttActionListener(){@OverridepublicvoidonSuccess(IMqttTokenasyncActionToken){log.info("MQTT 连接成功");}@OverridepublicvoidonFailure(IMqttTokenasyncActionToken,Throwableexception){lastConnectAttempt=0;// 被拒后清零时间窗,下一轮定时任务可以立刻重试log.warn("MQTT 连接被拒绝: {}",exception==null?"unknown":exception.getMessage());}});}catch(MqttExceptione){log.warn("MQTT 连接失败({}),{}ms 后重试",e.getMessage(),props.getReconnectIntervalMs());}}配套的回调里,connectComplete负责"交接":
@OverridepublicvoidconnectComplete(booleanreconnect,StringserverURI){everConnected=true;// 从此定时任务退场,重连交给 Pahotry{String[]topics={props.getTelemetryTopic(),props.getStatusTopic()};int[]qos={1,1};IMqttTokentoken=client.subscribe(topics,qos);token.waitForCompletion(10_000);log.info("MQTT 已订阅: {} (reconnect={})",String.join(", ",topics),reconnect);}catch(MqttExceptione){log.error("MQTT 订阅失败",e);}}三个设计点,面试也能聊:
- 为什么不启动时连不上就抛异常?应用先起、Broker 后起是常态。服务"不因依赖缺失而起不来",连接失败只告警,由定时任务保证最终连上——这正是"最终成功"的思路。
- 为什么重连后必须重新订阅?我用了
cleanSession=true:会话不保留,重连后订阅关系是空的,不在connectComplete里补订阅,就出现"连接显示在线、数据一条都收不到"的灵异现象。 everConnected为什么要 volatile?定时任务线程和 Paho 回调线程是两个线程,一个写一个读,可见性必须保证。
怎么验证重连真的可靠
光看代码没用,重连这种东西必须现场演练一次。我的验证步骤(可复现):
- 起应用,等日志出现"MQTT 已订阅",EMQX Dashboard 客户端页能看到
iothub-server-001在线; bin\emqx stop停掉 Broker → 应用日志出现"MQTT 连接断开"(connectionLost);bin\emqx start再启动,不重启应用→ 日志自动出现"MQTT 已订阅: … (reconnect=true)";- 回到看板,实时曲线继续往前走,数据链路无感恢复。
排查清单(拿去直接用)
| 现象 | 大概率原因 | 怎么确认 |
|---|---|---|
| 稳定报"连接被拒绝" | 认证/ACL 配置错 | 核对用户名密码;HTTP 认证回调必须返回{"result":"allow"} |
| 疯狂刷"正在连接",连上又断 | 手动重连与自动重连互踢 | 全局搜connect(,确保只有一个地方发起重连 |
| 连接成功几秒后被踢,循环 | 同 clientId 顶号 | EMQX Dashboard 看该 clientId 是否频繁上下线;查有没有第二个进程 |
| 启动时报连接失败,之后正常 | Broker 比应用后启动 | 属正常,定时任务兜底即可 |
| 显示在线但收不到数据 | 重连后没重新订阅 | 在connectComplete里补订阅 |
三个常见追问
Q:Paho 自动重连的间隔能自己控制吗?
A:不能配置,它内部从 1 秒开始翻倍、2 分钟封顶。想要可控的退避节奏,就得关掉automaticReconnect自己实现——但切记全工程只留这一个重连入口,别像第一版的我那样两头抢。
Q:cleanSession 该设 true 还是 false?
A:看你要不要"断线补传"。我的服务端消费遥测数据,丢了靠设备端本地缓存 + 时间戳重连后补发,服务端按时间戳幂等去重,所以true更简单。要 Broker 帮忙暂存离线消息就得false,代价是会话状态管理复杂度上一个台阶。
Q:keepalive 设多少合适?
A:Paho 默认 60 秒,服务端固定网络够用;弱网设备可以适当加大,配合 Broker 侧的会话过期时间一起调。keepalive 不是越小越"实时",它只是探活,实时性靠的是上报频率和推送链路。
我是软件工程在读(专升本),正在从零搭一个物联网设备接入平台(Spring Boot + EMQX + MySQL),本文代码全部来自这个真实项目。踩坑记录会持续更新,欢迎关注,一起卷物联网后端。