Spring Integration + Paho MQTT 麒麟 Linux 收到 QoS>0 消息后客户端主动断连问题排查复盘
场景:地震预警 MQTT 消费服务,Windows 开发环境一切正常;部署麒麟 Linux 后,Broker 推送 MQTT 消息,消费完成客户端主动关闭 TCP 连接,然后自动重连。业务处理耗时极短(几 ms),不是业务阻塞导致。
一、现象描述
- 开发环境:Windows + Spring Integration + Paho 1.2.5,YAML 配置
qos:2,接收预警消息,消费正常,TCP 长连接稳定,不会断开。 - 生产环境:麒麟 Linux 4.19 内核,相同代码、相同配置。Broker 下发 MQTT Publish 消息,业务消费成功,消费完成后客户端主动发送 TCP FIN 关闭连接 ,日志打印
Lost connection: MqttException; retrying...,随后 Paho 自动重连。 - 业务耗时很短:业务处理仅 4ms 左右,排除业务代码长时间阻塞 IO 线程的直观猜想。
- 抓包现象:消息到达前,PingReq/PingResp 心跳双向正常,连接健康;Broker 下发 Publish 报文后,客户端只回复 TCP 四层 ACK,没有发出 MQTT 协议层 QoS 应答包(QoS1 需要 PUBACK,QoS2 需要 PUBREC),紧接着客户端主动 FIN 断开 TCP。
二、排查思路(按顺序)
阶段 1:初步猜想(错误方向)
- 猜想 1:Broker keepalive 超时踢掉连接。
- 验证:Wireshark 抓包,发现FIN 包是客户端(麒麟 Java 进程)主动发出,不是 Broker 先关闭,推翻猜想。
- 猜想 2:业务代码阻塞 Paho IO 线程。
- 验证:增加埋点日志,业务耗时仅 4ms,非常短,看起来不像是阻塞。但这里埋下疑点:业务跑在哪个线程?
阶段 2:抓包定位协议异常
抓包重点观察 MQTT 协议报文:
- QoS=2 订阅,Broker 下发 Publish 消息。
- TCP 层:麒麟客户端收到包,回复 TCP ACK(四层确认收到数据包)。
- MQTT 应用层:没有 PUBREC 应答报文,Paho 内部抛出 MqttException,直接关闭 Socket。
关键线索:QoS>0 时,Paho 必须发送对应协议应答报文,应答发送失败触发连接关闭。
阶段 3:定位线程模型(核心转折点)
查看 Spring Integration MqttPahoMessageDrivenChannelAdapter 机制:
DirectChannel:同步通道。收到 MQTT 消息后,handler 业务代码直接运行在 Paho 底层 CommsReceiver IO 接收线程。- 执行流程:Paho IO 线程接收 Publish → 交给 DirectChannel → 在同一个 IO 线程执行业务 handler → handler 执行返回 → Paho 继续在同一个 IO 线程发送 QoS 协议应答(PUBREC/PUBACK)。
问题特征:Windows JVM Socket 时序行为可以正常完成 write 写出应答包;麒麟 Linux + Paho 1.2.5 存在 IO 时序 bug,哪怕业务只有几 ms,也会导致后续 Socket 写报文失败。
阶段 4:验证猜想
使用最小改动验证:YAML 把qos改为 0。 QoS=0 不需要任何协议应答包。测试后发现收到消息不再断连,确认问题和 QoS 应答报文发送强相关。
阶段 5:额外隐藏坑(中途踩坑)
修改通道为 ExecutorChannel 后,服务启动消费时报错: NoClassDefFoundError: org/springframework/boot/configurationprocessor/json/JSONObject 原因:spring-boot-configurationprocessor是编译期注解处理器 ,打包后的运行 jar 不会包含该 JSON 类。本地 IDE 编译能找到,服务器运行直接类找不到。 修复:替换 JSON 解析依赖,改用org.json或者 Jackson。
三、根本原因
- 组件版本缺陷 :
org.eclipse.paho.client.mqttv3:1.2.5存在已知 IO 时序 bug。 - Spring Integration 通道选型错误 :使用
DirectChannel同步通道,业务 handler 运行在 Paho 的 CommsReceiver 底层 IO 线程。 - 执行时序:handler 执行完毕后,Paho 复用同一个 IO 线程发送 QoS 协议应答报文。麒麟 Linux 内核 + JVM 组合下,该场景 Socket 写操作失败,抛出 MqttException,Paho 主动关闭 Socket。Windows 下 Socket 时序不触发该 bug,所以开发机正常。
重点:不是业务代码耗时过长,哪怕业务仅几毫秒,在特定 Linux 环境下依然会触发这个时序缺陷。Spring Integration 官方文档明确提示:MQTT inbound 消费,QoS>0 场景不建议使用 DirectChannel。
四、最终解决方案
YAML 配置保持不变,
qos:2保留,不需要降级 QoS,只修改 Spring Integration 的 MessageChannel Bean。 将DirectChannel替换为ExecutorChannel,使用独立线程池执行业务逻辑,业务处理与 Paho 底层 IO 线程解耦。
原理
- MQTT 消息到达,Paho CommsReceiver IO 线程接收 Publish 消息。
- 消息交给
ExecutorChannel:Paho IO 线程立刻返回,不再执行业务代码。 - Paho IO 线程空闲后,马上执行 QoS 协议应答(PUBREC),完成 MQTT 协议握手。
- 业务消息丢入独立线程池,在单独线程执行业务处理。
核心:释放 Paho IO 线程,让 Paho 优先完成 MQTT 协议应答报文发送,业务逻辑异步执行,规避 paho1.2.5 在 Linux 下 Socket 写时序 bug。
线程池选型(地震预警业务)
业务需要消息顺序消费 ,选用SingleThreadExecutor单线程池:
-
优点:严格保持消息接收顺序;线程数量固定为 1,不会无限创建线程,避免生产环境线程暴涨资源耗尽。
-
缺点:消息突增会排队;本业务处理极快,完全满足。
// 抽取为独立Bean,增加destroyMethod实现优雅关闭
@Bean(destroyMethod = "shutdown")
public ExecutorService mqttInboundExecutor() {
return Executors.newSingleThreadExecutor();
}@Bean
public MessageChannel mqttInputChannel(ExecutorService mqttInboundExecutor) {
return new ExecutorChannel(mqttInboundExecutor);
}
五、验证标准(上线前确认)
- 发送单条测试消息:消息正常消费,无
Lost connection日志,TCP 连接不断开。 - 连续批量发送多条消息:消息串行消费,无断连,无重连日志。
- 长时间挂机静置:Ping 心跳持续双向交互,长连接稳定。
- 抓包观测:Broker 下发 Publish 后,客户端正常发出 PUBREC(QoS2),完整走完 QoS2 四步握手。
六、踩坑总结 & 避坑清单
- Spring Integration MQTT Inbound 消费,QoS>0 严禁使用 DirectChannel,必须异步 ExecutorChannel。
- Paho 1.2.5 在 Linux 环境存在 IO 时序 bug,Windows 不触发,属于环境差异 bug,本地开发很难复现。
- 不要使用
spring-boot-configurationprocessor包内的 JSON 类做业务解析,仅编译期可用,运行期缺失。 - ExecutorChannel 线程池谨慎使用
newCachedThreadPool,无界线程池在消息洪峰下会造成线程无限膨胀,生产风险高。 - 优先抓包定位,不要只看日志。区分:Broker 踢连接 vs 客户端主动关闭连接,两者根因完全不同。
七、备选兜底方案(如后续还有异常)
- 升级 paho-client 版本至 1.2.6,修复该版本 IO 时序相关缺陷。
- 如果业务不需要严格消息顺序,可替换为有界线程池 + CallerRunsPolicy,防止任务队列满后消息丢失。