
简介面向三菱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/UDPHTTP 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报警和程序号 500msPLC 侧的开关量 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 BlockingCollectionCncSnapshot _queue; private long _dropped; public BoundedSnapshotQueue(int capacity 20000) { // 有界队列容量按最长断线时间 × 采集频率 × 机台数估算 _queue new BlockingCollectionCncSnapshot(capacity); } public void Put(CncSnapshot snap) { if (!_queue.TryAdd(snap)) { // 队列满丢最旧保证最新数据一定进得去 if (_queue.TryTake(out _)) Interlocked.Increment(ref _dropped); _queue.TryAdd(snap); } } public IEnumerableCncSnapshot TakeBatch(int max, int timeoutMs) { var batch new ListCncSnapshot(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 秒、两次机台断电重启、一次数据库重启。测试期间只做一件事——盯着健康度指标看曲线不看日志里的成功字样。健康度我只看五个指标多一个都是负担指标采集方式合格线异常时的第一反应采集周期偏差每轮记录实际耗时算 P95P95 设定值 × 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、端口、采集周期、队列容量、告警阈值全部从配置文件读改一次参数不用重新编译。我在一个模拟项目里就是因为把端口写死在代码里换机台时不得不重新发布凌晨两点在车间里抱着笔记本编译那种体验一次就够了。从那以后我每次接数采项目第一件事就是把配置文件骨架立起来哪怕只有三台机台也不例外。希望帮到你。本文还有配套的精品资源点击获取