
上个月给汽配厂焊接产线做数据采集升级一共16台PLC12台S7-1200 4台S7-1500合计近600个点位要做实时监控和工艺数据上报。一开始图省事按常规后端思路写每台PLC开一个独立Task各自轮询采集多线程并发跑心想这样效率最高。结果上线第一天就炸了数据时不时跳变、出现离谱的乱码值查日志发现偶尔还会丢包高峰时段整批数据对不上同一设备的模拟量和开关量不是同一个扫描周期的每天随机出现两三次通信异常重启程序就好根本抓不到现场以为是网线干扰、交换机问题换了工业线、加了隔离一点用没有整整排查了半个月从协议层抓到应用层最后才发现工业PLC采集根本不能照搬互联网的并发思路。绝大多数嵌入式PLC的通信栈都是单线程串行处理的并发请求上去轻则响应乱序、数据错位重则直接丢帧、截断响应看起来就是“乱码”。最后重构了整套采集架构采用单设备串行队列 请求批量合并的标准工业级设计上线后连续跑了两个多月零乱码、零丢包延迟还比之前并发方案低了近一半。今天把这套架构完整分享出来做工业数据采集、上位机开发的朋友照着搭至少能省半个月的现场踩坑时间。一、为什么工业采集不能盲目并发很多做惯了Web开发的程序员刚接触工业采集第一反应都是“多线程并发快”。但工业场景和互联网场景完全不一样并发采集90%以上都会出问题根源在三个地方。1. PLC通信栈的硬件天花板绝大多数中小型PLC的CPU都是嵌入式级别的通信处理能力非常弱。比如S7-1200默认同时只能处理3-5个通信请求超过之后就会排队、丢弃甚至直接复位连接。你这边开十几个线程并发发请求服务器端根本处理不过来响应乱序、丢帧是常态。很多时候你收到的“乱码”本质就是两个响应帧拼到一起或者只收到了半帧。2. 协议本身的串行属性Modbus RTU纯半双工协议同一总线同一时间只能有一个请求并发直接撞帧谁都收不到完整数据S7协议底层虽然是TCP但应用层是单工处理一个请求没响应完下一个请求只会被缓存或者丢弃OPC UA相对好一些但嵌入式服务器的并发能力也非常有限超过阈值性能指数级下降3. 数据一致性完全没法保证工业采集很多时候要求同一批数据是PLC同一个扫描周期的比如压力和对应的温度要能对上。并发请求下每个请求的响应时间不确定拿到的可能是前后好几个扫描周期的数据拼出来的工艺分析的时候根本没法用。二、核心架构单设备串行 请求合并这套架构的核心思想很简单对单台设备同一时间永远只有一个请求在途在此基础上把零散的小请求合并成批量请求既保证稳定又保证效率。设计原则设备级隔离每台PLC对应一个独立的串行队列一台设备出问题、阻塞了完全不影响其他设备请求合并同一设备、同一存储区的连续地址自动合并成一次批量读取减少交互次数异步调度全部用异步队列实现不阻塞业务线程也不用重量级的锁可观测每个队列的长度、成功率、延迟都可监控出问题一眼就能定位三、单设备串行队列实现串行队列是整个架构的基石保证单设备绝对串行不并发。用TaskCompletionSource实现异步化业务层调用是异步的底层是串行执行的体验和并发一样好。队列核心结构每个设备对应一个队列实例内部用ConcurrentQueue存请求单线程消费保证顺序public class PlcDeviceQueue { private readonly ConcurrentQueueReadRequest _requestQueue new(); private readonly SemaphoreSlim _signal new(0); private readonly IPlcClient _plcClient; private readonly RequestMerger _merger; private bool _isRunning; // 设备级配置 public string DeviceId { get; } public int Timeout { get; set; } 1000; public int RetryCount { get; set; } 2; public PlcDeviceQueue(string deviceId, IPlcClient plcClient) { DeviceId deviceId; _plcClient plcClient; _merger new RequestMerger(); // 启动消费线程 _isRunning true; _ Task.Run(ConsumeLoop); } // 对外异步读取接口 public TaskPlcResult ReadAsync(string address, DataType type) { var request new ReadRequest { Address address, DataType type, Tcs new TaskCompletionSourcePlcResult() }; _requestQueue.Enqueue(request); _signal.Release(); return request.Tcs.Task; } }消费循环与重试机制单线程循环消费失败自动重试区分临时错误和致命错误连续失败触发熔断private async Task ConsumeLoop() { while (_isRunning) { await _signal.WaitAsync(); // 取出当前所有待处理请求交给合并器 var batch new ListReadRequest(); while (_requestQueue.TryDequeue(out var req)) { batch.Add(req); } if (batch.Count 0) continue; try { // 请求合并 批量读取 var mergedRequests _merger.Merge(batch); foreach (var merged in mergedRequests) { var result await ExecuteWithRetry(merged); _merger.SplitResult(merged, result); } } catch (Exception ex) { // 全部请求标记失败 foreach (var req in batch) { req.Tcs.TrySetException(ex); } // 连续失败触发熔断 CheckFuse(); } } } private async Taskbyte[] ExecuteWithRetry(MergedRequest request) { for (int i 0; i RetryCount; i) { try { return await _plcClient.ReadBytesAsync( request.StartAddress, request.Length, Timeout); } catch when (i RetryCount) { await Task.Delay(50 * (i 1)); } } throw new TimeoutException($设备{DeviceId}读取失败已重试{RetryCount}次); }设备隔离的优势这是工业场景非常重要的一点如果用全局线程池并发某一台PLC断网了大量请求超时堆积很容易把整个线程池占满导致所有设备都卡住。而每台设备独立队列最多就是自己的队列堆住完全不影响其他设备的正常采集故障影响面降到最小。四、请求合并器串行也能比并发快很多人担心串行会慢其实工业采集中90%的时间都消耗在网络交互和协议栈处理上真正数据传输的时间可以忽略。把10个零散请求合并成1个批量请求交互次数减少90%效率反而比并发高得多。合并规则不是所有请求都能合并必须同时满足几个条件同一台设备、同一个存储区比如都是DB1或者都是输入区地址是连续的或者间隔在设定范围内数据类型相同读取方式一致在合并时间窗口内默认10ms到达的请求合并器实现核心是地址聚合和结果拆分用S7的DB块读取举例public class RequestMerger { private const int MergeWindowMs 10; private const int MaxGap 10; // 地址最大间隔超过就不合并 public ListMergedRequest Merge(ListReadRequest requests) { var result new ListMergedRequest(); // 按存储区分组 var groups requests.GroupBy(r GetArea(r.Address)); foreach (var group in groups) { // 按起始地址排序 var sorted group.OrderBy(r GetStartOffset(r.Address)).ToList(); // 贪心算法合并连续地址 var current new MergedRequest(); foreach (var req in sorted) { if (current.Requests.Count 0) { current.StartAddress req.Address; current.Length GetByteLength(req.DataType); current.Requests.Add(req); } else { int gap GetStartOffset(req.Address) - (GetStartOffset(current.StartAddress) current.Length); if (gap 0 gap MaxGap) { // 可以合并更新总长度 current.Length GetStartOffset(req.Address) GetByteLength(req.DataType) - GetStartOffset(current.StartAddress); current.Requests.Add(req); } else { result.Add(current); current new MergedRequest(); current.StartAddress req.Address; current.Length GetByteLength(req.DataType); current.Requests.Add(req); } } } if (current.Requests.Count 0) result.Add(current); } return result; } }结果拆分批量读取回来的字节数组按照每个原始请求的偏移量拆分分别设置结果public void SplitResult(MergedRequest merged, byte[] data) { int baseOffset GetStartOffset(merged.StartAddress); foreach (var req in merged.Requests) { int reqOffset GetStartOffset(req.Address) - baseOffset; int length GetByteLength(req.DataType); var slice new byte[length]; Array.Copy(data, reqOffset, slice, 0, length); // 解析成对应类型设置Task结果 var value ParseValue(slice, req.DataType); req.Tcs.TrySetResult(new PlcResult(value, true)); } }实际项目中100个连续点位的读取合并后只需要1次请求延迟从几百毫秒降到几十毫秒效果非常明显。五、工业级增强特性能跑起来只是基础工业现场要的是稳定跑还要有几个必备的容错机制。1. 优先级队列控制命令、报警信号的优先级要高于普通采集数据。队列里分优先级高优先级的请求可以插队保证控制指令实时性高优先级写操作、报警点位、控制信号普通优先级工艺数据、状态采集低优先级历史数据补读、非关键参数2. 熔断机制连续失败超过阈值比如5次自动熔断一段时间比如30秒队列直接拒绝新请求避免无限堆积。熔断期间后台尝试心跳检测恢复了自动解除熔断。这对现场经常断网、设备停机的场景非常有用不会因为一台设备挂了队列越堆越多把内存撑爆。3. 队列长度限制与溢出策略每个队列设置最大长度比如1000条超过之后按照策略丢弃旧请求或者拒绝新请求。工业采集数据都是实时的堆积太久的旧数据已经没有意义了丢了比堆着强。4. 自动重连与恢复设备通信断开后自动按间隔重试重连重连成功后队列自动恢复消费不需要上层业务干预。整个过程对业务层透明不用写一堆重连回调。六、实际落地效果对比我们产线16台PLC、580个点位改造前后的对比如下指标多线程并发方案串行队列合并方案平均读取延迟120-850ms波动极大45-70ms非常稳定数据错误/乱码率约2.7%0%单台设备最大并发8个线程1个在途请求CPU占用率约35%约12%日均异常次数2-5次0次数据一致性跨扫描周期不可控同批次同一周期上线两个多月除了两次PLC停机维护采集服务全程零故障数据准确率100%。而且因为架构清晰每台设备的状态、延迟、成功率都能监控运维排查问题非常快。七、最容易踩的3个误区1. 串行一定比并发慢大错特错。工业采集的瓶颈在交互次数不在计算。合并后的串行批量读取效率远高于零散并发请求。点位越多串行合并的优势越大。2. 多开几个连接就能解决并发问题PLC的连接数是有上限的而且每个连接都要占用服务器资源。多开连接只会让服务器更忙死得更快。真正的优化是减少请求次数不是增加连接数。3. 乱码都是网线或者硬件的问题90%的偶发乱码、丢包都是通信层的问题并发撞帧、响应乱序、帧截断。先查架构和协议逻辑再怀疑硬件。很多人上来就换网线、换交换机折腾半天一点用没有。最后工业数据采集永远是稳定第一效率第二。互联网那套高并发思路放到嵌入式工业设备上根本行不通。串行队列请求合并是工业网关、采集软件行业通用的标准架构经过了几十年现场验证。不管是S7、Modbus还是OPC UA底层都可以套用这个思路稳定性和可维护性都比盲目并发高一个量级。如果你的项目也遇到了多设备采集乱码、丢包、不稳定的问题不妨照着这个架构重构一下基本都能解决。