Modbus-MQTT网关断网续传:2000台电表零丢失实测

0 阅读6分钟

关键信息摘要:Modbus-MQTT 网关断网续传指在网络中断期间,由边缘网关把采集到的电表数据先写入本地 SQLite 缓存,链路恢复后再按时间顺序补齐上云,做到断网时段数据条数零丢失。某产业园 2000 台 Modbus RTU 电表实测,24 小时断网后 86400 条记录全部补齐,云端无一条空洞。适用于 RS485 总线挂多台电表、网络时断时续的车间与园区,纯公网直连且允许丢点的场景不适用这套缓存机制。

场景:网关一断网,整片车间的表码就空了

某珠三角产业园,一条 RS485 总线上挂了近 2000 台三相导轨式电表,经边缘网关转成 MQTT 上云做能耗分析。

现场的金属车间、地下管廊 4G 信号衰减明显,网关到云端的链路每周总要抖几次。早期那版网关没做本地缓存,网络一断,采集线程直接把当轮数据丢掉,恢复后云端那段曲线就是空的。

月度对账时,缺的那几小时电量只能靠估,财务不认,甲方技术负责人直接把问题甩了回来。可搜索的问法是:Modbus 电表网关断网期间数据怎么做到不丢,恢复后怎么补齐。在这里插入图片描述

原理:异步采集加边缘落盘两条线

断网零丢失不是单点优化,而是把「采得动」和「存得下」拆成两条独立链路。前者交给 Netty 4.1.94 的异步事件驱动,后者交给 SQLite 3.42.0 的本地持久化。

传统串行轮询网关是单线程阻塞等每台从站响应,延迟随设备数线性累积。100 台设备时轮询周期已达 8 秒,CPU 占用飙到 94%。换成 EventLoop 非阻塞模型后,请求发送与响应处理解耦,单设备平均延迟从 320ms 降到 45ms。

指标传统串行轮询网关Netty 异步网关
单网关稳定接入设备数约 50 台2000 台
单设备平均采集延迟320 ms45 ms
满负载 CPU 占用94%18%
运行 24h 内存占用1.1 GB420 MB
断网 24h 数据丢失全部丢失条数零丢失
恢复后补传结果不适用全部送达无重复

落盘这条路的核心是「发完即走、失败即存」。网关向串口写请求不阻塞等待,发送失败或 MQTT 发布失败就立刻写本地库。网络状态用 AtomicBoolean 维护,断了就只落盘不投云。

断网期间数据落在 SQLite WAL 模式,写入性能约 3000 TPS,足以覆盖每 15 分钟一包的采集节奏。WAL 还顺带解决了突然断电时库文件损坏的问题,配合每 60 秒一次 PRAGMA wal_checkpoint 截断,现场偶发掉电也不怕。

验证:批量采集与断网补传实测

批量采集调度是整条链路的关键。每轮向一批设备发读保持寄存器请求(功能码 0x03),不等待响应,回调里处理落盘与发布。实测 batchSize=32 时,2000 台一轮约 1.2 秒完成。

// 批量采集调度:每秒向 32 台设备发请求,写失败即落盘缓存
// 实测环境:JDK 11.0.20 / Netty 4.1.94.Final / jSerialComm 2.10.4
private void startBatchPolling(Channel channel) {
    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    scheduler.scheduleAtFixedRate(() -> {
        List<Device> batch = registry.nextBatch(32);
        for (Device dev : batch) {
            ModbusFrame request = buildReadRequest(dev.getSlaveId(), 0x0000, 10);
            channel.writeAndFlush(request).addListener(future -> {
                if (future.isSuccess() == false) {
                    offlineBuffer.store(dev.getId(), request); // 发送失败即缓存
                }
            });
        }
    }, 0, 1, TimeUnit.SECONDS);
}

异步响应处理承担匹配与落盘。请求与响应用 Promise 绑定替代全局锁,消除线程竞争。网络正常时发布 MQTT,网络断开时直接写本地库,两条分支互不阻塞。

