用 Go 写了一个单二进制的工业网关

为什么我用 Go 写了一个单二进制的工业网关

采集 Modbus / 西门子 S7 / 三菱 MC / 欧姆龙 FINS / 罗克韦尔 CIP / OPC UA,推送 MQTT / TDengine / InfluxDB,自带一个 Web 管理控制台——这样一个工业 IoT 网关,编译出来是一个 37MB 的可执行文件。拷到工控机双击就能跑,不用装数据库,不用装运行时,不用配 Nginx。

这篇文章不讲情怀,讲讲"单二进制"背后的三个技术决策、两条被压测推翻的直觉,以及这个方案真实的边界在哪。

gitee地址:https://gitee.com/wang4856304/iot-gateway(AGPL-3.0 开源)

github地址:https://github.com/Ellisata/iot-gateway(AGPL-3.0 开源)


一、工业现场的软件部署,比你想的原始

做过工业项目的同学都经历过这样的现场:

1、 车间里的工控机装的是 Windows 7,可能还是 32 位,系统补丁从没更新过;

2、 现场没有公网,或者有但没人敢给你开,Docker 拉镜像、npm install 全是奢望;

3、 操作这套系统的人是自动化工程师、电工,不是后端工程师,"装个 Node.js 再 npm start"对他们就是天书;

4、 设备 IP 是 192.168.0.x 这种子网,你的笔记本接上去,DHCP 都不一定分得出来。

在这种环境下,部署方案的复杂度和它的存活率成反比。Java 方案要先解释 JRE 版本冲突;Python 方案要先和 pip 源搏斗;"标准"的微服务架构意味着 MySQL、Redis、Nginx 一样都不能少——而现场真正需要的是:拷一个文件过去,它能自己跑起来,断网重启后还能自己爬起来。

所以我给自己定的验收标准只有一条:

部署 = 把一个文件拷到目标机器,运行,完事。

这条标准最终决定了整个项目的技术选型。最终产物:Windows amd64 / Linux amd64 / Linux arm64 三个平台的单文件发行包,压缩后 13~14MB,install.bat(或 install.sh)一条命令注册成系统服务,开机自启、崩溃自动拉起。

二、"单二进制"背后是三个技术决策

单二进制不是 Go 的免费午餐,难点在于:一个网关需要 HTTP 服务 + Web 前端 + 持久化存储 + 十几种协议栈,传统思路里每一样都要拉外部依赖。

决策 1:数据库用纯 Go 的 SQLite,彻底放弃 CGO

存储是第一个拦路虎。网关要存设备配置、点位表、报警历史、待补发的数据队列,SQLite 是 obvious 的选择——但 Go 生态里最常用的 mattn/go-sqlite3 是 CGO 包装,一开 CGO,交叉编译直接报废:给 arm64 的工控盒子编译,你得在 Linux 上装目标平台的 gcc 工具链,CI 成本陡增。

我选了 modernc.org/sqlite(经 glebarez/sqlite 接入 GORM):一个把 SQLite 用 Go 重写的移植版,性能比 C 版低 10%~30%,但换来的是 CGO_ENABLED=0 之下的一行命令交叉编译:

CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" .

对网关这种写入压力不大的场景(突发写入已被 outbox 削峰,后文细说),这点性能损耗完全在可接受范围内。能用编译复杂度换运行简单性的地方,一律换。

决策 2:前端用 go:embed 塞进二进制

管理控制台是 Vue 3 + Element Plus,构建产物约 3.3MB。用一个 //go:embed dist 嵌进 Go 二进制,fs.Sub 挂载后配好 SPA 路由回退和 MIME 表,前端就成了二进制的一部分——没有静态文件目录、没有 Nginx、没有"忘了拷 dist 文件夹"这种事故。

顺带的好处是版本一致性:界面和后端 API 永远是配套的,不存在前端旧版本连新后端的兼容问题。

决策 3:依赖注入用 Wire,编译期解决

DI 框架如果靠反射在运行时注入(比如 fx),失败要到启动那一刻才暴露;google/wire 在编译期生成注入代码,依赖图有问题编译就报错。对"一个二进制扔到现场跑半年、没人盯着"的系统,能提前到编译期发现的问题,就不要留给运行时

