从一个真实工业物联网项目出发,手把手带你从零搭建一套基于 Eclipse Paho + Spring Event 的 MQTT 事件驱动接入层。涵盖协议基础、客户端生命周期管理、回调机制与 retained 消息过滤、双层重连策略、Spring Event 解耦等核心内容,附完整源码级分析和面试要点。


一、为什么物联网项目要用 MQTT?

1.1 传统 HTTP 在物联网场景下的困境

想象你的系统接入了 100 个温度传感器,每个传感器每隔几秒就要上报一次温度数据。如果用传统的 HTTP 方式,每次通信都要经历"建立 TCP 连接 → 发送请求 → 等待响应 → 断开连接"的过程。100 个传感器 × 每秒 1 次上报 = 每秒 400 次连接建立和断开,服务器光是处理连接开销就扛不住了。

1.2 MQTT 的核心优势

MQTT(Message Queuing Telemetry Transport)是专为物联网设计的轻量级通信协议,它的核心思想是发布/订阅模型

  • 长连接:设备与 Broker 之间保持持久连接,不用反复建立/断开
  • 发布/订阅:设备把数据"发布"到 Broker 的某个 Topic 上,服务器"订阅"该 Topic 就能自动收到
  • 轻量级:协议头最小只有 2 字节,非常适合带宽受限的物联网场景

用一个生活类比来理解:MQTT 就像"订阅微信公众号"。设备是公众号作者,负责发布内容;服务器是读者,负责订阅内容;Broker 就是微信平台,负责把作者的内容推送给所有订阅者。


二、整体架构设计

在开始写代码之前,先看一下整体的消息流架构图:

传感器/设备
    │  发布消息到 Topic
    ▼
┌─────────────────────────┐
│      MQTT Broker        │  ← 消息中转站(如 EMQX / Mosquitto)
│   (tcp://host:1883)     │
└────────────┬────────────┘
             │  推送消息给订阅者
             ▼
┌──────────────────────────────────────────────┐
│  MqttCallback(Paho 回调适配层)               │
│  ├── messageArrived()  → 过滤 retained 消息   │
│  ├── connectionLost()  → 断线处理              │
│  └── connectComplete() → 重连后重新订阅        │
└────────────────┬─────────────────────────────┘
                 │  委托
                 ▼
┌──────────────────────────────────────────────┐
│  MqttIngressOrchestrator(接入编排器)          │
│  ├── 双层重连策略(Paho 内置 + 应用层兜底)     │
│  ├── 消息记录(Deque 滑动窗口,最近 50 条)     │
│  ├── 消息计数(AtomicLong 统计今日消息量)      │
│  └── 消息分发 → MqttMessageHandler             │
└────────────────┬─────────────────────────────┘
                 │
                 ▼
┌──────────────────────────────────────────────┐
│  MqttMessageHandler(Spring Event 桥梁)       │
│  └── 把 MQTT 消息包装为 MqttMessageEvent       │
│      通过 ApplicationEventPublisher 发布       │
└────────────────┬─────────────────────────────┘
                 │  Spring 事件机制(解耦分界线)
                 ▼
┌──────────────────────────────────────────────┐
│  DeviceDataEventListener(事件监听器)          │
│  └── @EventListener 监听事件                  │
│      → 调用 Pipeline 编排器处理数据             │
└──────────────────────────────────────────────┘

这个架构的核心设计思想是分层解耦

  • 回调适配层:只负责翻译 Paho 的回调接口,做最基本的过滤
  • 接入编排层:负责重连、订阅管理、消息记录等业务逻辑
  • 事件桥梁层:通过 Spring Event 实现通信模块与数据处理模块的解耦

三、配置层:让 MQTT 参数可外部化管理

3.1 配置属性类

所有 MQTT 连接参数都通过 @ConfigurationProperties 映射到 Java 对象,支持在 application.yml 中外部化配置:

@Component
@ConfigurationProperties(prefix = "mqtt")
@Data
public class MqttProperties {

    private String hostUrl;       // Broker 地址,如 tcp://192.168.1.53:1883
    private String username;      // 认证用户名
    private String password;      // 认证密码
    private String defaultTopic;  // 默认订阅主题
    private int timeout = 30;     // 连接超时(秒)
    private int keepAlive;        // 心跳间隔(秒)
    private Boolean cleanSession; // 是否清除会话
    private Boolean reconnect;    // 是否启用自动重连
    private Integer qos;          // 默认 QoS 等级(0/1/2)
    private Boolean isOpen;       // 是否启用 MQTT 模块

    private static String clientId;

    @Value("${mqtt.clientId:mqtt_server_{}}")
    public void setClientId(String clientId) {
        MqttProperties.clientId = clientId;
    }
}

对应的 YAML 配置:

mqtt:
  hostUrl: tcp://192.168.1.53:1883
  username: admin
  password: ******
  defaultTopic: client/device/#
  timeout: 30
  keepAlive: 60
  cleanSession: true
  reconnect: true
  qos: 1
  isOpen: true

几个关键配置项的解释

  • cleanSession=true:每次连接都是"全新"的,Broker 不保留之前的订阅信息。项目每次启动都会通过代码重新订阅,所以选 true。
  • reconnect=true:开启 Paho 内置的自动重连(第一层重连)。
  • qos=1:至少送达一次。温度数据宁可重复也不能丢失。
  • isOpen:一个总开关,控制是否初始化 MQTT 模块。本地开发时如果没有 Broker,设为 false 即可跳过。

3.2 条件加载:按需启动

不是所有环境都有 MQTT Broker(比如本地开发环境)。通过 Spring 的 @Conditional 机制,可以按需启动:

public class MqttCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String isOpen = context.getEnvironment().getProperty("mqtt.isOpen");
        return Boolean.valueOf(isOpen);
    }
}
@Configuration
public class MqttConfig {

