凌晨一点,车间数据断了:这一年我把物联网平台的驱动全拆成独立进程,然后开源了
一、从一个报障电话说起
凌晨一点,手机在床头震。客户车间的值班员声音发闷:"大屏上十几个点位全断了,数据不刷新了。"
我爬起来远程连上去看:平台进程活着,页面也打得开,就是采集全停了。翻日志翻到两点多,定位到了原因------协议驱动里一处类加载的问题在慢慢泄漏内存,把整个平台的堆一点点吃光了。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;
}
}
第三步,写个启动类,跑起来。
注册、心跳、遗嘱、配置拉取、采集调度、通道锁,这些全部由底座托管,一行都不用写。你只实现"怎么连接、怎么读、怎么写"这三件事,剩下的生命周期平台替你管。
九、仓库、演示与部署
地址:
- GitHub:github.com/unihh-iot/U...
- Gitee:gitee.com/giteeRepo/u...
- 在线演示:demo.unihh.com(账号
admin/unihhiot) - 默认账号:平台
admin/unihhiot,EMQX Dashboard(18083 端口)admin/public,MQTT 总线鉴权unihh/unihh
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 都欢迎------尤其欢迎带着现场问题来的那种。