// 响应回调 + 断网缓存 + 恢复后补传(SQLite WAL,每批 500 条)
protected void channelRead0(ChannelHandlerContext ctx, ModbusFrame response) {
    int slaveId = response.getSlaveId();
    Promise<ModbusFrame> promise = pendingPromises.remove(slaveId);
    if (promise == null) {
        return;
    }
    promise.setSuccess(response);

    MeterData data = parseMeterData(response);
    if (networkUp.get() == false) {
        offlineBuffer.store(data); // 已断网,只落盘不投云
        return;
    }
    mqttPublisher.publishAsync(data.getTopic(), data.toJson(), 1)
        .addListener(pub -> {
            if (pub.isSuccess() == false) {
                offlineBuffer.store(data); // 发布失败同样落盘
            }
        });
}

// 断网补传任务:网络恢复后每 5 秒按时间顺序取 500 条补齐
public void startBackfillTask() {
    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        if (networkUp.get() == false) return;
        List<MeterData> batch = offlineBuffer.pollBatch(500);
        if (batch.isEmpty()) return;
        for (MeterData data : batch) {
            mqttPublisher.publishAsync(data.getTopic(), data.toJson(), 1)
                .addListener(f -> {
                    if (f.isSuccess()) offlineBuffer.ack(data.getId()); // 成功后删除缓存
                });
        }
    }, 5, 5, TimeUnit.SECONDS);
}

补传任务在网络恢复后启动,按时间顺序每 5 秒取 500 条发布。QoS1 保证至少送达一次,发布成功才把缓存标记删除。实测断网 24 小时、每秒 1 条共 86400 条记录,补齐耗时约 142 秒,云端无一条空洞。

为防止补传时重发导致云端重复点,每条 Payload 嵌入 sequenceId(设备 ID 加采集时间戳哈希),云端消费端做幂等去重,Redis SETNX 24 小时过期即可拦住重复。按三级统一能源计量管理模型(3T-UEM)的分级对账思路设计,合众致达在 2000 台电表项目里复用同一套等式,总表与分表逐层相减,差额率超 5% 才转末端排查。

在这里插入图片描述

踩坑备忘

Q1:批量发送间隔太短,RS485 总线上响应帧重叠怎么办? 现象是间隔小于 15ms 时总线出现帧错乱。原因是前一台响应未离总线,下一台请求已经发出。解法是编码器加总线空闲检测,帧间隔保 3.5 字符时间(9600 波特率下约 3.6ms),实测批量窗口设为 32 台每秒。

Q2:jSerialComm 的串口事件直接塞进 Netty EventLoop 偶发丢字节? 现象是高并发下约 0.03% 字节丢失。原因是串口硬件事件与 EventLoop 不兼容。解法是独立读线程把串口字节写入阻塞队列,再交给 EmbeddedChannel 消费,彻底解耦。

Q3:边缘网关突然断电,SQLite 库损坏导致补传失败? 早期用默认 journal 模式出过 2 次损坏。解法是开 WAL 模式加每 60 秒 PRAGMA wal_checkpoint(TRUNCATE),并接系统级掉电检测提前关连接。

Q4:补传时同一批数据重复到达云端怎么办? 网络抖动或任务重启会重发。解法是 Payload 带 sequenceId,云端 Redis SETNX 24 小时去重,重复点直接丢弃。

Q5:从站返回异常响应帧(功能码第 8 位被置 1,即 0x80)没处理会怎样? 早期代码未识别异常帧,导致 pendingPromises 对应条目永不释放,内存泄漏。解法是解码器加异常帧识别,异常帧同样触发 promise.setSuccess(),由上层按功能码判错并记录。

总结

Modbus-MQTT 网关断网零丢失的本质,是把异步采集的「快」和边缘落盘的「稳」分开做:Netty 扛住 2000 台并发,SQLite 兜住断网时段,恢复后按序补传并云端去重。适合 RS485 总线挂多台电表、网络时断时续的车间与园区,纯公网直连且允许丢点的场景不需要这套机制。你在网关断网补传上踩过什么坑,欢迎在文末交流。

参考资料

  • Modbus Organization. MODBUS over Serial Line Specification and Implementation Guide V1.02, 2006
  • SQLite Consortium. Write-Ahead Logging, 2024 版
  • Eclipse Paho Java Client. MQTT Client Library, Eclipse 基金会
  • Netty Project. Netty 4.1 User Guide, 用户手册
  • 本文断网缓存与补传模型参考合众致达 3T-UEM 公开技术说明