    @Autowired
    private MqttClientUtils mqttClientUtils;

    @Conditional(MqttCondition.class)  // 只有 mqtt.isOpen=true 才执行
    @Bean
    public MqttClientUtils getMqttClientUtils() {
        try {
            mqttClientUtils.connect();
            return mqttClientUtils;
        } catch (Exception e) {
            log.error("MQTT 连接失败:{}", e.getMessage());
        }
        return null;
    }
}

四、客户端生命周期管理

4.1 唯一 ClientId 的生成

每个连到 Broker 的客户端必须有唯一的 clientId,否则 Broker 会把旧的踢掉。项目中采用"硬件序列号 + 时间戳"的组合来保证唯一性:

private static String getClientId() {
    // 获取服务器硬件序列号
    String serialNumber = OshiUtil.getSystem().getSerialNumber();
    // 格式:mqtt_server_{硬件序列号}_{当前时间戳}
    return StringUtils.format(MqttProperties.getClientId(),
            serialNumber + "_" + System.currentTimeMillis());
}

4.2 连接方法:双重检查锁保证线程安全

connect() 方法使用了经典的 Double-Check Locking 模式,防止多线程并发创建连接:

public MqttClient connect(String clientId, MqttCallback mqttCallback,
                           MqttConnectOptions mqttConnectOptions) {

    // 第一重检查:已经连着就不用再连了(无锁,快速路径)
    if (mqttClient != null && mqttClient.isConnected()) {
        return mqttClient;
    }

    // 加锁:排队等
    synchronized (LOCK_HELPER) {
        // 第二重检查:排到了再确认一次(防止别人已经把连接建好了)
        if (mqttClient == null || !mqttClient.isConnected()) {

            // 1. 创建 Paho 客户端实例
            mqttClient = new MqttClient(
                mqttProperties.getHostUrl(),   // Broker 地址
                clientId,                       // 唯一标识
                new MemoryPersistence()         // 消息暂存内存
            );

            // 2. 注册回调处理器
            mqttClient.setCallback(
                Objects.isNull(mqttCallback) ? mqttCallbackImpl : mqttCallback
            );

            // 3. 建立 TCP 连接
            mqttClient.connect(
                Objects.isNull(mqttConnectOptions)
                    ? getMqttConnectOptions() : mqttConnectOptions
            );

            if (mqttClient.isConnected()) {
                log.info("已成功连接到 MQTT 服务器,客户端ID: {}", clientId);
            }
        }
    }
    return mqttClient;
}

