开源一套工业物联网平台:驱动崩了不影响别的协议,64MB 堆跑边缘网关

凌晨一点,车间数据断了:这一年我把物联网平台的驱动全拆成独立进程,然后开源了

一、从一个报障电话说起

凌晨一点,手机在床头震。客户车间的值班员声音发闷:"大屏上十几个点位全断了,数据不刷新了。"

我爬起来远程连上去看:平台进程活着,页面也打得开,就是采集全停了。翻日志翻到两点多,定位到了原因------协议驱动里一处类加载的问题在慢慢泄漏内存,把整个平台的堆一点点吃光了。Modbus 的数据本来还是好的,最后也被一起拖死。

那个晚上我想明白一件事:把所有协议驱动塞在一个单体进程里,靠 SPI 动态加载来做"插件化",这件事本身就不成立。 类隔离做不干净,一个协议出问题,全体陪葬。驱动越多,这事越无解。

第二天我没有先去修那个 bug,而是把架构图撕了重画。重画出来的东西,就是今天要聊的 UNIHH-IOT------一套前后端完整的开源物联网数据采集与管理平台,最近我刚把它整理出来。

二、第一个决定:驱动不是插件,是独立进程

新架构里没有"插件"这个概念了。每个协议就是一个独立的 Java 进程:

text 复制代码
unihh-driver-modbus   Modbus TCP/RTU   端口 28011
unihh-driver-s7       西门子 S7 系列    端口 28012
unihh-driver-opcua    OPC UA           端口 28013

平台本体反而很简单,就三个服务:统一网关(28000)、管理中心(28001)、数据中心(28002)。

驱动和平台之间不直接调用,全部通过一条 EMQX MQTT 总线说话。驱动进程一启动,先挂遗嘱,再广播注册,然后每 5 秒上报一次心跳:

text 复制代码
iot/driver/register                     驱动上线,广播自己的 HTTP 地址和元数据
iot/driver/heartbeat                    周期心跳,附带活跃通道指标
iot/driver/{driverCode}/status          上线/下线状态(遗嘱也走这条,retained)
iot/driver/{driverCode}/data            点位数据上报
iot/driver/{driverCode}/cmd             反向控制指令
iot/driver/{driverCode}/device/config   设备配置定向下发

这么拆之后,最直接的变化是:某个驱动崩了,重启它就行,平台和其它协议完全无感。 你在网页上新增一台设备,配置通过主题热下发到对应驱动,不用重启任何进程。MCU 时代那句老话在这里依然好使------模块之间只靠消息通信,系统才经得起折腾。

驱动在平台上的样子是一张张卡片,带状态灯和心跳自检按钮:

三、一条 TCP 连接就是一问一答:通道锁的由来

拆完架构,以为能消停一阵,结果被两笔撞车的请求教育了。

现场设备都走以太网:Modbus TCP 的 502 端口,西门子 S7 的 102 端口,一台设备一条 TCP 会话。平台这边,轮询引擎在按周期采集,页面上运营人员随时可能手动反写一个设定值。某天这两笔请求几乎同时发到了同一条连接上------很多设备和网关是严格的一问一答处理模型,两笔请求塞进去,应答就可能错序、串号:变频器的转速应答被当成温度解析出来,数据莫名其妙地跳。

这种问题在测试环境很难复现,因为测试时没人和你抢连接;到了现场,轮询和反控是常态并存的。

最后的解法落在驱动底座里:每条设备通道一把锁,读写全部排队。

java 复制代码
public ReentrantLock getChannelLock(DeviceConfig config) {
    return channelLocks.computeIfAbsent(key, k -> new ReentrantLock(true));
}

注意用的是公平锁。因为排队的有两种人:轮询引擎的周期采集,和前端下发的反向控制。公平锁保证先到先得,不至于采集把反控指令饿死------现场改个设定值要点好几下才生效,操作工会直接打电话给你。

这个问题不新鲜,但只有在现场看着数据跳起来,你才会真正给它排上优先级。

四、驱动断电了,平台怎么知道?

分布式带来另一个问题:驱动成了独立进程,它死在十几公里外的机房里,平台怎么知道?

靠运维盯着是不现实的。这里用了两层机制:

第一层,MQTT 遗嘱(LWT)。 驱动连上 EMQX 时预先注册一条遗嘱消息,retain 住。进程正常下线会主动发 offline;但如果是断电、kill -9 这种"没有告别"的死法,TCP 连接悄悄断掉,就由 Broker 代发遗嘱。平台收到遗嘱,把驱动置离线,并级联把下挂设备全部标记离线。retained 保证晚启动的订阅方也能拿到最后的状态,不会出现"重启平台之后大家都显示在线"的假象。

