首页
>
资源
>
用户案例

IoTDB-Link 转码服务:从 MQTT 报文到时序库写入

本文作者来自某能源行业,基于储能场景下 IoTDB 的实际项目实践,聚焦云端转码服务(Link Service)的核心实现,系统拆解 MQTT 数据接入、Base64 解码、zlib 解压、批量写入 IoTDB、Redis 类型缓存、告警分流及断线重连等关键技术链路,分享储能站海量时序数据高效、安全、稳定上云的工程实践。

120260703.webp

一、转码服务在链路中的定位

在储能站数据上云链路中,转码服务(Link Service)是云端接入层与时序存储层之间的桥梁。它负责将边缘网关(EdgeBox)通过 MQTT 上报的压缩报文,转换为 IoTDB 可识别的时序数据,并完成批量写入。

以某能源平台为例,单站点峰值写入可达 1.5 万点/秒,转码服务需要同时满足高吞吐、低延迟、高可用三个目标。其定位可概括为:

  • 协议转换:

    MQTT Payload → JSON 对象 → IoTDB 数据点

  • 数据路由:

    按场站、设备、测点组织时序路径

  • 类型推断:

    利用 Redis 缓存判断测点数据类型(INT32 / DOUBLE)

  • 告警分流:

    命中告警规则的数据点转发到告警 MQTT Topic

  • 异常自愈:

    MQTT 断线自动重连,失败时触发钉钉告警

220260703.webp

二、核心代码流程:从 MQTT 到 IoTDB

转码服务的入口是 MQTT 回调接口 UploadDataCallback,核心逻辑在 PubDataToIotDBServiceImpl 中实现。以下按数据流向逐步拆解。

Step 1 MQTT 订阅与回调入口

服务启动时向 Broker 订阅 Topic edgeData/{siteAbbr}.{SN},收到报文后触发 UploadDataCallback

public class UploadDataCallback implements MqttCallback {

    private final PubDataToIotDBServiceImpl iotdbService;
    private final MqttClient mqttClient;
    private final String topic;
    private final DingTalkRobot dingTalkRobot;
    private final AtomicInteger reconnectCount = new AtomicInteger(0);
    // Lombok @Slf4j 自动生成 log 字段,或手动声明:
    // private static final Logger log = LoggerFactory.getLogger(UploadDataCallbackV2.class);

    public UploadDataCallback(MqttClient client, String topic,
                                PubDataToIotDBServiceImpl service) {
        this.mqttClient = client;
        this.topic = topic;
        this.iotdbService = service;
    }

    @Override
    public void messageArrived(String topic, MqttMessage message) {
        try {
            // 1. 提取 siteAbbr 与 SN
            String[] parts = topic.replace("edgeData/", "").split("\\.");
            if (parts.length < 2) {
                log.error("Invalid topic format, expected edgeData/{siteAbbr}.{SN}, got={}", topic);
                return;
            }
            String siteAbbr = parts[0];  // 如 Station_HZ
            String sn = parts[1];          // 如 EB20240001

            // 2. 进入转码与写入流程
            iotdbService.process(message.getPayload(), siteAbbr, sn);

            // 3. 重连计数器归零
            reconnectCount.set(0);
        } catch (Exception e) {
            log.error("Process MQTT message failed, topic={}", topic, e);
        }
    }

    @Override
    public void connectionLost(Throwable cause) {
        log.error("MQTT connection lost: {}", cause.getMessage());
        doReconnect();
    }

    private void doReconnect() {
        int count = reconnectCount.incrementAndGet();
        if (count > 3) {
            alertDingTalk("MQTT 断线超过 3 次,请检查 Broker 与网络状态");
            return;
        }
        try {
            Thread.sleep(5000L * count);
            mqttClient.reconnect();
            log.info("MQTT reconnected, attempt={}", count);
        } catch (Exception e) {
            log.error("MQTT reconnect failed, attempt={}", count, e);
        }
    }

    private void alertDingTalk(String content) {
        // 调用钉钉机器人 Webhook 发送告警
        dingTalkRobot.send(new MarkdownMessage("IoTDB-Link 告警", content));
    }
}

建议:重连间隔按指数退避(5s / 10s / 15s),避免对 Broker 造成重连风暴。超过 3 次后触发钉钉告警,人工介入排查。

Step 2 解密、解压与 JSON 解析

Payload 为 Base64 编码的 zlib 压缩数据,需先解码再解压,最后解析为 JSON 数组:

@Service
public class PubDataToIotDBServiceImpl {

    private static final int BATCH_SIZE = 10000;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private SessionPool sessionPool;
    @Autowired
    private MqttClient alarmMqttClient;

    public void process(byte[] payload, String siteAbbr, String sn) {
        // 1. Base64 解码
        byte[] compressed = Base64.getDecoder().decode(payload);

        // 2. zlib 解压
        String json = zlibDecompress(compressed);

        // 3. 解析 JSON 数组
        List<DeviceData> dataList = JSON.parseArray(json, DeviceData.class);

        // 4. 加载该站点告警点集合(一次 MQTT 消息仅查一次)
        Set<String> alarmPoints = loadAlarmPoints(siteAbbr);

        // 5. 按设备分组处理
        Map<String, List<DeviceData>> byDevice = dataList.stream()
            .collect(Collectors.groupingBy(DeviceData::getObject));

        for (Map.Entry<String, List<DeviceData>> entry : byDevice.entrySet()) {
            String deviceId = entry.getKey();
            List<DeviceData> deviceData = entry.getValue();
            writeToIotDB(siteAbbr, sn, deviceId, deviceData, alarmPoints);
        }
    }

    private String zlibDecompress(byte[] compressed) {
        try (ByteArrayInputStream bis = new ByteArrayInputStream(compressed);
             InflaterInputStream iis = new InflaterInputStream(bis)) {
            return new String(iis.readAllBytes(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new RuntimeException("zlib decompress failed", e);
        }
    }
}

注意:Base64 解码失败通常是 Payload 被截断或编码错误,应记录原始报文长度并告警,便于排查 EdgeBox 端问题。

三、测点路径组装规则

IoTDB 采用树形时间序列路径,转码服务需要将设备编码与测点编号映射为规范的路径。本系统的路径规则为:

root.{siteAbbr}.{SN}_{deviceId}.{measurement}

其中各段含义如下:

320260703.webp

示例:BMS01 设备的电池簇电压测点 YC_001,完整路径为:

root.Station_HZ.EB20240001_BMS01.YC_001

经验:将 SN 与 deviceId 组合为中间节点,既能保证同一站点下不同网关的数据隔离,又能避免 deviceId 冲突。下划线连接是约定,查询时需注意转义。

四、批量写入:积累与触发策略

单条写入 IoTDB 的 RTT 开销不可忽视。转码服务采用「积累批次」策略:单设备数据积累到 10,000 点,或当前消息处理完毕时,触发一次批量写入。

private void writeToIotDB(String siteAbbr, String sn,
                            String deviceId, List<DeviceData> dataList,
                            Set<String> alarmPoints) {
    List<String> paths = new ArrayList<>();
    List<Long> times = new ArrayList<>();
    List<TSDataType> types = new ArrayList<>();
    List<Object> values = new ArrayList<>();

    for (DeviceData data : dataList) {
        long timestamp = data.getTimestamp();
        for (Map.Entry<String, Object> metric : data.getMetrics().entrySet()) {
            String pointCode = metric.getKey();
            Object pointValue = metric.getValue();

            // 组装完整路径
            String path = String.format("root.%s.%s_%s.%s",
                siteAbbr, sn, deviceId, pointCode);
            paths.add(path);
            times.add(timestamp);

            // 类型推断
            TSDataType dataType = resolveDataType(siteAbbr, pointCode);
            types.add(dataType);
            values.add(castValue(pointValue, dataType));

            // 告警点过滤:命中则转发到告警 MQTT
            if (alarmPoints.contains(pointCode)) {
                forwardAlarm(siteAbbr, sn, deviceId, pointCode, timestamp, pointValue);
            }
        }

        // 达到批次阈值即写入
        if (paths.size() >= BATCH_SIZE) {
            flush(paths, times, types, values);
            paths.clear();
            times.clear();
            types.clear();
            values.clear();
        }
    }

    // 消息末尾兜底写入
    if (!paths.isEmpty()) {
        flush(paths, times, types, values);
    }
}

private void flush(List<String> paths, List<Long> times,
                   List<TSDataType> types, List<Object> values) {
    try {
        sessionPool.insertRecords(paths, times, types, values);
    } catch (StatementExecutionException e) {
        log.error("IoTDB insertRecords failed, batchSize={}", paths.size(), e);
        // 可扩展:失败写入死信队列,供后续补偿
    }
}

建议:BATCH_SIZE 可根据单消息测点数调整。若单消息通常不足 1 万点,可将阈值降低至 5,000,或按时间窗口(如 100ms)强制 flush,避免延迟累积。

五、类型缓存:Redis 加速类型推断

IoTDB 写入时需要显式指定数据类型(TSDataType)。如果每个测点都查库或查配置文件,性能将成为瓶颈。转码服务使用 Redis 缓存站点测点类型映射:

private TSDataType resolveDataType(String siteAbbr, String pointCode) {
    String cacheKey = "site:dataType:" + siteAbbr;
    String typeStr = redisTemplate.opsForHash().get(cacheKey, pointCode);

    if ("INT32".equalsIgnoreCase(typeStr)) {
        return TSDataType.INT32;
    } else if ("INT64".equalsIgnoreCase(typeStr)) {
        return TSDataType.INT64;
    } else if ("FLOAT".equalsIgnoreCase(typeStr)) {
        return TSDataType.FLOAT;
    } else if ("DOUBLE".equalsIgnoreCase(typeStr)) {
        return TSDataType.DOUBLE;
    } else if ("BOOLEAN".equalsIgnoreCase(typeStr)) {
        return TSDataType.BOOLEAN;
    }

    // 缓存未命中:兜底为 DOUBLE
    return TSDataType.DOUBLE;
}

private Object castValue(Object raw, TSDataType type) {
    if (raw == null) {
        return null;
    }
    switch (type) {
        case INT32:
            if (raw instanceof Integer) return raw;
            return Integer.parseInt(String.valueOf(raw));
        case INT64:
            if (raw instanceof Long) return raw;
            return Long.parseLong(String.valueOf(raw));
        case FLOAT:
            if (raw instanceof Float) return raw;
            return Float.parseFloat(String.valueOf(raw));
        case DOUBLE:
            if (raw instanceof Double) return raw;
            return Double.parseDouble(String.valueOf(raw));
        case BOOLEAN:
            if (raw instanceof Boolean) return raw;
            return Boolean.parseBoolean(String.valueOf(raw));
        case TEXT:
            return String.valueOf(raw);
        default:
            return String.valueOf(raw);
    }
}

420260703.png

建议:告警 Topic 与数据 Topic 分离,避免告警被大数据量 Topic 的消费延迟拖累。告警中心可独立订阅 alarm/#,实现毫秒级响应。

七、稳定性设计:断线重连与告警

转码服务是数据上云的「咽喉」,其稳定性直接影响数据完整性。除了代码层面的异常捕获,还需在基础设施层面做好防护。

设计要点 三级稳定性保障

  • L1 客户端重连:

    MQTT 断线后自动重试,最多 3 次,间隔递增

  • L2 钉钉告警:

    超过 3 次重连失败,立即通知运维值班群

  • L3 进程保活:

    通过 Kubernetes LivenessProbe 或 Supervisor 确保进程存活

重连与告警的完整逻辑已在 UploadDataCallback 中展示,此处补充进程级保活配置示例:

# Kubernetes Deployment 片段
livenessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 60
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5

关键:若转码服务长时间不可用,EdgeBox 的本地缓冲将逐渐写满,最终可能丢失数据。因此重连告警的响应时间应控制在 5 分钟以内。

八、完整数据流回顾

以下是从 MQTT 报文到 IoTDB 写入的完整流程,展示各模块如何协作:

520260703.webp

九、写在最后 + 下一篇预告

转码服务的核心挑战不是「解析 JSON」,而是在高并发、多站点、多设备并发上报的场景下,保证解析、类型推断、批量写入的稳定性和时效性

本文总结的批量写入策略、Redis 类型缓存、告警点过滤与断线重连机制,均来自真实生产环境的落地经验,已做脱敏处理。实际部署时,建议根据场站规模调整:小型场站可降低 BATCH_SIZE 至 5,000,减少内存占用;大型场站需关注 Redis 缓存命中率与 IoTDB SessionPool 连接数,避免成为瓶颈。

下一篇我们将深入 IoTDB 内部,讲解测点树的设计与集群部署策略。

下一篇预告

IoTDB 测点树与集群设计:root.{site}.{device}.{point}
拆解 IoTDB 树形时间序列路径的设计原则,包括层级规划、节点命名、权限隔离与集群部署策略,附带 3 节点集群配置与性能调优建议。