为什么需要双重检查? 假设两个线程同时通过了第一重检查(那时还没连接),然后排队等锁。第一个线程进去创建了连接,第二个线程进去如果不检查,就会再创建一个连接覆盖掉第一个——这就出 bug 了。

MemoryPersistence 的选择:QoS 1/2 的消息在确认前临时存在内存里。程序崩溃时这些消息会丢失,但物联网场景下传感器持续上报,偶尔丢一两条无关紧要,没必要做文件持久化增加磁盘 IO。


五、回调机制:Paho 事件的第一道防线

5.1 四个回调方法

MqttCallbackExtended 接口定义了 4 个方法,Paho 在不同事件发生时会调用对应的方法:

@Component
public class MqttCallbackImpl implements MqttCallbackExtended {

    private final MqttIngressOrchestrator mqttIngressOrchestrator;

    // ① 收到消息时调用 ← 最核心
    @Override
    public void messageArrived(String topic, MqttMessage mqttMessage) throws Exception {
        // 【关键】过滤 retained 消息
        if (mqttMessage.isRetained()) {
            log.debug("跳过 retained 消息, topic={}", topic);
            return;
        }
        mqttIngressOrchestrator.onMessage(topic, mqttMessage);
    }

    // ② 连接断开时调用
    @Override
    public void connectionLost(Throwable throwable) {
        mqttIngressOrchestrator.onConnectionLost(throwable);
    }

    // ③ 连接完成时调用(首次连接 + 重连成功都会调用)
    @Override
    public void connectComplete(boolean reconnect, String serverURI) {
        mqttIngressOrchestrator.onConnectComplete(reconnect, serverURI);
    }

    // ④ 消息发布完成时调用
    @Override
    public void deliveryComplete(IMqttDeliveryToken token) {
        // 日志记录,省略...
    }
}

5.2 为什么要过滤 Retained 消息?(重点)

这是整个回调层最重要的设计决策。

Broker 会为每个 Topic 保留最后一条标记为 retained 的消息。当客户端(重新)订阅该 Topic 时,Broker 会立刻把这条消息推送过来——哪怕它可能是几小时前的旧数据。

不过滤的后果:假设一台传感器已经断电 2 小时,你的服务器重启后订阅了它的 Topic,Broker 会把 2 小时前的最后一条 retained 消息推给你。如果不过滤,系统会把它当作"实时上报"处理,导致一台断电的设备被错误标记为"在线"。

时间线:
  10:00  传感器A 发送一条 retained 消息
  10:05  传感器A 断电
  14:00  服务器重启,订阅 Topic
  14:00  Broker 推送 10:00 的 retained 消息
  → 不过滤 → 传感器A 被错误标记为"在线" ✗
  → 过滤掉 → 正确处理 ✓

5.3 回调层的设计原则:只做适配,不做业务

你会发现每个回调方法都只有一两行代码,全部是委托给 MqttIngressOrchestrator。这不是偷懒,而是单一职责原则的体现:

  • MqttCallbackImpl 的职责是"适配 Paho 的回调接口"
  • 所有业务逻辑(重连策略、消息记录、订阅管理)都在 MqttIngressOrchestrator

这样做的好处是:如果以后换 MQTT 客户端库(比如从 Paho 换成 HiveMQ),只需要重写这个回调类,业务逻辑完全不用动。


六、接入编排器:MQTT 层的真正"大脑"

MqttIngressOrchestrator 承担了 MQTT 接入层的核心职责,我们逐个拆解。

6.1 订阅管理:支持动态增删

// 默认订阅的 Topic
private static final String[] DEFAULT_SUBSCRIBE_TOPICS =
    new String[]{"client/device/#", "client/alarm/#"};