第二层,看门狗兜底。 心跳超过 15 秒没来,直接置离线。万一遗嘱因为网络抖动丢了,这层还能兜住。

工业现场对"状态可信"的要求比互联网场景苛刻得多------大屏上这台泵明明显示在线,人跑过去发现早停了,这种事出一次,客户就不信你的系统了。

五、数据存哪:不押注任何一个数据库

采集上来的数据往哪放,是每个物联网平台都绕不开的问题。我的做法是把存储做成接口加路由,不替使用者做决定:

  • TDengine 存时序数据,专业的活交给专业的库;
  • MySQL 存业务配置和台账;
  • Redis 做点位实时值镜像,大屏要的"当前值"直接查缓存,不碰时序库;
  • 本地内存环形缓冲兜底,数据库抖一下不至于直接丢数据。

想接 InfluxDB、ClickHouse 或者别什么新库,实现一个引擎类注册进去就行,业务层一行不用改。另外单条保存和批量保存是两个独立接口:周期采集的高频数据走批量削峰,告警这种关键消息走单条立刻落库,不攒批。

六、界面长什么样

平台是 Vue 3 + Element Plus 写的前端,功能上该有的都有。挑几张图看看。

登录进来首先是监控大盘,设备在线率和点位数据的总览:

设备台账,连接参数、在线状态、下挂点位都在这一层管理:

物模型是平台的核心抽象,属性、服务、事件三要素,物理点位和模型测点在这里做映射绑定。这一层做好,上层的规则和告警才写得动:

实时数采监测,点位值、品质戳、时间戳直接看:

规则引擎做联动计算和告警判定,采集数据经过清洗校准后在这里分流:

告警事件有完整的记录和处理流转,谁处理的、什么时候处理的都留痕:

此外还有完整的 RBAC(用户/角色/菜单)、操作审计日志、数据字典这些管理后台的基本盘,篇幅原因不逐个贴了。

七、64MB 堆,跑在工控机上

做边缘部署的时候,资源是真的紧。工控机、树莓派这种环境,你没法给它一个 2G 堆的 JVM。

所以编码层面就一直按小内存的习惯来:基本类型优先、对象复用、避免轮询路径上的临时对象。启动参数也给出了一套推荐配置:

bash 复制代码
# 核心业务服务(64MB~128MB 堆)
java -server -Xms64m -Xmx128m -XX:MaxMetaspaceSize=128m \
     -XX:+UseSerialGC -jar unihh-admin.jar

# 协议驱动(32MB~64MB 堆)
java -server -Xms32m -Xmx64m -XX:+UseSerialGC -jar unihh-driver-modbus.jar

小堆上 SerialGC 反而比 G1 稳,停顿短,行为可预期。另外整套平台没有引入注册中心和配置中心,服务地址就在 application.yml 里显式写死------在边缘环境里,少一个中间件就少一个故障点,java -jar 拷过去就能跑,现场工程师不需要懂微服务治理那套东西。

八、写一个新驱动,三步

平台已经内置了 Modbus、西门子 S7、OPC UA 三个驱动,但现场协议永远比驱动多。所以驱动底座的易用性是我最在意的部分------写一个新协议驱动,只需要三步。

第一步 ,引入 unihh-protocol 依赖:

xml 复制代码
<dependency>
    <groupId>com.unihh.iot</groupId>
    <artifactId>unihh-protocol</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

第二步 ,继承 AbstractDriverPlugin,只关心协议本身:

java 复制代码
@Component
@IoTDriver(code = "custom-protocol", name = "自定义协议驱动", version = "1.0.0")
public class CustomDriverPlugin extends AbstractDriverPlugin {

    @Override
    public boolean openConnection(DeviceConfig config) throws Exception {
        // 与设备握手建连
        return true;
    }

    @Override
    public ParsedPayload readPoints(DeviceConfig config,
                                    List<ThingModel.PropertySpec> points) throws Exception {
        ParsedPayload payload = new ParsedPayload();
        // 批量读点位: payload.addPoint(code, value, quality, timestamp)
        return payload;
    }

    @Override
    public boolean writePoint(DeviceConfig config, String pointCode,
                              String address, Object value, String dataType) throws Exception {
        // 反控:编码报文写下去
        return true;
    }
}