这三个决策叠加的结果:

传统工业网关 iot-gateway
JDK/Python + MySQL + Redis + Nginx 一个可执行文件
部署文档写三页 拷贝,运行
升级 = 停服 + 备份 + 迁移 + 祈祷 换文件,重启(配置数据在 data/ 目录,不动)
arm 网关盒子需配环境 一条命令交叉编译

三、架构:一个进程里的四个引擎

PLC / 设备 ──▶ collector 采集引擎 ──▶ driver 协议驱动层(12 种协议)
                    │
                    ▼ RecordSink
              push 推送引擎 ──┬──▶ MQTT Broker
                              ├──▶ TDengine v3
                              └──▶ InfluxDB v3
                    │ 断网
                    ▼
              SQLite outbox 本地缓存(恢复后补发)
              alarm 断联报警 · Web 控制台 · Open API

启动顺序本身就藏着一个教训(main.go 里有一段注释专门记了这笔账):推送引擎必须先于采集引擎启动。否则第一轮采集出来的数据,会因为通道还没连上而被丢弃——网关上线后"开头一段数据缺失"这种很难排查的问题,根源就是初始化顺序。

采集引擎:Worker 池与防堆积

采集的并发模型很朴素:固定大小的 goroutine 池(runtime.NumCPU()*2 个 worker,队列 1024),点位按扫描频率分组,每个频率组独立 ticker 调度,读取任务提交共享池。

真正值钱的是两个细节:

1. in-flight 防堆积。 每台设备每轮只允许一个未完成的读取任务在池里——上一轮没跑完就跳过本轮。没有这个机制,一台网络抖动的慢设备会在队列里堆积任务,把整个池拖死。慢设备应该只拖慢自己,不能拖垮全局。

2. 串口独占。 RTU 设备要走串口,天然是互斥资源,整轮采集持锁;而 TCP / S7 只在"重连 + 单次 Read"时加锁。锁的粒度按传输介质的物理特性定,而不是一刀切。

热加载:为什么不用 fsnotify

网关在运行时经常要改点位表——加个测点、调个周期,不应该重启进程。实现热加载时我犹豫过要不要上 fsnotify,最后选择了每 10 秒轮询数据库 + FNV-1a 内容级 checksum,原因很简单:

配置根本不在文件系统里,在 SQLite 里。

fsnotify 监听的是文件事件,而设备、点位、通道的配置是用户通过 Web 控制台写的数据库——监听文件毫无意义。所以 watcher 对启用状态的设备和点位逐行计算 checksum(连 protocolJSON、扫描频率这些字段都在内),新增/删除/修改/启停全部可感知。

检出变更后的切换是零中断的:建新任务(驱动只创建不连接)→ 停旧任务(排空在途轮询)→ 启动新任务(懒连接)→ 原子指针置换。全程没有"先停再启"的服务空窗。

代价也说清楚:全表 checksum 在百万级点位时单轮要 1.7 秒、消耗数 GB 内存,是目前扩展性的已知瓶颈(压测报告里如实记录了)。对典型规模(几千到几万点位)完全无感,这个取舍我认为是对的,但百万点位的场景将来要换增量方案。

断网不丢数据:Outbox 补发

车间网络和数据中心网络不是一个物种——交换机重启、光纤被铲车挖断是常态。工业场景对数据的要求是"可以晚到,不能没有",这就是 outbox 模式:

  1. 采集数据进入推送引擎后,广播给所有通道,非阻塞——通道缓冲满就丢最旧的进 outbox,绝不反压采集线程;
  2. 通道正常时直接发送;断连或缓冲满时,数据序列化落盘到 SQLite 的 push_outbox 表(按通道隔离,默认上限 10 万批,满则裁剪最旧);
  3. 恢复后由补发协程按 id 顺序重发:每次取 16 批一个窗口,窗口内全部成功才删除,任一失败立即停下等重连——保证不跳批、不丢批;
  4. 补发是事件驱动而不是轮询:落盘、重连成功都会唤醒 drain 协程,外加 10 秒兜底 timer 防止漏唤醒。轮询版本空转时每秒都在打 SQLite,事件驱动版本闲时零开销。