// 当前已订阅的 Topic 集合
private final Set<String> subscribeTopics = new LinkedHashSet<>();

// 统一订阅(重连后调用)
public void subscribeAll() {
    synchronized (subscribeTopics) {
        for (String topic : subscribeTopics) {
            mqttSubscriber.subscribe(topic, 0);
        }
    }
}

// 动态新增 Topic
public boolean subscribeTopic(String topic, int qos) {
    synchronized (subscribeTopics) {
        subscribeTopics.add(topic.trim());
    }
    mqttSubscriber.subscribe(topic.trim(), qos);
    return true;
}

为什么要维护 subscribeTopics 集合? 因为 MQTT 的订阅关系绑定在连接上,重连后订阅就丢了。subscribeAll()connectComplete() 回调中被调用,用来重新订阅所有 Topic。没有这个集合,重连后就不知道该订什么了。

6.2 双层重连策略(面试高频考点)

private static final int MAX_RECONNECT_ATTEMPTS = 5;
private static final long RECONNECT_INTERVAL = 5000; // 毫秒
private int reconnectAttempts = 0;

public void reconnectWithPolicy() {
    Boolean shouldReconnect = mqttProperties.getReconnect() &&
            (Objects.isNull(MqttClientUtils.getMqttClient())
             || !MqttClientUtils.getMqttClient().isConnected());

    if (shouldReconnect && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
        reconnectAttempts++;
        log.info("正在进行第{}次重连尝试...", reconnectAttempts);
        try {
            Thread.sleep(RECONNECT_INTERVAL);
            MqttClientUtils.reconnect();
            if (MqttClientUtils.getMqttClient().isConnected()) {
                log.info("重连成功");
                reconnectAttempts = 0; // 重置计数器
            }
        } catch (Exception e) {
            log.error("重连异常:{}", e.getMessage(), e);
        }
    } else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
        log.error("已达到最大重连次数, 停止重连");
    }
}

双层重连的完整流程

连接断开
  │
  ├── 第一层:Paho automaticReconnect(指数退避,1s → 120s)
  │     ├── 成功 → connectComplete(true) → subscribeAll() → 恢复正常
  │     └── 失败 ↓
  │
  └── 第二层:应用层 reconnectWithPolicy()
        ├── 固定 5 秒间隔,最多 5 次
        ├── 成功 → reconnectAttempts 重置 → 恢复正常
        └── 5 次都失败 → 停止重连,打日志告警

6.3 消息记录:Deque 滑动窗口

系统维护了一个最近 50 条消息的滑动窗口,供前端管理页面展示"最近消息":

private static final int MAX_RECENT_MESSAGES = 50;
private final Deque<Map<String, Object>> recentMessages = new ArrayDeque<>();

private void recordRecentMessage(String topic, String payload, int qos) {
    Map<String, Object> item = new LinkedHashMap<>();
    item.put("time", LocalDateTime.now().format(TIME_FORMATTER));
    item.put("topic", topic);
    item.put("qos", qos);
    item.put("payload", payload);

    synchronized (recentMessages) {
        recentMessages.addFirst(item);       // 新消息插到队头
        while (recentMessages.size() > MAX_RECENT_MESSAGES) {
            recentMessages.removeLast();     // 超过 50 条就从队尾移除最旧的
        }
    }
}

效果就像一个滑动窗口:

收到第 1 条 → [消息1]
收到第 2 条 → [消息2, 消息1]
...
收到第 50 条 → [消息50, ..., 消息1]
收到第 51 条 → [消息51, ..., 消息2]  ← 消息1 被挤出窗口

6.4 消息计数:AtomicLong + 跨天重置

private final AtomicLong todayMessageCount = new AtomicLong(0);
private String lastResetDate = "";

private void incrementTodayMessageCount() {
    String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
    if (!today.equals(lastResetDate)) {
        synchronized (this) {  // 双重检查,防止多线程重复重置
            if (!today.equals(lastResetDate)) {
                todayMessageCount.set(0);
                lastResetDate = today;
            }
        }
    }
    todayMessageCount.incrementAndGet();
}