第三步,写个启动类,跑起来。

注册、心跳、遗嘱、配置拉取、采集调度、通道锁,这些全部由底座托管,一行都不用写。你只实现"怎么连接、怎么读、怎么写"这三件事,剩下的生命周期平台替你管。

九、仓库、演示与部署

地址:

Docker 镜像部署(推荐)

仓库里每个服务都带了 Dockerfile,配合根目录的一键脚本,整套平台可以直接以镜像方式跑起来:

bash 复制代码
# 打包全部微服务镜像(也可指定单模块,如 unihh-driver-modbus)
./build-docker.sh

# 一键起整套:MySQL 8 + EMQX + 网关 + 管理中心 + 数据中心 + 三个协议驱动
docker compose up -d

compose 里一共编排了 8 个容器:MySQL 挂了初始化脚本自动建库,EMQX 用的是预置好鉴权配置的专用镜像(registry.cn-shenzhen.aliyuncs.com/deversun/unihh-emqx),6 个应用服务走容器网络别名互相寻址,连接参数全部通过环境变量注入------所以整套东西依然不需要注册中心和配置中心。镜像仓库地址和版本号在 .env 里统一控制(DOCKER_REGISTRY / VERSION),要推自己的私有仓库改一行就行。Windows 环境用仓库里的 build-docker.bat,效果一样。

前端 npm run build 出 dist 交给 Nginx 托管,接口全部走网关 28000。

如果只是想单独拉个 EMQX 联调驱动,两条命令也够:

bash 复制代码
docker pull registry.cn-shenzhen.aliyuncs.com/deversun/unihh-emqx:v20261009
docker run -d --name unihh-emqx -p 1883:1883 -p 18083:18083 \
  registry.cn-shenzhen.aliyuncs.com/deversun/unihh-emqx:v20261009

裸机 JAR 部署

面向边缘工控机的场景,Maven 打包后逐个 java -jar 启动即可(推荐 JVM 参数见第七节),不依赖任何中间件治理组件。

技术栈一览:JDK 17、Spring Boot 2.7、EMQX 5.x、MySQL 8、Redis、TDengine 3.x,前端 Vue 3 + Vite。

十、写在最后:它还不完美

最后说点实话。

这套平台的 Modbus 和 S7 驱动在项目里跑得最久,被打磨得比较扎实;OPC UA 驱动还年轻,复杂场景下不敢说样样都稳。协议覆盖也远远谈不上全,比如 BACnet------贡献指南里的分支示例名都拿它当了例子,代码还没有。文档也在陆续补,有些设计当时没留字据,写起来得翻代码回忆当时的思路,进度快不起来。

但方向我是认的:驱动独立进程、总线托管生命周期、状态必须可信、资源占用必须抠。 这四条是从现场的一个个深夜换来的,后面不管加多少协议,都不打算动摇。

项目用 Apache 2.0 协议开源,仓库在 GitHub 和 Gitee,在线演示环境在 demo.unihh.com(账号 admin / unihhiot),可以直接点进去体验。如果你也在做工业数采、边缘网关,或者正被单体物联网平台的驱动加载问题折磨,欢迎拿去用、拿去改。提 Issue、提 PR 都欢迎------尤其欢迎带着现场问题来的那种。

相关推荐
MetWeave4 小时前
一朵玫瑰,装下一年的风(《风不是一个数》系列第 5 篇
前端·数据可视化
学长毕设4 小时前
基于SpringBoot的网上流浪狗救助捐赠平台-附源码
java·spring boot·后端
xiaoqiMikko4 小时前
Redis 实战(三):持久化出错 —— fork 失败、磁盘写满、AOF 膨胀与损坏,逐个复现(6.2.4)
redis·后端
废柴程序员_熊熊5 小时前
DeepSeek Harness 实测:两条命令装好,11 步写完 632 行代码,3 个测试全绿
后端
phil65 小时前
沙箱核心功能系列 05:沙箱内 Agent 与进程执行 API
后端
用户5947614425955 小时前
Windows 部署 Playwright MCP 供 linux Codex 远程操作 Chrome:403 Forbidden 与 0 Tools 问题排查
后端
deli0075 小时前
秒杀超卖实测:1000 个并发请求下 SQL 加锁前后差多少
前端
用户394995084635 小时前
Python 的哈希契约:为什么 list 不能当字典的键
后端
leo在掘金5 小时前
744B模型,32GB内存跑起来
后端·架构