VB.NET实现TCP/IP通讯转发服务:从架构设计到稳定部署

VB.NET实现TCP/IP通讯转发服务:从架构设计到稳定部署 简介在工业数据采集与设备联网场景中TCP/IP通讯是承载海量设备数据交互的基础协议而如何高效、稳定地把不同来源的协议数据汇聚并转发到上层系统则是许多工程团队面临的核心共性需求。基于Socket编程原理转发服务需要妥善处理连接管理、粘包半包、并发读写以及异常恢复等关键环节才能实现数据不丢失、链路不断线。这类设备接入层与数据转发网关的技术价值在于通过统一协议转换和路由分发大幅降低MES等平台对接异构设备的复杂度。在实际应用中无论是工厂车间老设备改造还是多目标系统数据同步都依赖一套健壮的TCP转发引擎。本文以VB.NET技术栈为例从模块划分、队列缓冲、断线重连、Windows服务化部署到压测调优完整拆解了一套生产级转发服务的设计思路与工程实践可帮助开发者快速搭建类似的数据汇聚与转发系统。 设备通讯转发这块需求在工控圈里太常见了但网上能讲得透彻的资料其实不多。大部分教程要么只讲单客户端收发demo要么上来就扔一堆代码让人自己看真正能告诉你“为什么这么设计”的文章少得可怜。我这次用VB.NET做了一套TCP/IP通讯转发程序从需求拆解到编码实现再到压测部署全程走了一遍踩了不少坑也沉淀了一些可复用的设计思路。这篇就把完整的实现思路和经验教训写出来给要做类似功能的朋友做个参考。先说下场景方便你判断这篇东西适不适合你。我这边是工厂车间里的设备数据采集项目大概几十台老旧设备通讯协议五花八门但底层都是TCP/IP承载。上层MES系统需要实时拿设备状态和生产数据但MES不想对接每一台设备所以中间必须加一层转发服务把设备的协议数据统一汇聚再转给MES。除了数据汇聚转发还要求断线重连、多设备并发接入、数据不丢失、以及服务化部署。这套东西用VB.NET实现核心功能全部跑通稳定运行了大半年。如果你正打算搞类似的设备接入层、数据转发网关、或者协议汇聚服务这篇文章可以直接当参考。我会把程序结构怎么分、核心模块怎么写、并发怎么处理、断线怎么扛、压测怎么做、服务怎么部署全部拆开讲清楚。1. 项目背景与选型思路动手写代码之前先把为什么选VB.NET这件事说透。很多人一听说VB.NET就觉得是老掉牙的技术但实际上在工业自动化这个圈子里VB.NET的存量项目多到超出想象。很多设备厂商提供的DLL、SDK示例、老系统的维护代码都是VB.NET甚至VB6写的你要是跟现场老师说“我给你换C#重写”他大概率会摇头因为后续维护没人敢接。我这次选VB.NET技术栈主要是三个原因现场已有一套VB.NET写的数据采集程序在跑新模块直接复用同一个运行环境和部署方式降低运维成本。老服务器是Windows Server 2008 R2.NET Framework 4.0环境VB.NET支持得非常成熟稳定。团队里负责维护的同事最熟的就是VB.NET没必要为了技术上的“高级感”引入团队不熟悉的新栈。当然如果你是从零起步、没有历史包袱那选C#或Java甚至Go都完全没问题TCP/IP通讯本身跟语言无关。但在我这个场景里“顺势而为”比“推倒重来”更符合现场实际情况。那这套转发程序到底要干什么我用一句话概括设备侧通过TCP接入转发服务统一接收再按预设规则分发到目标侧MES、数据库服务、第三方平台等并在中间完成连接管理、数据缓冲、断线重连、日志记录这些脏活累活。具体功能清单如下设备接入管理支持TCP Client接入模式即设备主动连本服务。数据转发路由一条设备数据可以转发到多个目标地址。连接状态维护设备连接、目标连接全生命周期管理心跳检测与自动重连。并发处理能力多台设备同时在线收发数据互不阻塞。运行日志与监控设备上下线、数据收发、异常信息都要可追溯。这套东西本质上就是一个轻量级的消息中间件只是消息源从应用程序变成了TCP设备消息去向从消息队列变成了具体的目标Socket服务。2. 程序整体结构与模块划分转发服务最忌讳的就是“一个大类干所有事”。如果所有代码都堆在一起前期设备少可能看不出来问题等设备一多、链路一复杂改个功能都要动全身那维护成本直接起飞。所以我在编码前就把模块边界划清楚了。2.1 解决方案的项目分层整个解决方案按职责拆成下面几个独立模块DeviceSocketManager设备接入层负责监听设备连接、管理设备Socket会话。ForwardTargetManager转发目标层负责维护到目标系统的长连接、执行数据发送。DataParser协议解析层负责把原始字节流转成完整的数据帧。DataQueue数据缓冲层缓存待转发的数据削峰填谷。Logger日志服务统一记录运行日志、通讯日志、错误日志。ConfigManager配置管理用XML文件保存设备信息、转发规则、连接参数。ServiceMain程序入口启动和协调各模块。每个模块都是独立的类通过公开方法被外层调用。模块之间不共享内部状态只通过明确的数据结构交互。这样做的直接好处是每一层都可以单独测试排查问题时能快速定位到某一层出了问题。2.2 核心数据流向一条设备数据从进来到出去整体流程是这样的设备A通过TCP连接上报原始数据 - DeviceSocketManager接收字节流 - DataParser按协议拆包得到一条完整数据帧 - 数据帧被写入DataQueue队列 - ForwardTargetManager从队列取出数据根据配置查找转发目标 - 通过对应的目标连接把数据发送到MES或其他系统。这个流程中DataQueue队列是核心枢纽。我坚持要加这个队列不是为了显得架构复杂而是为了解决三个实际问题设备生产和目标消费的速度天然不匹配设备突发数据时队列能缓冲压力。目标端暂时掉线时数据可以暂存在队列里等连接恢复再补发避免直接丢弃。多个设备同时上报时队列能让转发过程有序进行避免并发写目标连接导致帧交叉错乱。2.3 配置驱动模式我这个程序的另一个设计原则是“配置驱动代码不硬编码”。设备的IP、端口、协议格式、转发目标、启用状态全部放在一个XML配置文件里。程序启动时加载配置运行中读取配置做路由和转发。这样做的好处经历过现场交付的朋友应该秒懂。设备数量变化、目标IP改了、某个设备暂时停用全都不用改代码重新编译直接编辑配置文件然后重启服务就行。配置文件的结构简化后大概是这样的config listeners listener nameDeviceListener port9000/ /listeners devices device idDEV001 namePLC-01 protocolmodbus-tcp/ device idDEV002 namePLC-02 protocolmodbus-tcp/ /devices targets target nameMES host192.168.1.100 port5000 protocoltcp-client/ /targets forwardRules rule deviceIdDEV001 targetMES enabledtrue/ rule deviceIdDEV002 targetMES enabledtrue/ /forwardRules /config这里有个细节我特意把“设备”和“转发规则”分开配置。因为实际场景里不是每台设备的数据都要往同一个目标转发有些设备的数据要发MES有些设备的数据要发报表系统。设备、目标、规则三层解耦让数据路由变得非常灵活。3. 核心模块与关键代码实现这一章是全文的核心部分我会把几个关键模块的实现思路和核心代码逻辑讲清楚。先说明一下我不会整段源码全贴出来那会把文章拖得很长也没必要而是把最关键的链路摘出来讲透。3.1 SocketListener设备接入模块设备接入是整个转发服务的入口我使用TcpListener在指定端口上监听设备连接。端口号从配置读取监听地址用IPAddress.Any这样设备无论是从哪个网卡进来的都能连上。Dim listener As New TcpListener(IPAddress.Any, _port) listener.Start() While _isRunning Try Dim client As TcpClient Await listener.AcceptTcpClientAsync() Dim clientId As String client.Client.RemoteEndPoint.ToString() AddClientSession(clientId, client) Task.Run(Sub() HandleClient(client, clientId)) Catch ex As Exception Logger.Error(Accept client error: ex.Message) End Try End WhileAcceptTcpClientAsync这个异步等待让我避开了一个大坑如果我用同步的AcceptTcpClient主循环会阻塞在等待连接上但单连接阻塞其实还好真正麻烦的是在处理某个连接时如果网络卡顿监听线程被拖住新的设备连接就会排队。用异步方式每次新连接进来立即交个独立任务处理监听循环立刻回到等待下一个连接的状态互不干扰。还有一点Task.Run里的HandleClient内部必须有完整的Try Catch包住绝不能让异常逃逸到后台任务里。后台任务里的未处理异常在大多数情况下会导致进程直接崩溃对转发服务来说这是重大事故。3.2 DataParser数据拆包模块设备数据接收最核心的问题不是“怎么收”而是“怎么知道一条完整的数据到齐了”。TCP是流协议字节流本身没有边界。设备发一帧数据网络层可能把它拆成两个包发过来也可能把好几帧合并成一个包发过来这就是常说的粘包半包问题。解决粘包半包的唯一可靠办法就是利用应用层协议的帧格式来拆包。我这边对接的设备协议帧结构是帧头(2字节0xAA55) 设备地址(1字节) 命令字(1字节) 数据长度(2字节) 数据体 校验(2字节CRC16)。拆包模块的核心逻辑是维护一个字节缓冲区收到新数据先追加进去然后循环扫描找帧头找不到就丢弃一个字节继续找。找到帧头后读取数据长度字段判断缓冲区里的字节数是否满足“帧头长度字段数据体校验”的总长。满足则切出一帧剩余数据继续下一轮解析。Private _buffer As New List(Of Byte) Private Sub OnDataReceived(ByVal data() As Byte) SyncLock _buffer _buffer.AddRange(data) TryExtractFrames() End SyncLock End Sub Private Sub TryExtractFrames() While _buffer.Count 6 If _buffer(0) HAA AndAlso _buffer(1) H55 Then Dim dataLen As Integer (_buffer(2) 8) Or _buffer(3) If _buffer.Count 4 dataLen 2 Then Dim frame(4 dataLen 1) As Byte _buffer.CopyTo(0, frame, 0, 4 dataLen 2) _buffer.RemoveRange(0, 4 dataLen 2) ProcessFrame(frame) Else Exit While End If Else _buffer.RemoveAt(0) End If End While End Sub这段代码最需要注意的一个细节是设备地址和命令字各占1字节但如果协议里某个字段用了多字节拆包时就要考虑大小端Big Endian / Little Endian的问题。不同设备厂商对多字节字段的字节序定义不一样有的高位在前有的低位在前解析错了整个长度字段就废了。这个字段解析必须跟设备协议文档校准清楚。另外我在拆包模块里加了SyncLock保护缓冲区。因为设备Socket接收线程和拆包逻辑之间可能涉及并发访问缓冲区加锁后就能保证同一时刻只有一个线程操作缓冲区避免数据错乱。3.3 ForwardTargetManager转发目标管理转发目标管理模块维护本服务到目标系统的长连接。在我的场景里MES系统是TCP Server所以本服务需要作为TCP Client主动连接MES。目标连接的管理不能是“用一次连一次”那是短连接模式在工业数据采集场景下效率太低。TCP三次握手和四次挥手都是有开销的设备数据本身就是高频上报每帧数据都重建连接系统开销全部浪费在网络握手上了。我实现的是一个带自动重连的长连接Public Class TargetConnection Private _tcpClient As TcpClient Private _host As String Private _port As Integer Private _isRunning As Boolean Private _sendLock As New Object Public Sub Connect() While _isRunning Try _tcpClient New TcpClient() _tcpClient.Connect(_host, _port) Logger.Info([Target] Connected to _host : _port) StartReceiveThread() Exit While Catch ex As Exception Logger.Warn([Target] Connect failed: ex.Message , retry in 5s...) Thread.Sleep(5000) End Try End While End Sub Public Function SendData(ByVal data() As Byte) As Boolean SyncLock _sendLock Try If _tcpClient IsNot Nothing AndAlso _tcpClient.Connected Then Dim stream As NetworkStream _tcpClient.GetStream() stream.Write(data, 0, data.Length) stream.Flush() Return True End If Catch ex As Exception Logger.Error([Target] Send error: ex.Message) Reconnect() End Try Return False End Function End Class这段代码里有几个设计点很关键_sendLock是发送锁。多线程同时往同一个Socket写数据如果没有锁保护两个线程的字节流可能交叉写入目标端解析出来的就是乱帧。这个锁是必须的不是可选项。发送失败时立即触发重连而不是等定时器慢慢扫到。这样可以缩短故障窗口期让转发服务更快恢复。Connect方法里的while循环不能在UI线程跑需要在后台线程调用否则会卡死界面或主循环。3.4 DataQueue数据缓冲队列队列模块是整个程序的“心脏”也是防止数据丢失的核心保障。我使用的是ConcurrentQueue(Of Byte())这是一个线程安全队列多个设备线程可以并发往里写数据一个或多个转发线程从队列里取数据发送。Private _dataQueue As New Concurrent.ConcurrentQueue(Of Byte()) Private _forwardThread As Thread Private Sub StartForwardThread() _forwardThread New Thread(AddressOf ForwardLoop) _forwardThread.IsBackground True _forwardThread.Start() End Sub Private Sub ForwardLoop() While _isRunning Dim data() As Byte If _dataQueue.TryDequeue(data) Then TryForward(data) Else Thread.Sleep(10) End If End While End Sub转发线程在队列为空时睡眠10毫秒再进入下一轮循环。10毫秒这个值不是拍脑袋定的太短会导致CPU空转浪费资源太长会增加数据转发延迟。我实测下来10毫秒在几十台设备的场景下延迟和CPU占用达到了很好的平衡。有人可能担心Thread.Sleep(10)会影响转发吞吐量。其实不会因为队列里没数据时本来就没事干有数据时TryDequeue会立即返回睡眠逻辑只在队列空闲时才执行吞吐量瓶颈在Socket发送而不在队列轮询。4. 多客户端并发与数据隔离设备一多并发问题就躲不掉。我在最开始做设计方案时就在想如果每台设备一个线程那50台设备就是50个线程Windows下线程切换开销会不会成为瓶颈实际测试下来50台设备的数据量对现代服务器来说连皮毛都算不上但共享资源的并发访问问题却是实打实的。4.1 设备Session会话管理每台设备接入服务后我都会在内存里建立一个ClientSession对象用来保存这台设备的所有运行时状态Socket连接、最近活跃时间、数据缓冲区、连接ID等。Private _sessions As New Dictionary(Of String, ClientSession) Private Function GetOrCreateSession(ByVal clientId As String, ByVal client As TcpClient) As ClientSession SyncLock _sessions If Not _sessions.ContainsKey(clientId) Then Dim session As New ClientSession(clientId, client) _sessions.Add(clientId, session) End If Return _sessions(clientId) End SyncLock End FunctionSession管理这里有个隐蔽的坑——设备断线重连后旧Session如果没清理干净新连接建立时会拿到残留的旧状态导致数据错乱或缓冲区残留脏数据。所以连接关闭时必须同步从字典中移除Session并清空缓冲区。为了保证字典的线程安全所有对_sessions的读写操作都在SyncLock保护下进行。这里不能用普通的Dictionary然后指望它自己线程安全并发读写Dictionary会直接抛异常。4.2 每个连接独立接收线程设备接入后每个连接由一个独立的异步接收循环处理。核心逻辑是循环从NetworkStream读取数据每读到一段字节流就交给拆包模块读到0字节或抛异常则说明连接断开退出循环并清理Session。Private Async Function HandleClientAsync(ByVal client As TcpClient, ByVal clientId As String) As Task Using client Dim buffer(4095) As Byte Dim stream As NetworkStream client.GetStream() While _isRunning Try Dim readCount As Integer Await stream.ReadAsync(buffer, 0, buffer.Length) If readCount 0 Then Exit While Dim received(readCount - 1) As Byte Array.Copy(buffer, received, readCount) OnDataReceived(clientId, received) Catch ex As Exception Logger.Error([Client clientId ] read error: ex.Message) Exit While End Try End While End Using RemoveSession(clientId) End Function这段代码有几个值得展开的点使用ReadAsync而不是同步Read好处是数据没到达时不占用线程线程可以返回线程池。在这个场景下每个设备连接都占一个线程的话几十个连接就是几十个线程空等虽然线程池能扛但没必要白占资源。异步读取让程序的并发上限提高了不止一个量级。缓冲区大小设置为4096字节是基于我这边设备协议的最大帧长来的。如果你的设备单帧数据量很大有些视觉系统一帧就几十KB4096就太小了ReadAsync一次读不完一帧要用循环拼包。这个缓冲区大小要根据实际业务数据量来定。连接断开后用Using语句确保TcpClient释放。这里有个容易忽略的点RemoveSession必须在连接断开路径里被调用否则Session字典会累积已断开的连接时间长了造成“幽灵设备在线”的假象。4.3 数据隔离设备ID贯穿全链路数据隔离的重点不在于代码而在于数据建模。多台设备的数据都汇聚到转发服务如果只转发原始字节流目标端根本不知道这条数据是哪个设备来的那这套转发就没有意义。我的方案是在内部数据结构中用一个自定义类ForwardData包含设备ID、原始数据帧、时间戳等元数据。设备数据进来时打上设备ID标签入队的是ForwardData对象而不是裸字节数组。转发时根据设备ID查询转发规则找到目标地址后再把原始数据帧发送出去。Public Class ForwardData Public Property DeviceId As String Public Property Payload As Byte() Public Property Timestamp As DateTime End Class还有一个细节是队列要按设备分组。如果不分组所有设备的数据都进同一个队列一台设备突发大量数据就会把其他设备的数据堵在后面。我做了按设备分组的队列每个设备一个独立队列转发线程轮询所有设备的队列。这样一台设备的问题不会拖垮其他设备的转发线路数据隔离做到了进程级以下。5. 断线重连与异常恢复通讯程序离不开断线重连这是基本功但想把基本功做好其实有不少讲究。设备断线、目标系统重启、网络闪断这些都是工业现场的日常。如果重连策略设计不合理故障恢复时反而可能引发二次灾难。5.1 设备连接断线检测机制设备连接断开有两种典型场景正常关闭设备发FIN包服务端Read返回0立即感知。异常掉线设备断电、网线拔出、设备死机TCP层不会立刻通知服务端服务端Read会一直阻塞。第二种场景最坑设备物理上已经消失了服务端却还以为它在线。TCP本身有KeepAlive机制但默认的探测周期太长可能是几小时对工业实时监控来说完全不够用。我采用“心跳超时主动断开”机制来解决在ClientSession里记录最近活跃时间每收到一次数据就刷新这个时间。后台心跳检测线程每5秒遍历一次所有Session发现超过30秒没有数据更新的连接就强制关闭并清理Session。Private Sub HeartbeatCheckLoop() While _isRunning Thread.Sleep(5000) SyncLock _sessions Dim now As DateTime DateTime.Now Dim expiredKeys As New List(Of String) For Each kvp In _sessions If (now - kvp.Value.LastActiveTime).TotalSeconds 30 Then expiredKeys.Add(kvp.Key) End If Next For Each key In expiredKeys Logger.Warn([Device key ] heartbeat timeout, closing session.) _sessions(key).Close() _sessions.Remove(key) Next End SyncLock End While End Sub这个机制上线后立即解决了“幽灵设备”问题。很多现场设备断电后不会发FIN包没有心跳检测的话在线设备数会越累越多最后把服务端的连接数占满。5.2 目标端断线重连的退避策略目标端比如MES系统宕机或重启时如果所有设备的数据还在持续上报转发服务会不断尝试重连目标。这里有一个严重的潜在问题——重连风暴。所谓重连风暴是指目标系统恢复运行时所有等待重连的客户端在瞬间同时发起连接请求目标系统可能接受不过来重新被打挂。为了避免这种情况重连必须采用“指数退避随机抖动”策略。Private Function GetRetryInterval(ByVal retryCount As Integer) As Integer Dim base As Integer 1000 * Math.Min(Math.Pow(2, retryCount), 60) Dim jitter As Integer New Random().Next(0, 500) Return CInt(base) jitter End Function指数退避让重连间隔从1秒、2秒、4秒、8秒一路涨上去上限60秒加上0到500毫秒的随机抖动把各客户端的重连时间点错开。这样即使几十个客户端同时断线重连请求也不会在同一个瞬间全部打到目标系统上。我实际生产中遇到过MES系统升级重启按这个策略整个转发服务在MES恢复后大约1-2分钟内完成全部重连期间数据按队列策略缓冲业务没有受明显影响。5.3 数据不丢失的保证机制“数据不丢失”这件事必须说清楚边界。我实现的保证是“尽力传输失败重试”跟消息中间件那种严格的事务性消息不是一回事。具体机制是这样转发线程从队列取出数据后尝试发送到目标端。如果发送失败数据不会直接丢弃而是重新放回队列头部等待目标连接恢复后补发。如果目标端长时间不可用队列会持续积压。我做了一个保护阈值积压超过10000条时丢弃最老的数据并输出紧急告警日志。防止内存无限增长把服务拖垮。Private Sub TryForward(ByVal data() As Byte) Dim success As Boolean _targetManager.SendData(data) If Not success Then _dataQueue.Enqueue(data) Logger.Warn([Forward] Target unavailable, data queued for retry.) End If End Sub这里有一个经验教训补发机制必须加“补发速率限制”。刚开始我的补发策略是目标连接一旦恢复队列里积压的所有数据在瞬间全部发送。结果MES那边直接处理超时反而把刚恢复的连接又搞断了。后来我加了速率限制重连成功后的前1分钟内每秒最多补发200条数据超过的继续排队。这个机制让目标系统有足够时间逐步消化积压的数据补发完成后系统稳定进入正常转发状态。6. Windows服务化部署与运维程序写完功能测试通过接下来就是部署问题。如果只是拿控制台窗口跑着服务器一重启程序就没了或者要手动登录去启动那就太业余了。转成Windows服务是必须的一步。6.1 控制台程序改造成Windows服务开发调试阶段我用控制台模式跑这样看日志方便改完代码F5就能跑。功能稳定后再改造成Windows服务。VB.NET改造Windows服务的方法比较直接在项目中添加一个继承自ServiceBase的类重写OnStart和OnStop方法把主逻辑的启动和停止封装进去。Public Class TcpForwardService Inherits ServiceBase Private _forwardEngine As ForwardEngine Protected Overrides Sub OnStart(ByVal args() As String) _forwardEngine New ForwardEngine() _forwardEngine.Start() End Sub Protected Overrides Sub OnStop() _forwardEngine.Stop() End Sub End Class这里有个必须注意的坑——OnStart方法必须在30秒内返回。Windows服务管理器在30秒内等不到OnStart返回就认定服务启动失败。所以Start方法里只能启动后台线程然后立即返回绝不能直接在OnStart里跑阻塞式的监听循环。我第一版代码就踩了这个坑服务怎么都启动不了后来才意识到是线程阻塞了。6.2 安装与开机自启Windows服务的安装有两种常用方法InstallUtil.exe和sc命令。我推荐用sc命令更简洁不依赖额外的安装项目。sc create TcpForwardService binPath C:\Services\TcpForwardService.exe start auto sc start TcpForwardService提醒一下sc命令的binPath和start后面等号后必须有一个空格这是Windows命令行历史上最经典的老坑。等号前面不能有空格等号后面必须有空格写错了服务是创建不成功的。创建服务时指定start auto开机就能自启。如果现场有多个服务器节点可以用组策略或运维平台统一分发服务配置实现批量部署。6.3 日志管理与故障排查日志是通讯程序的救命稻草。设备通讯这类程序一旦出问题没有日志几乎无法排查因为数据流转过程是看不见摸不着的全靠日志还原现场。我这边分了三种日志文件各司其职运行日志程序启动、停止、配置加载、模块初始化等信息记录程序生命周期。通讯日志设备连接、断开、数据收发记录带设备ID和数据帧摘要排查通讯问题主要看这个。错误日志异常堆栈、发送失败、重连告警等信息第一时间定位故障点。日志文件按天切割每天一个文件保留最近30天自动清理。代码里用文件写锁避免多线程同时写日志导致内容交错。上线初期我排查问题有个固定套路先看错误日志有没有新异常再翻通讯日志看设备连接状态最后比对配置文件和实际环境是否匹配。这套流程下来95%的问题能在10分钟内定位。这里给大家一个额外建议建议加一个运行状态接口用HttpListener监听本机8080端口返回当前设备连接数、队列积压量、目标连接状态、运行时长等指标。这样Zabbix或其他监控系统可以直接拉取数据做告警异常时提前发现而不是等现场报障了才后知后觉。7. 压力测试与性能调优通讯服务上线之前压测这关必须过。我自己当时在测试环境里模拟了50台设备同时连接每台设备每秒上报一条数据连续跑了一个通宵期间还人为断开部分设备模拟断网场景。结果暴露出不少问题。7.1 第一轮压测的意外瓶颈第一轮压测结果出乎意料CPU占用率只有5%左右内存也不高但数据转发延迟波动很大有些数据从入队到发出竟然延迟超过2秒。排查之后发现瓶颈不在CPU也不是网络带宽而是转发线程的队列轮询模式。当时我还是单转发线程每10毫秒轮询一次队列队列为空就睡眠10毫秒。当大量数据同时涌入时单线程处理不过来形成了排队效应延迟自然飙升。解决方案是增加转发线程数从1个增加到3个。多个转发线程同时从同一个ConcurrentQueue取数据队列的线程安全机制保证一条数据只会被一个线程拿到。调整后压测最大延迟降到了200毫秒以内效果非常明显。但多转发线程并发发送到同一个目标时必须确保同一时刻只有一个线程在写目标Socket否则数据帧交叉写入目标端解析必挂。这个我提前在TargetConnection里加了_sendLock保护所以多线程转发到同一个目标也是安全的。假如当初没加锁现在就得在转发调度层做目标锁了那代码会复杂很多。7.2 缓冲区与内存控制内存控制的核心是防止“数据只进不出”。目标端长期宕机时队列疯狂积压内存会一路涨上去。我做了一个两级保护队列长度超过5000条时输出告警日志。队列长度超过20000条时丢弃最老的数据并把告警级别提升为紧急。这个阈值是根据目标端恢复能力来定的。20000条数据如果每秒补发200条100秒能清空目标端基本能扛住。超过这个量要么目标系统已经挂了很久要么设备端数据异常该降级就降级保服务保内存更重要。Socket缓冲区大小方面接收缓冲区我用4096字节。有人可能觉得4096太小但缓冲区大小其实取决于设备单帧数据长度。我的设备协议最长也就1KB多4096完全够。如果设置得太大反而会在设备突发数据时一口气吃掉很多内存几十个连接放大下来就是几百MB。另外我把ReceiveBufferSize和SendBufferSize显式设为8192。默认值在设备频繁发送小数据包时会引起较多的系统调用开销调大一点能略微优化收发效率实测稳定性和系统占用都有改善。7.3 长时间运行稳定性验证压测不只是看CPU和内存更要看长时间运行的资源占用趋势。我做了两个针对性测试长期运行测试连续跑48小时观察内存有没有持续增长、线程数有没有波动、句柄数有没有泄漏趋势。极端故障测试模拟目标端MES系统宕机5分钟再恢复观察数据补发是否正常、队列是否无限积压、恢复后有没有大量数据瞬间涌入把目标端打崩。这两个测试帮我发现了一个很隐蔽的问题设备断线后如果Session不主动清理TCP连接对象和关联的线程资源不会被GC及时回收长时间运行后句柄数持续爬升。修复Session清理逻辑后24小时测试的句柄数曲线基本走平。这个教训也让我的代码规范多了一条凡是持有Socket或Stream的类Dispose路径必须完整不能依赖GC兜底。8. 上线后的踩坑记录上线后遇到的一些问题属于那种文档里不会写、光看代码也发现不了的类型。这里整理几条最有代表性的给即将踩坑的朋友提个醒。8.1 假连接与半开连接问题上线第一周我发现一个奇怪的现象设备在线数一直在涨但实际生产中的设备数量没变。查通讯日志后发现有些设备的TCP客户端在断电重启后旧连接没有正常发FIN包断开服务端的接收线程还在阻塞等数据所以服务端以为设备还活着。这就是TCP的半开连接——连接双方没有数据往来时任何一方都感知不到对方已经消失。解决办法就是我前面提到的心跳超时机制设备超过30秒没数据就主动断开并清理Session。加了心跳之后在线设备数的统计数据终于和实际设备数对上了。8.2 粘包半包导致的解析错乱某次设备上报频率提高后服务端频繁出现解析失败。排查后确认是典型的TCP粘包半包问题设备连续发送多条报文时可能在一个TCP包内包含多帧也可能一条报文被拆成两个TCP包发过来。粘包半包问题的解决方案就是开头写的拆包逻辑利用协议帧头和数据长度字段。这里有一条很重要的经验拆包逻辑一定要在服务端做绝对不能依赖设备端“确保每次发送刚好是一帧”。工业现场的网络环境和设备实现五花八门服务端必须自己做好容错这是转发服务的职责边界。8.3 编码问题导致的乱码这个坑发生在我对接一个用文本协议JSON字符串上报数据的设备时。设备发送的JSON是UTF-8编码而目标MES系统那边期望的是GBK编码结果MES收到后全是乱码。处理方案是在转发模块加了一个可配置的编码选项每个目标连接都指定自己的编码方式默认UTF-8老系统配GBK。这个配置项让我在后续对接各种老接口时省了不少事因为很多第三方系统用的还是非UTF-8编码这种细节不提前约定好联调时绝对血压拉满。8.4 平滑重启机制程序改完配置后需要重启但直接重启服务意味着所有设备连接全部断开重连数据转发中断几十秒这是现场不能接受的。我后来在管理端加了一个平滑重启的触发点先停止接收新数据等待队列中的数据转发完毕再重新加载配置最后重新启动监听。整个过程中设备连接保持不断只是短暂暂停新数据的转发。这个窗口很短实测大概2-3秒就完成了配置热加载。对于大多数工业现场这个中断时间完全在可接受范围内。9. 写到最后的一些经验总结项目上线到现在已经稳定运行了差不多8个月中间经历了几次目标系统重启、设备批次更换、网络割接没有发生过一次数据丢失事故整体靠谱程度远远超出预期。回顾整个过程有几点经验想分享给做类似通讯转发项目的朋友。配置一定要外置不要硬编码。现场环境变起来非常快设备IP会变、端口会变、目标地址会换代码里硬编码的下场就是改一个IP就要重新发布一次程序运维成本极高。日志一定要齐全尤其是通讯日志。通讯程序的故障排查完全依赖日志还原现场没有日志的通讯程序就是黑盒出了问题只能干瞪眼。断线重连一定要有退避策略。指数退避加随机抖动可以避免目标系统恢复时的重连风暴这个策略在极端故障场景下救了我好几次。队列是必需品。设备生产和目标消费的速度天然不匹配队列就是削峰填谷的关键。没有队列的转发程序就像没有蓄水池的供水系统水压一大就爆管。多线程共享资源一定要加锁。TCP连接写入必须用锁保护否则两个线程同时写一个Socket数据帧交叉写入就是必然事故而且这种bug极难复现和排查。上线前一定做压测。特别是长时间运行稳定性和极端故障恢复测试。很多问题在开发环境根本暴露不出来只有在高并发、长时间、断网恢复这种极端条件下才会现形。压测越狠上线后睡得越安稳。如果你的项目也需要类似的通讯转发层希望这篇经验能帮你把关键的设计考量和坑位提前踩平。等你的程序跑顺了、日志齐了、重连稳了你会发现用VB.NET做TCP/IP通讯转发其实一点都不“老古董”。本文还有配套的精品资源点击获取