三菱CNC数据采集C#源码Demo:EZSocket、缓冲落库与断线重连
简介:面向三菱CNC数采开发与自动化调试场景的C#示例工程,适合具备一定C#基础、希望将机床数据接入上位机或业务系统的工程师参考,也可作为自动化技术爱好者学习数控通讯流程的入门材料。内容围绕与三菱数控系统建立连接、采集加工参数与设备状态、处理错误日志等需求展开,并说明配合安装A2.exe的作用,帮助理解Demo运行前提、通讯接口调用与数据交换方式。压缩包约3.52MB,文件总数暂无有效值,类型明细上游未给出;预览显示含参考手册PDF与SimCNC-master仿真源码目录,可用于对照通讯协议、搭建无实机测试环境并验证采集逻辑。已有626人学习,适合借此梳理连接建立、数据解析、异常处理与调试排错思路,为后续上位机集成提供参考。
1. 从一台 M800 的网口说起:这份 Demo 能省掉哪一半工作量
很多人以为三菱CNC数采最难的是"连不上",实际在现场把线插好、IP 配好,ping 通了,真正耗时间的是后面那一段:坐标怎么读、报警号怎么对上文本、采集跑一夜内存涨到几个 G、换一台系统版本不一样的机台又得重调。这份带 C# 源码的三菱CNC数据采集 Demo,价值不在"演示能读到数",而在它把会话建立、周期采集、数据落地、断线重连这几段骨架先搭好了,你拿到手是把字段换成自己车间的,不是从零画架构图。适合两类人:手上有一批 M70/M80 系列机台、想自己做一套轻量数采的自动化工程师;以及接了小项目、需要一份能跑通的 C# 三菱CNC数采参考实现的外包开发者。它不解决跨品牌统一建模,也不替你做 MES 对接,边界先讲清楚,后面才好判断哪部分能直接用、哪部分必须自己补。
2. 采集链路选型:EZSocket、MC 协议与 MTConnect 三条路怎么选
动手写第一行代码之前,最该花时间的是把"数据从哪个口子出来"定下来。三菱CNC的数据出口不止一个,选错了,后面写多少代码都是白搭。这一章把 CNC 侧数据和 PLC 侧数据的边界划清,再把三条常见接入路线摆到一张表里对比,最后给出这份 Demo 的工程分层,方便你判断它是"能直接改"还是"只能当参考"。
2.1 CNC 数据与 PLC 数据先划清边界
三菱CNC系统里其实住着两个世界。一个是 NC 世界:轴机械坐标、绝对坐标、相对坐标、主轴实际转速、进给速度、当前执行的程序号、刀具补偿号、报警号与报警文本、伺服负载。这些数据由 NC 内核维护,走厂商提供的 NC 数据访问接口最直接,字段语义明确,不用你去猜某个寄存器是第几轴。另一个是 PLC 世界:卡盘夹紧/松开、防护门开关、三色灯状态、加工计数、外部急停链路、上下料机构的握手信号。这些本质上是梯形图里的软元件,走 MC 协议读 D/M/X/Y 更合适。
把这两个世界混在一个采集周期里,是新手最常见的结构性问题。NC 接口一次调用返回的是结构化对象,PLC 侧返回的是一段连续的字节数组,两者的失败模式、超时特征、刷新频率都不一样。我一般的做法是在采集层就分成两个独立的采集器,各自有独立的周期和独立的健康状态,只在数据落地时按machine_id + timestamp对齐。这样某一侧卡住了,另一侧还能继续跑,不会整条链路一起哑掉。
还有一处容易忽略:NC 侧的坐标数据是有"生命周期"概念的。程序运行中、程序停止、急停、复位,同名字段的可用性不一样。有些实现在急停状态下读绝对坐标会返回上一次的缓存值,看着像正常数据,其实已经失真。所以采集器里必须同时抓一个"运行状态"字段,落地时一起写进去,后面做数据分析的人才知道这条记录该不该信。
2.2 三条接入路线的成本对照
下面这张表是我自己在几个模拟项目里反复对比后沉淀下来的,参数取值来自实际调试经验,具体到你的机台型号和选件配置会有出入,看趋势就行。
| 维度 | EZSocket / NC 数据访问库 | MC 协议裸写(3E 帧) | MTConnect |
|---|---|---|---|
| 接入形态 | 厂商提供的 .NET/COM 组件,C# 直接引用 | 自己拼二进制帧走 TCP/UDP | HTTP + XML,轮询 Agent |
| 数据粒度 | 轴、主轴、报警、程序号等语义字段 | 软元件级,D/M/X/Y 任意地址 | 按设备模型定义的 DataItem |
| 单机接入工作量 | 低,半天能读到第一条坐标 | 中,帧格式和响应解析要自己写 | 高,需要部署 Agent 并做映射 |
| 跨品牌能力 | 无,绑定单一品牌 | 无,各品牌协议不同 | 强,标准模型 |
| 额外授权 | 通常需要官方运行库/选件 | 无,只要网口开放 | Agent 侧一般开源 |
| 典型故障 | 版本不匹配、位宽不匹配 | 端口不通、字节序搞反 | Agent 掉线、映射字段缺失 |
| 适合场景 | 单品牌车间、快速出结果 | 补采 PLC 侧信号、机床无 NC 接口 | 多品牌混线、要对接上层平台 |
选型判断其实很简单:车间里只有一个品牌的机台,优先走 NC 数据访问库,因为它把"第几轴的第几个坐标"这种语义问题替你解决了;如果还要采门、灯、卡盘这些外围信号,再叠一层 MC 协议补采;只有当车间里同时存在多个品牌、上层平台又要求统一模型时,才值得为 MTConnect 付出那份额外的部署和映射成本。别为了"架构先进"在一台机台上堆三套协议,维护成本会把省下来的时间全吃掉。
提示:MC 协议是否开放、走 TCP 还是 UDP、端口号是多少,取决于机台的网络参数设定,不同选件配置下默认值可能不同。动手前先在机台侧确认一遍,别拿网上抄来的端口硬试。
2.3 Demo 工程结构与依赖清单
这份 Demo 的源码结构是典型的三层:适配层、采集层、落地层。适配层把厂商 API 关在门内,对外只暴露一个自定义接口;采集层负责周期调度和状态机;落地层负责缓冲和批量写入。这个分法不新鲜,但对数采这种"厂商 API 随时可能换版本"的场景特别管用——哪天换了一台系统版本不同的机台,你只需要再写一个适配器实现,采集层一行不用动。
依赖清单不长,但要一次配齐:.NET Framework 4.7.2 或以上(走 COM 互操作时我更倾向 Framework,踩的坑少)、厂商的通信运行库、一个数据库驱动、一个日志库。工程里建议单独建一个Libs目录放厂商 DLL,并且把"复制到输出目录"设成 true,否则调试时经常出现"设计器里能引用、运行时报找不到模块"的玄学问题。下面是最小可跑的分层骨架,把接口先立住,后面所有代码都往里填。
// 适配层对外契约:所有厂商差异都在实现类里消化 public interface ICncChannel : IDisposable { bool IsOpen { get; } void Open(); // 建立会话,内部做重试 CncSnapshot ReadSnapshot(); // 一次读全量,避免多次往返 } // 采集层只认这个结构体,不认厂商类型 public sealed class CncSnapshot { public DateTime TimestampUtc { get; set; } public string MachineId { get; set; } public double[] AxisPositions { get; set; } // 按轴号顺序,单位 mm public double SpindleSpeed { get; set; } // rpm public double FeedRate { get; set; } // mm/min public int ProgramNo { get; set; } public int AlarmNo { get; set; } // 0 表示无报警 public int RunState { get; set; } // 1 运行 2 暂停 3 急停 4 复位 }ICncChannel里刻意把Open和读取分开,是为了让采集层能自己控制重连节奏——如果Open写在构造函数里,一旦网络抖动你连重试的入口都没有。ReadSnapshot一次性返回全量而不是提供GetAxis(i)这种细粒度方法,是因为走网络协议的接口每次调用都是一次往返,十个字段调十次,周期直接被拖垮。TimestampUtc用 UTC 存,展示层再转本地时区,这一条后面第 5 章还会专门讲,它是时区问题的后悔药。
3. 把 Demo 跑起来:从运行库注册到第一条轴坐标
环境这一关劝退过不少人,不是因为难,是因为报错信息太含糊。这一章按"环境准备 → 会话建立 → 采集循环"的顺序走一遍,每步给出可执行的命令或代码,并说明失败时该看哪里。跟着走完,你应该能在自己的机台上看到第一条真实的轴坐标。
3.1 环境准备与网络连通性验证
第一步是把运行库装对。厂商的通信组件通常是 32 位和 64 位两套,你的宿主进程是哪个位数,就必须装对应那一套,混装的表现是"编译通过、运行时抛类型加载异常"。工程属性里把目标平台从Any CPU改成明确的x64或x86,别留默认值,这是最常见的翻车点之一。装完之后用管理员权限注册一次,路径按你实际安装位置改。
# 以管理员身份运行;32 位进程用 SysWOW64 下的 regsvr32 regsvr32 "C:\Program Files (x86)\Common Files\Vendor\Comm\EzSocket.dll" # 确认注册成功:查注册表里有没有对应的 ProgID reg query "HKCR\EzSocketLib.EzSocket" /s # 确认进程位数与库位数一致 corflags "C:\YourApp\bin\Debug\YourApp.exe"三条命令各管一件事:第一条把 COM 组件写进注册表;第二条验证写入成功,查不到就说明路径不对或者权限不够;第三条确认宿主进程的位数标记,32BITREQ表示必须跑在 32 位下。第三条规定经常被跳过,结果就是排查半天发现是位数问题,白白浪费一个下午。
网络侧先做最朴素的验证,别一上来就开程序。机台侧的 IP、子网掩码、网关要和采集机在同一网段,防火墙把采集程序放行。用 PowerShell 探端口比 telnet 稳,因为 Windows 默认经常没装 telnet 客户端。
# 1. 先确认三层可达 Test-Connection -ComputerName 192.168.1.20 -Count 4 # 2. 再确认端口通;5007 只是常见默认值,务必以机台侧设定为准 Test-NetConnection -ComputerName 192.168.1.20 -Port 5007 -InformationLevel Detailed # 3. 看本机是不是有多张网卡抢路由 Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway -ne $null }第二步返回TcpTestSucceeded : True才算通。如果 ping 通但端口不通,八成是机台侧没开对应服务、端口号填错、或者中间有设备做了端口过滤。第三步容易被忽略:采集机如果同时接了办公网和车间网,默认路由可能把发往机台的包甩到办公网卡上,表现是"偶尔能通、大部分超时",这种间歇性故障最难查,先把路由理清楚。
3.2 会话建立与句柄生命周期管理
会话这层的核心原则只有一条:一个采集实例对应一个会话句柄,且句柄的生命周期必须由状态机管,不能散落在业务代码里。我见过最典型的错误写法是在每次读取时new一个通信对象,读一百次就泄漏一百个句柄,跑两小时机台侧直接拒绝新连接。正确做法是把通道包成一个单例式的、带重连能力的对象。
public sealed class EzCncChannel : ICncChannel { private readonly string _host; private readonly int _port; private readonly object _gate = new object(); private dynamic _session; // 厂商对象,具体类型按引用库版本改 private DateTime _lastOpenFail = DateTime.MinValue; public bool IsOpen { get; private set; } public EzCncChannel(string host, int port) { _host = host; _port = port; } public void Open() { lock (_gate) { // 失败退避:连续失败时不要立刻重试,避免把机台侧连接池打满 if (DateTime.UtcNow - _lastOpenFail < TimeSpan.FromSeconds(5)) return; try { _session = Activator.CreateInstance(Type.GetTypeFromProgID("EzSocketLib.EzSocket")); _session.Open(_host, _port, 3000); // 第三个参数是超时,单位 ms IsOpen = true; } catch (Exception ex) { _lastOpenFail = DateTime.UtcNow; IsOpen = false; Log.Warn($"open failed host={_host} err={ex.Message}"); } } } public void Dispose() { lock (_gate) { try { _session?.Close(); } catch { /* 关闭失败不抛,避免拖垮退出流程 */ } _session = null; IsOpen = false; } } }这段代码有三处值得说。_gate锁保证同一时刻只有一个线程在建会话,多线程并发Open是句柄泄漏的头号来源。_lastOpenFail做的是失败退避,间隔设 5 秒,机台侧网络断开时如果每 100ms 重试一次,一分钟就是 600 次无效连接,有些实现会直接把源 IP 拉黑。Open的第三个参数是超时,3000ms 是个折中值:设太短,机台忙的时候误判为断线;设太长,采集线程被阻塞,整个周期被拖垮。
dynamic加ProgID的写法是为了让你不用在编译期绑定具体类型库,换一个版本的运行库时改个字符串就行。代价是没有编译期检查,方法名拼错要运行到才知道。所以我会在Open成功后立刻做一次"探针读取",读一个必然存在的字段(比如轴数),读不到就认为这次会话是假的,主动关掉重来。
ICncChannel的注释里写了「一次读全量」,实现时要注意厂商接口通常不支持真正的批量,那就把多次读取压缩在一次会话内完成,别在两次读取之间做任何 IO 或数据库操作。采集线程的黄金法则是:会话期间只做内存操作,出了会话再谈落库。
3.3 采集循环:轴坐标、主轴转速与报警号
采集循环要解决三个具体问题:周期怎么定、值怎么判重、异常怎么不中断。周期不是越短越好,NC 侧的坐标刷新本身有内部节拍,你 10ms 读一次,读到的可能还是同一个值,白白占带宽。我的经验值是:坐标和转速 100ms 到 200ms,报警和程序号 500ms,PLC 侧的开关量 50ms 到 100ms。这个粒度对绝大多数加工过程分析够用,对真正的插补级分析不够,但那就不是数采该干的事了。
判重同样重要。一台机台一天产生几十万条记录,其中大部分坐标是重复的。可以只存"变化点 + 每 N 秒的心跳点",存储量能降一个数量级。下面这个循环把采集、判重、异常隔离串在一起。
public sealed class Collector { private readonly ICncChannel _ch; private readonly IBlockingSink _sink; private readonly int _intervalMs; private CncSnapshot _last; public async Task RunAsync(CancellationToken ct) { var sw = new Stopwatch(); while (!ct.IsCancellationRequested) { sw.Restart(); try { if (!_ch.IsOpen) _ch.Open(); if (_ch.IsOpen) { var snap = _ch.ReadSnapshot(); snap.TimestampUtc = DateTime.UtcNow; // 判重:坐标变化超过 0.001mm 或距上次落库超过 5s 才写 if (IsChanged(snap) || (snap.TimestampUtc - _last.TimestampUtc).TotalSeconds >= 5) { _sink.Put(snap); // 只入内存队列,绝不在采集线程里写库 _last = snap; } } } catch (Exception ex) { Log.Error($"collect error machine={_last?.MachineId} err={ex.Message}"); _ch.Dispose(); // 出错先拆会话,下一轮重建 } var wait = _intervalMs - (int)sw.ElapsedMilliseconds; if (wait > 0) await Task.Delay(wait, ct); } } private bool IsChanged(CncSnapshot cur) { if (_last == null) return true; if (cur.AlarmNo != _last.AlarmNo) return true; if (cur.ProgramNo != _last.ProgramNo) return true; for (int i = 0; i < cur.AxisPositions.Length; i++) if (Math.Abs(cur.AxisPositions[i] - _last.AxisPositions[i]) > 0.001) return true; return false; } }Stopwatch配合"减去已耗时再 Delay"的写法,是为了让周期稳定在设定值附近,而不是"设定值 + 执行耗时"逐轮累积漂移。这一点在长时间运行里差别很大:不修正的话,设定 100ms 实际跑成 130ms,一天的样本量少 23%,做节拍分析时数据对不上。
catch里直接Dispose而不是只记日志,是因为通信异常之后会话对象的状态往往已经不可信,继续用它会读到脏数据。拆掉重建的成本是几十毫秒,比读一整天假数据划算。
_sink.Put只做内存入队,这个约束必须硬性守住。一旦有人在采集线程里写了一次数据库,数据库抖动就会传导成采集超时,进而触发重连,进而雪崩。落地的事放到第 4 章单独讲。
设备型号、轴数、程序号格式这些信息,不同机型和系统版本差异不小,代码里凡是硬编码的地方都建议提成配置项。
4. 让数据真正落下来:时间戳、缓冲队列与断线重连
能读到数只是半程,另一半在"读到的数怎么不丢、不乱、不把数据库压死"。这一章讲三件事:为什么采集频率必须和入库频率解耦、环形缓冲怎么设计、断线重连的状态机怎么写才不会抖动。
4.1 采集频率不能跟着数据库走
新手最常见的架构是"采一条、写一条",代码短,逻辑直观,在单机台、低频率下确实能跑。但它有三个致命伤:第一,数据库的写入延迟会直接叠加到采集周期上,一次慢查询就能让采集周期从 100ms 变成 2 秒;第二,网络抖动时采集线程阻塞在 IO 上,机台侧数据在缓冲区里堆积,恢复后一次性涌进来,形成尖峰;第三,采集和存储的责任混在一起,后面想换存储介质(时序库、消息队列、文件)就得动采集代码。
解耦的做法是在中间加一层有界队列。采集线程只负责Put,独立的落地线程按批Take,两边频率互不影响。队列要有界,满了之后采取"丢最旧"或"阻塞采集"的策略,我一般选丢最旧并计数——数采场景里,最新的数据永远比历史数据重要,而丢了多少条本身就是一个应该被监控的健康指标。
public sealed class BoundedSnapshotQueue : IBlockingSink { private readonly BlockingCollection<CncSnapshot> _queue; private long _dropped; public BoundedSnapshotQueue(int capacity = 20000) { // 有界队列:容量按"最长断线时间 × 采集频率 × 机台数"估算 _queue = new BlockingCollection<CncSnapshot>(capacity); } public void Put(CncSnapshot snap) { if (!_queue.TryAdd(snap)) { // 队列满:丢最旧,保证最新数据一定进得去 if (_queue.TryTake(out _)) Interlocked.Increment(ref _dropped); _queue.TryAdd(snap); } } public IEnumerable<CncSnapshot> TakeBatch(int max, int timeoutMs) { var batch = new List<CncSnapshot>(max); if (_queue.TryTake(out var first, timeoutMs)) batch.Add(first); while (batch.Count < max && _queue.TryTake(out var more)) batch.Add(more); return batch; } public long DroppedCount => Interlocked.Read(ref _dropped); }容量 20000 不是拍脑袋:按 20 台机台、每台 10 条/秒、允许 100 秒的数据库不可用来估算,正好两万。这个数应该写进配置,而不是常量。TakeBatch先带超时取第一条,再非阻塞地把剩下的捞干净,这样既不会空转烧 CPU,又能在有数据时一次取满,减少数据库往返次数。DroppedCount暴露出来给监控用,这个值一旦持续增长,说明落地侧跟不上,该加索引或者换存储了。
4.2 批量落库与表结构
批量写入的关键是批次大小和事务粒度的平衡。批次太小,数据库往返次数多;批次太大,一次失败重放代价高,而且长事务会锁表。我一般取 500 到 2000 条一批,配合SqlBulkCopy或者多值INSERT,单批耗时控制在 200ms 以内。
表结构上有个小设计能省很多事:把"变化点"和"心跳点"用同一个表存,加一个is_heartbeat标记。做过程分析时过滤掉心跳点,做设备在线率统计时只看心跳点,一张表两用。
CREATE TABLE cnc_snapshot ( id BIGINT IDENTITY(1,1) PRIMARY KEY, machine_id VARCHAR(32) NOT NULL, ts_utc DATETIME2(3) NOT NULL, -- 统一存 UTC,展示层再转 axis_json NVARCHAR(512) NULL, -- 轴坐标数组,序列化后存 spindle_speed DECIMAL(10,2) NULL, feed_rate DECIMAL(10,2) NULL, program_no INT NULL, alarm_no INT NULL, run_state TINYINT NULL, is_heartbeat BIT NOT NULL DEFAULT 0 ); -- 查询模式基本固定为"某台机某段时间",复合索引按这个建 CREATE INDEX ix_snapshot_machine_ts ON cnc_snapshot (machine_id, ts_utc) INCLUDE (alarm_no, run_state);ts_utc用DATETIME2(3)而不是DATETIME,毫秒精度对判重和节拍分析是必要的,DATETIME的 3.33ms 精度在 100ms 周期下会产生肉眼可见的对齐误差。索引把alarm_no和run_state放进 INCLUDE,是因为报表查询几乎总要带上这两个字段,覆盖索引能避免回表。
axis_json这招在小规模场景下很好用,省掉一张轴明细表。但如果后面要做"任意轴的任意时间段曲线",就得拆成cnc_axis_value(machine_id, ts_utc, axis_no, value)的窄表。这个改造越晚做代价越大,如果一开始就知道要做轴级分析,直接上窄表。
4.3 断线重连的状态机
重连逻辑写不好,会出现"抖动放大":网络短暂抖动 1 秒,程序疯狂重连 100 次,每次都失败,机台侧认为是攻击行为。正确做法是用一个显式状态机,把"失败次数"变成退避时间的输入。
public enum LinkState { Disconnected, Connecting, Online, Degraded } public sealed class LinkStateMachine { private int _failCount; private DateTime _nextRetryUtc = DateTime.MinValue; public LinkState State { get; private set; } = LinkState.Disconnected; // 返回 true 表示本次允许尝试连接 public bool ShouldTryConnect(DateTime nowUtc) { if (State == LinkState.Online) return false; return nowUtc >= _nextRetryUtc; } public void OnSuccess() { _failCount = 0; _nextRetryUtc = DateTime.MinValue; State = LinkState.Online; } public void OnFailure(DateTime nowUtc) { _failCount++; // 指数退避:1s, 2s, 4s, 8s ... 上限 60s var delaySec = Math.Min(60, Math.Pow(2, Math.Min(_failCount - 1, 6))); _nextRetryUtc = nowUtc.AddSeconds(delaySec); // 连续失败 3 次以上进入 Degraded,只告警不放弃 State = _failCount >= 3 ? LinkState.Degraded : LinkState.Disconnected; } }退避上限设 60 秒,是权衡的结果:设太小起不到保护作用,设太大(比如 10 分钟)会让一次短暂的网络闪断变成长时间的数据缺口。Degraded这个状态很多人不设,全用Disconnected表示,结果就是"刚断 1 秒"和"断了半小时"在监控上看起来一样,运维没法判断优先级。
状态机的调用点必须在采集循环内部,而不是搞一个独立的定时器去探活,两套节奏容易打架。采集循环每轮先问ShouldTryConnect,返回 true 才真正去Open,这样重连节奏和采集节奏天然同步。
5. 避坑与排查:五个把我按在机台旁边的现场问题
这一章的每一条都是真实踩过的,按"现象 → 原因 → 解决"写,方便你对着症状查。
5.1 连得上、读得到,但坐标全是 0
现象:会话建立成功,Open没报错,读取也返回了对象,但所有轴坐标都是 0,主轴转速也是 0,唯独报警号能读到。
原因:多数是"读错了数据区"。NC 侧的坐标分机械坐标、绝对坐标、相对坐标、剩余量等多个区,有些实现需要显式指定区号或者先切换到某个坐标系才返回有效值。另一个可能是轴号映射错了,读的是不存在的轴,实现层默默返回 0 而不是报错。
解决:先用机台的操作面板对照一次——把面板上显示的机械坐标抄下来,再和程序读到的值逐个比对,能立刻判断是"区错了"还是"轴号错了"。代码里加一个断言:如果所有轴同时为 0 且运行状态为"运行中",视为异常,打警告日志,别让它静默写库。
5.2 采集跑几小时内存涨到几个 G
现象:程序启动时内存 80MB,跑 8 小时后涨到 3GB,最终 OOM 退出,重启后又慢慢涨。
原因:九成是队列无界,或者落库线程因为异常退出了但没人发现,采集端一直往里塞。剩下的一成是 COM 对象没释放——每次读取都new一个对象,GC 又回收不了 COM 引用,形成泄漏。
解决:队列强制有界(第 4 章那个实现),落地线程用try/catch包住整个循环体,出错后延迟重启而不是直接退出,同时把"队列长度"和"落地线程是否存活"打点到监控。COM 对象严格按"一个采集实例一个会话"管理,Dispose里显式Close并置 null。上线前跑一次 24 小时浸泡,用任务管理器看内存曲线,平稳才算过。
5.3 多机台并发时随机几台读不到
现象:单台调试一切正常,一上到 10 台并发,总有 2 到 3 台随机超时,重启程序又能好一阵。
原因:线程数开太多,每台机台一个线程,加上定时器和日志,线程池被占满;或者所有机台共用同一个通信组件实例,内部锁竞争导致排队。还有一种隐蔽情况是采集机的网卡或交换机端口在突发并发下丢包。
解决:机台数超过 8 台时改用"少量线程 + 异步轮询"的模型,不要一台一线程。通信组件按机台实例化,绝不共享。先把并发降到 3 台看是否稳定,再逐步加,找出临界点。网络侧用ping -t长时间跑,看有没有规律性的丢包。
5.4 报警号能读到,文本对不上
现象:alarm_no读到 1024,但程序里查表得到的文本和机台面板显示的完全不是一回事。
原因:报警文本不是 NC 标准数据的一部分,它来自机台厂商或者最终用户自定义的报警表,不同项目、不同系统版本差别很大。指望一套硬编码的映射表通吃所有机台,必然翻车。
解决:把报警文本做成外部可配置的映射表,按machine_model + system_version + alarm_no三键查找,查不到就显示原始编号并记录一条"未映射报警"日志。跑一段时间后,把这些未映射的编号导出来,交给机台侧确认,逐步补全。这样即使映射不全,数据也不会错,只是文本暂时缺失。
5.5 时间戳差 8 小时,报表全错位
现象:数据库里的时间和机台面板显示的时间差了整整 8 小时,跨天统计全部错位。
原因:采集机、数据库、机台三方的时区设置不一致,有的存本地时间,有的存 UTC,汇总时又按本地时间截断。
解决:全链路统一存 UTC,只在展示层转本地时区。机台侧如果只能提供本地时间,采集时立刻转成 UTC 并记录原始时区偏移。数据库字段名明确写ts_utc,让后来的人没有歧义空间。这一条改起来最便宜,但漏掉的代价最大——时区错了,所有基于时间的分析全部作废,而且很难发现。
6. 从 Demo 到产线:用 72 小时浸泡和一张健康度表把稳定性钉死
Demo 能跑通和能上线之间,隔着一段叫"长时间稳定性"的距离。我的做法很笨但有效:任何一套采集程序,不管多简单,上线前必须跑满 72 小时浸泡测试,期间人为制造三次网络中断(拔网线 30 秒)、两次机台断电重启、一次数据库重启。测试期间只做一件事——盯着健康度指标看曲线,不看日志里的"成功"字样。
健康度我只看五个指标,多一个都是负担:
| 指标 | 采集方式 | 合格线 | 异常时的第一反应 |
|---|---|---|---|
| 采集周期偏差 | 每轮记录实际耗时,算 P95 | P95 < 设定值 × 1.5 | 查线程池和 GC 停顿 |
| 队列积压深度 | 定时采样队列长度 | 稳态 < 容量 20% | 查落地线程是否卡在慢 SQL |
| 丢弃条数 | 队列的 DroppedCount 增量 | 24 小时为 0 | 同上,优先扩批次而非扩容量 |
| 重连次数 | 状态机 OnFailure 计数 | 24 小时 < 3 次 | 查网络和机台侧连接数上限 |
| 数据缺口时长 | 相邻两条记录的最大间隔 | < 3 倍采集周期 | 对照机台运行状态,排除正常停机 |
这五个指标里,我最看重"数据缺口时长",因为它是一个结果性指标,前四个出问题最终都会体现在它身上。实现上很简单:落地时记录上一条记录的时间戳,超过阈值就打一条告警,带上机台号和缺口时长。
浸泡测试还有一个小技巧:不要只测"正常生产",要专门测边界。让机台在程序运行中途急停、在换刀时断电、在报警状态下保持两小时。这些场景下读出来的字段才是真正考验解析逻辑的地方——正常加工时所有字段都有值,边界状态下才会暴露"某个字段其实一直返回缓存值"这种问题。
// 缺口检测:落地线程里顺手做,不需要额外定时器 private DateTime _lastTsUtc = DateTime.MinValue; private void CheckGap(CncSnapshot snap, int intervalMs) { if (_lastTsUtc != DateTime.MinValue) { var gapMs = (snap.TimestampUtc - _lastTsUtc).TotalMilliseconds; // 阈值取 3 倍采集周期;超过就告警,但正常停机也会触发,需结合 run_state 判断 if (gapMs > intervalMs * 3) { Log.Warn($"gap machine={snap.MachineId} gapMs={gapMs:F0} state={snap.RunState}"); } } _lastTsUtc = snap.TimestampUtc; }这段代码的阈值取 3 倍周期,是因为单次 GC 或者一次慢查询造成 1 到 2 个周期的抖动是正常的,追着这个告警会被淹没。带RunState一起打日志,是为了区分"真丢数据"和"机台正常停机"——后者在夜班和午休时段大量出现,不区分的话告警没有可读性。
还有一件事值得在 Demo 基础上补:把配置外置。机台列表、IP、端口、采集周期、队列容量、告警阈值,全部从配置文件读,改一次参数不用重新编译。我在一个模拟项目里就是因为把端口写死在代码里,换机台时不得不重新发布,凌晨两点在车间里抱着笔记本编译,那种体验一次就够了。从那以后我每次接数采项目,第一件事就是把配置文件骨架立起来,哪怕只有三台机台也不例外。希望帮到你。
本文还有配套的精品资源,点击获取