配合设备断联报警(去抖 + 状态机,只在离线/恢复的边沿落库),现场断网从"数据悄悄缺失"变成"报警中心里一条有始有终的记录"。

四、压测:50 万点位,和两个被推翻的直觉

代码写得再自洽,不压测都是自欺欺人。我搭了一套压测工具(进程内跑完整采集链路,对面是逐帧实现的假 PLC 服务器,Modbus/MC/FINS/S7/CIP/OPC UA 都有),8 核 Windows、loopback 网络。两个关键数字:

  • Modbus.TCP,100 台设备 × 5000 点位 @ 1000ms:50 万点位,0% 掉点,实测 60 万记录/秒写入采集链路;
  • 吞吐边界在本机约 200 万记录/秒(200 设备 × 2000 点 @ 200ms),再往上开始掉点。

过程中两个结论和我的直觉相反,值得单独说:

直觉一:worker 不够就加 worker。 实际把池从 32 加到 64,掉点率反而从 16.1% 恶化到 19.4%。瓶颈根本不在调度,在 CPU——goroutine 切换和锁竞争把核吃光了。后来把记录转换里的两次 map 查询优化成索引直取,该函数 CPU 占比从 13.7% 降到 1.7%。** profiler 说的话比直觉可靠。**

直觉二:容量按点位数算。 真实公式是 帧数 × 往返延迟。Modbus 一次读 100 个寄存器是 1 帧,读 100 个分散点位可能是 100 帧——后者容量直接缩水 100 倍。这也是为什么驱动层把"批量读、最大标签数/请求"作为一等公民配置(CIP 批量读优化后,掉点从 69.8% 直接到 0%)。

五、为什么是 Go

回头看,Go 在这件事上的优势不是语言层面多先进,而是和"工业现场"这个环境咬合得严丝合缝:

  1. 交叉编译:一台开发机产出三个平台的静态二进制,arm64 工控盒子不需要在任何远端配环境;
  2. goroutine 的心智模型:"每台设备一个连接状态机、每个频率组一个 ticker"这种并发结构,写出来就是它该有的样子;
  3. 生态里正好有纯 Go 的 SQLite——这在几年前还不可想象,是整个单二进制方案的基石;
  4. pprof 就在标准库里,压测时的 CPU 火焰图、goroutine 泄漏排查都是开箱即用。

六、诚实的局限

不想把文章写成软文,边界也说清楚:

  • 压测数字来自 loopback + 假 PLC,是网关侧上限,真实现场的网络延迟和设备响应速度才是容量天花板;
  • 欧姆龙 FINS 的 HostLink FCS 计算、CIP 部分响应格式,代码已实现但个别方言细节仍在等真机回归,项目 README 里如实标注了;
  • checksum 轮询热加载在百万点位级是瓶颈;
  • 单机架构:没有集群,没有高可用——工业网关的可靠性靠的是"进程稳 + 断网补发 + 服务自动拉起",而不是分布式。

七、项目信息

iot-gateway,AGPL-3.0 开源(内部部署使用、经 API/MQTT 对接自有系统均不触发开源义务,README 有 7 条 FAQ 说清楚边界):

  • gitee仓库:https://gitee.com/wang4856304/iot-gateway
  • github仓库:https://github.com/Ellisata/iot-gateway
  • 12 种协议/传输驱动、MQTT / TDengine v3 / InfluxDB v3 推送通道,驱动和通道都是"注册表 + 接口"的插件式设计,init() 里注册即接入,新增协议不需要动引擎代码,也不需要改前端(协议参数是 JSON Schema 动态表单);
  • 内置 Web 控制台(中英双语)、Open API 密钥鉴权对接 MES/ERP、Docker Compose 与单二进制两种部署方式。

如果你在做工业数据采集、MES 对接、或者只是想看一个"如何把整个系统塞进一个二进制"的完整案例,欢迎 Star、提 Issue、贡献驱动。


觉得有收获的话,点赞收藏让更多人看到;项目中踩过的协议坑(FINS FCS、CIP 地址项、字节序配置)我会在后续文章里逐个展开。

posted @ 2026-09-08 17:55  故约翰  阅读(3)  评论(0)    收藏  举报