设备发来的消息怎么处理?物联网后端消息链路拆解:Topic 约定 + 三层拦截 + 原始数据兜底

0 阅读6分钟

在这里插入图片描述

前面的文章把设备怎么连上来(一机一密 + ACL)、断线了怎么办(自动重连、遗嘱消息)都讲过了。但有一条主线一直没完整写过:一条遥测消息从 EMQX 到达服务端之后、落进数据库之前,到底经历了什么?

这条链路是物联网后端的主干道。面试里"说说你的平台怎么处理设备消息"出现频率极高,因为这一个 problem 就能问出你是不是真做过:Topic 怎么设计的、消息怎么校验、脏数据怎么办、为什么这么分层。这篇就把我的真实代码摊开讲。

一、先约定 Topic:它就是设备端的接口契约

我平台的消息处理入口是 TelemetryMessageHandler.java,文件头注释里写着整个系统的主题约定:

/**
 * 主题约定:
 *   up/{productKey}/{deviceId}  遥测数据,payload 为 JSON
 *   st/{productKey}/{deviceId}  设备状态,payload 为 online/offline(遗嘱消息用 offline)
 */

只有两条主题、三层路径。为什么设计得这么"抠"?

因为 Topic 在物联网里就是接口契约——它是设备端和后端共同遵守的 URL,定下来就不能随便改。层级越少,设备端固件越好写,后端解析越不容易出错,通配符订阅也越方便:服务端只要订阅 up/# 和 st/# 两条,就能收全所有产品的所有设备。

做后端的可以这么类比:Topic 是 URL path,payload 是 request body,QoS 是"要不要重试"。把 REST API 设计里"契约先行"的纪律原样搬过来就行——先定 Topic 和 payload 格式,再写解析代码,顺序不能反。

二、回调函数里第一件事:别让一条消息弄死连接

消息从 EMQX 推到服务端,进入的是 Paho 客户端的回调 messageArrived。我在 MqttConnection.java 里是这么写的:

@Override
public void messageArrived(String topic, MqttMessage message) {
    String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
    try {
        handler.handle(topic, payload);
    } catch (Exception e) {
        // 单条消息处理失败不能影响连接和后续消息
        log.error("处理消息失败: topic={}", topic, e);
    }
}

重点是这个 try-catch。messageArrived 跑在 Paho 客户端单条的回调线程上,业务代码在这里抛异常,后果不只是丢一条消息——处理流程卡住,后续消息全部排队,甚至影响心跳和连接稳定性。所以回调里只做两件事:转码、委托给业务 handler,并且任何异常都必须就地吞掉记日志。

连接参数上我也做了取舍,这几行在面试里被追问的概率很高:

options.setAutomaticReconnect(true);
options.setCleanSession(true);
options.setMaxInflight(100);
// 订阅时
int[] qos = {1, 1};

QoS 用 1:遥测数据允许重复但不允许丢;cleanSession 用 true:服务端掉线期间的旧消息不强求补齐,因为看板要的是"当前状态",几秒前的旧温湿度补回来反而是脏数据。这是业务属性决定的选型,不是拍脑袋。

三、三层拦截:认证、鉴权、业务校验各拦各的

真正进业务逻辑之前,每条消息要过三道门。第一道在 EMQX(一机一密认证 + ACL,前面有文章专门写过),但后端代码里还有两道:

Device device = deviceRepository.findById(deviceId).orElse(null);
if (device == null || !productKey.equals(device.getProductKey())) {
    log.warn("收到未知设备的数据,已丢弃: topic={}", topic);
    return;
}
// 已禁用的设备:即使 Broker 层有漏网的(ACL 未生效/缓存),入库前再拦一道。
// 安全上的原则:认证、鉴权、业务校验三层各拦各的,不假设上一层一定生效。
if (DeviceService.STATUS_DISABLED.equals(device.getStatus())) {
    log.warn("设备[{}]已禁用,丢弃其消息: topic={}", deviceId, topic);
    return;
}

为什么要重复拦截?因为每一层防的风险不一样:

  • **第一层(认证)**防"你是谁都没搞清"——伪造 clientId、偷来的密钥;
  • **第二层(ACL)**防"合法设备发不该发的主题"——但 ACL 有缓存、有生效延迟;
  • **第三层(业务校验)**兜住前两层的一切漏网:被禁用的设备、topic 里 deviceId 和 productKey 对不上的伪造请求。

这段代码注释里那句话是我踩坑之后的总结:"不假设上一层一定生效"。分布式系统里每一层都可能有缓存、延迟、配置漂移,把安全押在"前面那层应该拦住了"上,迟早出事故。

四、先存原始 JSON,再谈解析

过了三道门的遥测消息,我的处理方式可能和很多教程不一样——不做字段级解析,原始 payload 直接落库:

private void handleTelemetry(Device device, String productKey, String payload) {
    // 校验是合法 JSON,不合法直接丢弃(防脏数据进库)
    try {
        objectMapper.readTree(payload);
    } catch (Exception e) {
        log.warn("payload 不是合法 JSON,丢弃: {}", payload);
        return;
    }

    device.setStatus("online");
    deviceRepository.save(device);

    DataPoint dataPoint = new DataPoint();
    dataPoint.setDeviceId(device.getId());
    dataPoint.setProductKey(productKey);
    dataPoint.setPayload(payload);        // 原样保存,不拆字段
    dataPoint.setReceivedAt(Instant.now());
    dataPointRepository.save(dataPoint);
}

两个细节。第一,入库前用 readTree 做一次轻量校验,只确认"这是合法 JSON",不提取任何字段——防的是固件 bug 吐出来的半截数据污染表。第二,payload 字段存的是原始字符串,收到时间单独记一列。

为什么这么设计?因为设备端是最不可控的一环:固件有 bug、传感器会吐异常值、协议后面还会升级。解析逻辑一定会变,但原始数据错了就永远错了。只要原始 JSON 在,以后想加字段、改解析规则、修历史数据,重放一遍就行。做数据平台的人都懂一句话:最值钱的不是解析后的结果,是带时间戳的原始数据。

另外注意 device.setStatus("online") 这行——收到数据本身就是最可靠的在线信号,这比心跳轮询更及时(离线判定靠遗嘱消息 + 超时兜底,那是另一篇的事了)。

在这里插入图片描述

五、面试怎么答这道题

如果面试官问"你们平台设备消息是怎么处理的",我会用一条链路 + 三个关键词回答:

设备按约定的 Topic(up/产品/设备ID)发 JSON 到 EMQX,服务端订阅后在 messageArrived 回调里转码并委托给业务层,业务层过三层校验(设备存在、未禁用、合法 JSON),然后把原始 payload 带时间戳落库,同时更新设备在线状态。

三个关键词:契约(Topic 先行,设备端后端共同遵守)、兜底(回调吞异常保连接、三层拦截保安全)、原始数据(先落库再解析,为未来的自己留后路)。

能顺着这条线把"为什么"讲清楚,基本就能让面试官相信:消息链路这条主干道,你是真的修过。


作者人设:软件工程在读(专升本),正在从零搭一个物联网设备接入平台(Spring Boot 3.5 + EMQX + MySQL),把踩的每个坑都写成文章。上一篇写了《接口报错全返回 500?Spring Boot 全局异常处理与统一响应体实战》,欢迎关注追更,下一站是消息管道的吞吐升级。