AtomicLong 而不是普通 int,是因为多个线程可能同时调用 incrementAndGet()AtomicLong 内部用 CAS 操作保证线程安全,性能比 synchronized 好得多。


七、Spring Event 解耦:通信模块与业务模块的分界线

这是整个 MQTT 接入层最精妙的设计——通过 Spring 的事件机制,让通信模块和数据处理模块完全解耦。

7.1 事件对象

@Data
public class MqttMessageEvent {
    private String topic;
    private String payload;
    private int qos;

    public MqttMessageEvent(String topic, String payload, int qos) {
        this.topic = topic;
        this.payload = payload;
        this.qos = qos;
    }
}

7.2 消息处理:从 MQTT 消息到 Spring 事件

@Service
public class MqttMessageHandler {

    private final ApplicationEventPublisher eventPublisher;

    public MqttMessageHandler(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    public void handleMessage(String topic, String payload, int qos) {
        MqttMessageEvent event = new MqttMessageEvent(topic, payload, qos);
        eventPublisher.publishEvent(event);  // 发布事件
    }
}

只有 40 行代码,但做了一件非常重要的事:解耦

如果不用事件机制,MqttCallbackImpl 就要直接调用 Pipeline 编排器的代码,这意味着通信模块要依赖工作流模块的代码。用了 Spring Event 之后,通信模块只需要发布一个事件,完全不关心谁来处理。

7.3 事件监听:Pipeline 的入口

@Component
public class DeviceDataEventListener {

    private final DeviceReportWorkflowOrchestrator orchestrator;

    @EventListener
    public void handleDeviceData(MqttMessageEvent event) {
        if (Objects.isNull(event) || StringUtils.isBlank(event.getPayload())) {
            return;
        }
        orchestrator.executeFromMqtt(
            event.getTopic(), event.getPayload(), event.getQos());
    }
}

7.4 解耦分界线图示

┌──────────────────────────────────────────────┐
│  通信模块(XYIoT-communication)               │
│                                              │
│  MqttCallbackImpl                            │
│      → MqttIngressOrchestrator               │
│          → MqttMessageHandler                │
│              → eventPublisher.publishEvent() │
└──────────────────┬───────────────────────────┘
                   │
          ═══ 解耦分界线 ═══
          通过 Spring Event 通信
          两边互不认识
                   │
┌──────────────────▼───────────────────────────┐
│  工作流模块(XYIoT-workflow)                  │
│                                              │
│  DeviceDataEventListener                     │
│      → DeviceReportWorkflowOrchestrator       │
│          → 9 阶段 Pipeline 开始处理...         │
└──────────────────────────────────────────────┘

八、完整消息链路一图流

把所有内容串起来,从传感器上报到 Pipeline 开始处理的完整链路:

传感器上报 MQTT 消息
    │
    ▼
MQTT Broker 推送消息
    │
    ▼
① MqttCallbackImpl.messageArrived()
    │ 过滤 retained 消息
    │ 委托给编排器
    ▼
② MqttIngressOrchestrator.onMessage()
    │ 消息计数 +1
    │ 记录到 Deque(最近 50 条)
    │ 委托给消息处理器
    ▼
③ MqttMessageHandler.handleMessage()
    │ 包装成 MqttMessageEvent
    │ ApplicationEventPublisher.publishEvent()
    │
    ══════ 解耦分界线 ══════
    │
    ▼
④ DeviceDataEventListener(@EventListener)
    │ 监听到 MqttMessageEvent
    │ 调用 Pipeline 编排器
    ▼
⑤ DeviceReportWorkflowOrchestrator.executeFromMqtt()
    │ 开始 9 阶段 Pipeline 处理...
    ▼
  (后续Pipeline处理,此处不展开)

九、设计亮点总结

回顾整个 MQTT 接入层的设计,有几个值得学习的亮点:

1. 分层解耦:回调层只做 Paho 接口适配,编排层负责业务逻辑,事件桥梁层实现模块间解耦。每层职责清晰,互不干扰。

2. 防御性过滤:在数据入口处(messageArrived)就过滤掉 retained 历史消息,避免后续所有环节被脏数据污染。

3. 双层重连:Paho 内置的指数退避重连作为主力,应用层固定间隔重连作为兜底,确保在各种异常场景下都能最大程度恢复连接。

4. 运行时可观测:Deque 滑动窗口记录最近 50 条消息原文,AtomicLong 统计今日消息量,方便运维排查和监控。

5. Spring Event 解耦:通信模块和工作流模块通过事件机制完全解耦,两个模块互不依赖,可以独立开发、测试和部署。


十、面试高频问答

Q1:说说你项目中 MQTT 消息从设备到服务器的完整链路?

设备通过 MQTT 协议将数据发布到 Broker 的指定 Topic。我们的服务使用 Eclipse Paho 客户端订阅这些 Topic。消息到达时,MqttCallbackImplmessageArrived 回调被触发,首先过滤掉 retained 历史消息(避免离线设备被误判为在线),然后委托给 MqttIngressOrchestrator 编排器。编排器负责消息记录(Deque 滑动窗口保留最近 50 条)和消息计数(AtomicLong 统计今日消息量)。之后消息被传递给 MqttMessageHandler,它把 MQTT 消息转化为 Spring ApplicationEvent 发布出去,实现了通信模块和数据处理模块的解耦。最终,DeviceDataEventListener 通过 @EventListener 监听到事件,将数据送入 Pipeline 处理。

Q2:为什么要过滤 retained 消息?

Broker 在客户端重新连接并订阅时,会回放每个 Topic 上最后一条 retained 消息。这些消息可能是很久以前的历史数据。如果不过滤,一条断电设备的历史消息会被当作实时上报处理,导致该设备被错误标记为"在线",影响设备状态判断的准确性。

Q3:你们的双层重连策略是怎么设计的?

第一层是 Paho 客户端自带的 automaticReconnect,采用指数退避策略(间隔从 1 秒增长到 120 秒)自动重连。如果 Paho 重连也失败了(比如 Broker 长时间宕机),第二层应用层兜底机制会介入,以固定 5 秒间隔最多重试 5 次。重连成功后会触发 connectComplete 回调,统一执行 subscribeAll 重新订阅所有 Topic,因为 MQTT 的订阅关系绑定在连接上,重连后需要重新订阅。

Q4:为什么用 Spring Event 而不是直接方法调用?

主要是为了模块解耦。通信模块(MQTT 接入)和工作流模块(数据处理)通过 Spring Event 通信,两个模块互不依赖。这样通信模块可以独立开发和测试,未来如果要替换 MQTT 客户端库或者增加其他数据源(比如 TCP Socket),只需要发布同样的事件即可,Pipeline 完全不用改。


附:

  1. MQTT 的 QoS 0/1/2 分别代表什么含义?物联网场景为什么通常选 QoS 1?
  2. cleanSession=truefalse 的区别是什么?
  3. 为什么每个 MQTT 客户端的 clientId 必须唯一?
  4. 为什么要过滤 retained 消息?不过滤会导致什么具体问题?
  5. connect() 方法中为什么要用双重检查锁?去掉第二重检查会怎样?
  6. 为什么重连后需要重新订阅 Topic?
  7. subscribeTopics 集合的作用是什么?
  8. 双层重连的两层分别是什么?各自的重连策略有什么不同?
  9. recentMessages 为什么用 DequeaddFirst + removeLast 实现了什么效果?
  10. Spring Event 在这个架构中起到了什么作用?如果不用会怎样?

本文源码基于真实工业物联网项目,使用 Eclipse Paho v3 + Spring Boot 2.x 实现。
如果觉得有帮助,欢迎点赞收藏,后续还会更新 Pipeline 编排引擎、Redis 双键 TTL 离线检测等模块的深度解析。

更多推荐