C#做物联网接入服务器,最容易陷入的尴尬就是:框架换了一个又一个,代码越写越重,设备数量一多还是顶不住。我之前接到一个“硬件数据接收示例”的需求,数万台设备通过TCP上报数据,不需要Web页面,也不接MQTT那套,就用自定义二进制协议,一台服务器尽量扛住。折腾完以后发现,真正靠谱的方案往往不依赖重型组件,反而要把Socket模型、协议解析、会话管理这几层写扎实。这篇文章把我打磨的一套C#轻量级高并发接收服务器源码完整复盘一遍,从模型选型到万级连接压测,附带我踩过的各种坑。
这套程序解决的是物联网接入层的核心问题:硬件设备接入、数据接收、自定义协议解析、指令下发,以及支持数万设备长连接。适合准备做设备接入网关、上位机后端的上位机工程师,也适合刚接触C#高并发Socket的新人。下面的内容主要建立在本机单进程基础上,先把单机压满,再谈集群扩展。
说清楚一个前提:标题里的“接收程序源码”指的是一个可以嵌入到你业务项目里的接入层,不是完整物联网平台。它管好“谁来连、怎么收、收完怎么交给业务、怎么保证连接不泄漏”,至于数据展示、告警、规则引擎,那是上层业务的事。把边界划清楚,代码才可能轻量。
1. 项目定位与方案选型:为什么轻量级接入层要自研而不是用网关框架
1.1 先弄清楚“高并发”到底并发在哪里
项目标题里强调“支持数万设备”“高并发”,很多人一看就以为要对标每秒几万QPS的互联网后端。实际做设备接入这么多年,我的体会是:设备上报的QPS通常不高,但“长连接数量”是实打实的压力源。
举一个真实场景:2万台温度采集终端,每30秒上报一次温度数据。算下来每秒大概不到700个包,处理起来毫无压力。但服务器上挂着2万条TCP长连接,每条连接都有自己的Socket句柄、内核收发缓冲、应用层会话对象。这一批对象常在常驻,加上心跳、掉线重连、半开连接检测,才是高并发真正的难点。
所以做这个项目时,我始终盯着三件事:句柄数不能涨、内存不能飞、GC不能频繁。而这三个指标里任何一个失控,都可能在设备量上来之后突然压垮服务。
另外,自定义协议意味着你拿不到“现成的开发包”,协议解析必须自己写。这部分和基于MQTT或者HTTP的做法完全不同,你需要自己设计帧格式、自己处理粘包半包、自己定义心跳机制。标题里那句“非Web端”就是在提醒你,别把Web服务的套路直接搬过来。
1.2 为什么非Web端要用Socket长连接,而不是HTTP、WebSocket或MQTT
这个选型问题几乎每次都会被问到。有些人习惯性用ASP.NET Core写一个接收接口,让设备POST JSON上来。小规模没问题,几十台设备调试完全够用,但放到数万设备场景,问题就出来了。
HTTP方式的问题:设备端MCU资源通常很有限,内存几十KB到几百KB的货色比比皆是。HTTP报文的请求头动辄上百字节,设备还要自己拼JSON、算Content-Length,对单片机开发者来说又麻烦又费资源。更关键的是,HTTP短连接意味着设备每次上报都要重新建连,数万台设备同时醒来上报时,服务器会瞬间涌进大量连接,端口和句柄压力反而更高。
WebSocket方式的问题:WebSocket解决了全双工长连接,但多了一次握手开销,帧格式对于私有协议场景显得多余。除非你要同时支持浏览器直接查看实时数据,否则硬件设备走WebSocket没有明显好处。
MQTT方式的问题:MQTT本身更适合公共IoT场景,需要一个broker,协议栈和设备SDK都要额外移植。如果项目里设备协议是私有定制的,再套一层MQTT反而多出一个适配环节,出问题不好排查。
我最常用的处理是:设备端直接用TCP长连接,自定义二进制帧上报数据。二进制帧短小、解析快、不需要序列化框架,设备端好实现,服务器端也容易做到高吞吐。而且长连接建立一次,服务器随时可以主动下发指令,这是HTTP做不到的。标题里“非Web端、自定义协议”其实就是这套思路。
1.3 三种异步模型怎么选:SAEA、async/await、APM
C#做Socket服务,主流的异步模型就三种:老式APM(BeginReceive/EndReceive)、async/await包一层、以及SocketAsyncEventArgs(简称SAEA)。我在这个项目里选择SAEA,下面这条对比值得你收藏。
| 模型 | 触发方式 | 资源分配 | 适合场景 |
|---|---|---|---|
| APM Begin/End | IO完成后回调线程 | 每次操作分配IAsyncResult对象 | 老项目维护,小规模连接 |
| async/await | 编译器状态机+线程池续体 | 每次await有状态机分配,并发高时有压力 | 中规模,开发效率高 |
| SocketAsyncEventArgs | 底层IOCP/epoll直接回调 | 对象复用,分配极少 | 高并发接入,首选 |
自己写服务器时,async/await看起来最亲切,读代码也舒服。但高连接场景下,每个连接上同时挂几个待续体,对象分配量和上下文切换就不容小觑。SAEA的核心设计思想是“复用”:SocketAsyncEventArgs对象池化,接收缓冲区和发送缓冲区都预先分配好,IO完成后回调线程直接处理,避免每次收发重新分配对象。
Windows上SAEA底层走IOCP(完成端口)模型,可以理解成“快递柜模式”:系统帮你收好货物,到了就通知你,而不是你一直在门口守着。连接数再多,等待IO的线程也不会被阻塞,这是单机支撑数万设备的关键基础。
2. 核心代码逐段拆解:从Accept到拆包到心跳管理
2.1 接入层:SocketAsyncEventArgs + IOCP 的完整封装
先看监听和接受连接的部分。这段代码是整个服务器的入口,负责把新进来的TCP连接变成设备会话。需要注意的点我写在代码后面的说明里。
public sealed class TcpReceiveServer { private readonly Socket _listenSocket; private readonly ConcurrentDictionary<long, DeviceSession> _sessions = new(); private volatile bool _running; public void Start(int port) { _running = true; _listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); _listenSocket.Listen(1024); // 预创建几个AcceptEventArgs,避免多个线程同时Accept的瓶颈 for (int i = 0; i < Environment.ProcessorCount * 2; i++) { StartAccept(null); } } private void StartAccept(SocketAsyncEventArgs? acceptEventArgs) { if (!_running) return; var acceptEvent = acceptEventArgs ?? new SocketAsyncEventArgs(); acceptEvent.Completed += OnAcceptCompleted; acceptEvent.AcceptSocket = null; bool pending = _listenSocket.AcceptAsync(acceptEvent); if (!pending) { OnAcceptCompleted(this, acceptEvent); } } private void OnAcceptCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError == SocketError.Success && e.AcceptSocket != null) { var session = CreateSession(e.AcceptSocket); if (session != null) { _sessions[session.DeviceId] = session; StartReceive(session); return; } } e.AcceptSocket?.Close(); if (_running) StartAccept(e); } }几个容易栽跟头的细节:
AcceptAsync返回false代表同步完成,这时候必须直接调用回调处理,否则连接会一直挂在队列里没人管。很多新手只处理返回true的情况,结果本地一压测就发现连接处理不过去。
AcceptEventArgs建议复用。我习惯预创建多个Accept事件,让多个“接收器”同时排队,这样在高并发connect进来时不会被单个Accept串行卡住。Completed事件只绑定一次,后续复用同一个对象就不要再重复绑定。
Accept失败后一定要Close。否则AcceptSocket非空但握手已经断裂,会在底层留下一个半连接对象。压测时如果发现句柄数只涨不降,先检查这里。
接收逻辑同样用SAEA。每个会话创建时分配一个接收专用SocketAsyncEventArgs,并设置一个固定大小的缓冲区,后续接收都复用这一个对象。
private void StartReceive(DeviceSession session) { var recvArgs = session.RecvArgs; recvArgs.AcceptSocket = session.Socket; bool pending = session.Socket.ReceiveAsync(recvArgs); if (!pending) { OnReceiveCompleted(session.Socket, recvArgs); } } private void OnReceiveCompleted(object? sender, SocketAsyncEventArgs e) { var session = (DeviceSession)e.UserToken!; if (e.SocketError != SocketError.Success || e.BytesTransferred == 0) { session.Close(); return; } session.UpdateLastActive(); ProtocolParser.Feed(session, e.Buffer!, e.Offset, e.BytesTransferred); // 继续下一次接收,同一个recvArgs不能同时处于pending状态 if (!session.Socket.ReceiveAsync(e)) { OnReceiveCompleted(session, e); } }BytesTransferred为0表示对端关闭,必须走清理流程。很多半开连接问题就是这里没判断,导致死连接一直占着会话表。
我的习惯是在创建会话时挂DeviceToken,这样回调里通过e.UserToken就能取回会话对象,不用再去字典里反查,少一次锁操作。
2.2 自定义协议设计:帧格式、CRC校验、粘包拆包状态机
协议是自定义的,我的设计思路是:帧头固定、帧长明确、校验兜底。下面是一种常见的紧凑帧格式,非常适合温湿度、电表、水电表这类小包数据。
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头1 | 1 | 固定0xFF |
| 帧头2 | 1 | 固定0x55 |
| 设备ID | 4 | 小端序,全局限定 |
| 命令字 | 1 | 0x01心跳、0x02数据上报、0x81下发应答 |
| 数据长度 | 2 | 小端序,仅指数据域长度 |
| 数据域 | N | 具体业务数据,最大65535字节 |
| CRC16 | 2 | 校验从设备ID到数据域,低字节在前 |
帧头用两字节固定值,是为了减少误触发。设备ID用4字节整数,而不是字符串,查询和索引都快很多。如果有上万台设备,字符串主键在字典和数据库里都会拖慢速度。
协议解析最核心的问题永远是粘包半包处理。TCP是流式协议,可能一次收到多个帧包,也可能一帧数据分好几次才来。千万不要用“判断缓冲区是否包含某个结束符”这种土办法,必须用状态机。
我的解析器核心逻辑长这样:
internal enum ParseState { WaitHead1, WaitHead2, WaitLength, WaitData, WaitCrc } public void Feed(DeviceSession session, byte[] buffer, int offset, int count) { while (count > 0) { switch (_state) { case ParseState.WaitHead1: if (buffer[offset] == 0xFF) _state = ParseState.WaitHead2; offset++; count--; break; case ParseState.WaitHead2: if (buffer[offset] == 0x55) { _state = ParseState.WaitLength; } else if (buffer[offset] != 0xFF) { _state = ParseState.WaitHead1; } offset++; count--; break; case ParseState.WaitLength: if (count < 2) { // 长度字段还没凑齐,保存剩余数据,等下一次Feed return; } _payloadLength = buffer[offset] | (buffer[offset + 1] << 8); _state = ParseState.WaitData; offset += 2; count -= 2; break; case ParseState.WaitData: int need = _payloadLength - _payloadIndex; int take = Math.Min(need, count); Buffer.BlockCopy(buffer, offset, _payloadBuffer, _payloadIndex, take); _payloadIndex += take; offset += take; count -= take; if (_payloadIndex == _payloadLength) { _state = ParseState.WaitCrc; } break; case ParseState.WaitCrc: if (count < 2) { return; } _crcLow = buffer[offset]; _crcHigh = buffer[offset + 1]; offset += 2; count -= 2; int calcCrc = Crc16(_payloadBuffer, _payloadLength); if (calcCrc == (_crcLow | (_crcHigh << 8))) { RaisePacketReceived(session, _payloadBuffer, _payloadLength); } Reset(); break; } } }写状态机时最容易被坑的,是“一个字段跨两个包”的情况。比如设备一次只发来半个长度字节,你如果直接返回,那另半个字节可能在下一次Feed里,处理好这种残留数据是协议解析器的必修课。我在每次解析前都会把还没消费完的字节存下来,保证粘包半包都不丢。
数据域缓冲区建议用一个比较大的数组池来管理,不要在每次收到包时new byte[N],不然高频上报时GC压力会非常大。我用的是全局环形buffer池,解析完成后把payload直接交给业务层,业务层处理完再归还。
CRC校验不能省。工业现场环境里,串口线缆干扰、电源纹波导致数据错乱都是常有的事,没有校验的协议等于裸奔。CRC16计算量很小,设备端也容易实现,性价比很高。
2.3 会话容器与心跳清理:数万连接不断线的关键
有了连接和协议,剩下就是管好会话。我用ConcurrentDictionary存所有在线设备,key是设备ID,value是Session对象。为什么不用普通Dictionary加锁?因为高并发下加锁会导致所有接收线程挤在一起,性能下降非常明显。ConcurrentDictionary在读写分离场景下表现更好。
public sealed class DeviceSession { public long DeviceId { get; set; } public Socket Socket { get; } public DateTime LastActive { get; private set; } public SocketAsyncEventArgs RecvArgs { get; } public void UpdateLastActive() { LastActive = DateTime.UtcNow; } public void Close() { try { Socket.Shutdown(SocketShutdown.Both); } catch { /* 忽略已关闭异常 */ } Socket.Close(); RecvArgs.Dispose(); } }每个连接进来时,设备先发送一条带设备ID和token的“注册帧”,服务器校验通过后才加入会话字典。不要一开始就信任连接,否则别人随便telnet你的端口就能白嫖资源。
心跳检测单独起一个后台任务,定时扫描会话字典,把超时的连接踢掉。代码很简单:
Task.Run(async () => { while (_running) { await Task.Delay(30_000); var timeout = DateTime.UtcNow.AddSeconds(-90); foreach (var item in _sessions) { if (item.Value.LastActive < timeout) { _sessions.TryRemove(item.Key, out var session); session?.Close(); } } } });这里的核心参数是心跳超时。我一般按“3倍心跳周期”来设置,比如设备每30秒发一次心跳,那90秒没动静就判定掉线。太短会误杀慢网络下的设备,太长会留一堆半开连接占用资源。
需要注意的地方:ConcurrentDictionary在枚举的同时删除元素是安全的,但如果业务代码里对会话对象做了其他操作,要确保那些操作也能处理“已关闭”状态。我习惯在Session里加一个IsActive标记,关闭时置false,业务侧看到false直接丢弃数据。
还有一个经验是:不要在心跳检测里做昂贵的数据库操作。扫一趟2万个Session很便宜,但如果每发现一个超时就去查库,很快就会被拖住。离线通知应该通过队列异步处理,不能阻塞清理线程。
2.4 发送队列与指令下发:别让并发写Socket乱掉
服务器不止要接收数据,还要能主动下发指令。这地方也有个大坑:如果多个线程同时调用Socket.Send,发送顺序会乱,甚至出现数据包交叉。我见过不少项目在这上面翻车,数据错乱半天查不出原因。
解决方式就是给每个Session维护一个独立发送队列,用“正在发送”标记保证同一时刻只有一个线程在处理发送。
private readonly object _sendLocker = new(); private readonly ConcurrentQueue<byte[]> _sendQueue = new(); private bool _sending; public void Send(byte[] payload) { _sendQueue.Enqueue(payload); lock (_sendLocker) { if (_sending) return; _sending = true; } SendNext(); } private void SendNext() { while (_sendQueue.TryDequeue(out var payload)) { var sendArgs = _sendArgsPool.Rent(); sendArgs.SetBuffer(payload, 0, payload.Length); sendArgs.Completed += OnSendCompleted; bool pending = _socket.SendAsync(sendArgs); if (!pending) { OnSendCompleted(this, sendArgs); } return; } lock (_sendLocker) { _sending = false; } } private void OnSendCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError != SocketError.Success) { Close(); return; } e.Completed -= OnSendCompleted; _sendArgsPool.Return(e); SendNext(); }这个模式的重点在于:发送动作始终只有一个线程在执行,下一个包要等上一个包的回调回来才继续发,顺序天然保证。并发状态下多线程往队列里塞,也只是塞队列,不会直接操作Socket。
发送完的SocketAsyncEventArgs要还回池子,避免反复分配。如果设备量很大,发送也是高频操作,这里不做池化,很快就能把GC逼到极限。
3. 跑一次真实万级压测:启动、模拟、结果分析
3.1 服务启动与基础配置
代码写完之后,我一般先在本机跑一遍冒烟测试,确认基本功能可用,再上压力。服务启动逻辑很简单:
var server = new TcpReceiveServer(); server.OnPacketReceived += (session, cmd, data) => { // 这里把解析好了的业务包丢给后台队列 _businessQueue.Enqueue(new PacketData(session.DeviceId, cmd, data)); }; server.Start(9000);启动后我会先看三个基础指标:监听端口是否正常、空连接占用情况、有无异常日志。用netstat -an | findstr 9000能迅速看到连接状态。
这一步很容易踩的坑是防火墙。Windows服务器上开发环境没问题,部署到正式机器后设备连不上,十有八九是防火墙没放行端口。记得先把入站规则加上,再让设备侧开联调。
3.2 模拟数万设备连接与上报
要压测,就得有模拟设备端。自己写一个简单的压力客户端,循环创建Socket连接到服务器,然后定时发心跳和数据帧。核心代码大概长这样:
private void CreateClients(int deviceCount, string ip, int port) { for (int i = 0; i < deviceCount; i++) { var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.Connect(ip, port); _clients.Add(socket); } } private void SendHeartbeatLoop() { var timer = new System.Timers.Timer(30_000); timer.Elapsed += (_, _) => { foreach (var socket in _clients) { if (socket.Connected) { socket.Send(BuildHeartbeatPacket()); } } }; timer.Start(); }注意一个现实约束:一台Windows机器默认动态端口范围有限,只能开出大约6万多个客户端连接。如果你要压到2万甚至更多,建议把压测客户端部署在多台机器上,或者调大动态端口范围。我自己的经验是,想让压测结果可信,至少要有两台模拟客户端机器,否则连接数上不去,以为是服务器瓶颈,其实是模拟器自己的端口不够。
3.3 压测结果与资源占用复盘
下面是一次典型压测的结果,环境是4核8G Windows Server 2019,.NET Core 3.1,接收缓冲区8KB池化配置。
| 连接数 | 上报频率 | CPU | 内存 | 备注 |
|---|---|---|---|---|
| 1万 | 30秒/次心跳 | 5%-10% | 约350MB | 稳定运行无告警 |
| 2万 | 30秒/次心跳 | 10%-15% | 约700MB | 连接建立耗时变长 |
| 2万 | 1秒/次,每包128字节 | 30%-45% | 约850MB | 拆包和业务队列压力上来 |
从这个结果能看到一个规律:连接数是内存的主要开销来源,瘦身后的Session对象加接收缓冲区,每个连接大概分摊到30-40KB。如果设备上报频率高,CPU的主要消耗其实不是收发,而是拆包、CRC校验和队列入队。
2万连接的场景下,连接建立瞬间会有几千个Accept请求涌进来,如果Accept队列长度配置太小,会出现部分连接建立失败。我把Listen的backlog设为1024,实测在2万并发建连时表现还算稳,再大就得靠负载均衡分流了。
内存数据仅供参考,不同机型、不同框架版本差别很大。但有一个结论我很确定:单机2万连接完全可行,前提是代码里没有反复new byte[]、没有阻塞IO、没有写数据库同步等待。
4. 上线前后必看的常见问题与性能优化清单
4.1 半开连接、Socket句柄泄漏怎么查
半开连接是物联网服务器最隐蔽的敌人。设备断电、网线拔掉、WiFi断掉,对端不会主动发FIN包,服务器这边Socket还认为连接活着,会话表里一直占着位置。时间一长,大量死连接把内存耗尽,新设备连不进来。
我的处理是两层防护。第一层是应用层心跳扫描,就是前面写的那个后台任务,90秒无数据就踢掉。第二层是TCP KeepAlive,虽然默认参数不顶用,但可以主动调短:
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);TcpKeepAliveTime设为30秒后,系统每30秒探测一次,连续5秒没响应就判定连接死亡。这能比应用层心跳更快发现网络层问题。注意这个API在不同的.NET版本和操作系统上支持程度不一样,Linux上的参数名也不同,部署前先验证。
排查句柄泄漏时,Windows上可以用任务管理器看句柄数,或者用handle.exe。如果句柄数只涨不降,优先怀疑Accept失败后没关Socket、ReceiveAsync回调里异常没处理导致会话对象没释放、SAEA复用时没有重置状态。
4.2 内存飞涨与GC压力怎么压下来
内存飞涨最常见的原因,不是连接本身,而是你“为了处理业务”分配了太多临时对象。比如每收到一个包就new byte[]、每组装一帧就List<byte>一把梭,设备一多,GC就成天忙着回收垃圾。
我优化内存主要靠三板斧:
第一,接收缓冲区池化。每路连接虽然有自己的接收SAEA,但多个连接可以共享同一个大ArrayPool,尤其在上报频率低的时候,没必要让每个连接都占着一块8KB内存吃一辈子。
byte[] buffer = ArrayPool<byte>.Shared.Rent(4 * 1024); try { // 使用buffer } finally { ArrayPool<byte>.Shared.Return(buffer); }第二,业务包传递时避免拷贝。解析器直接把缓冲区里的有效区间交给业务层,业务层需要异步处理时再复制一份进队列,否则队列引用指向被归还的池化内存,数据会被覆盖。这里要特别小心,凡是跨线程使用的数据必须深拷贝或者用线程安全队列包住。
第三,日志要异步。压测时如果每收一个包就Console.WriteLine或者Debug.WriteLine一次,服务器会瞬间被IO拖垮。我通常把日志写到一个有界队列,由单独线程批量写文件,压测时只记录错误和关键事件。
4.3 上位机UI卡顿、日志阻塞、数据库写入瓶颈
标题下面的热词里反复出现“C#循环数据采集和UI刷新卡顿”,这几乎是上位机开发的通病。如果你把接收服务器嵌入到一个WinForms/WPF上位机程序里,接收回调线程千万不要直接textBox.Invoke刷界面。高频上报时Invoke会淹没UI线程,界面直接卡死。
我的做法是接收线程只往队列里塞数据,UI线程用定时器批量拉取刷新:
private readonly ConcurrentQueue<string> _screenQueue = new(); // 接收线程里 _screenQueue.Enqueue($"设备{deviceId}温度:{temp}℃"); // UI定时器,每200ms刷新一次 private void UiTimer_Tick(object? sender, EventArgs e) { while (_screenQueue.TryDequeue(out var line)) { textBox.AppendText(line + Environment.NewLine); } }如果UI上只需要显示最新值,不要累积历史,那就用ConcurrentDictionary保留下一条记录,UI定时器每次都读最新值,显示效率高很多。
数据库写入也是一样,绝对不能逐条插。2万设备,每秒上报一次,那就是每秒2万条insert,单条insert必死。我实测的可行方案是内存队列批量攒,攒到500条或者每2秒批量插入一次。用SqlBulkCopy或者拼接批量Insert,速度能提升几十倍。
Task.Run(async () => { var batch = new List<TelemetryData>(500); while (_running) { while (batch.Count < 500 && _dataQueue.TryDequeue(out var item)) { batch.Add(item); } if (batch.Count > 0) { await BulkInsertAsync(batch); batch.Clear(); } else { await Task.Delay(200); } } });设备上报量再大的话,就该换时序数据库了。TDengine、InfluxDB这类时序库对设备的写入有专门优化,吞吐量比关系数据库高一个量级。
4.4 吞吐量上不去时,按什么顺序排查
压测结果不理想时,不要上来就怀疑语言不行或者框架不行。我一般按下面这个顺序排查:
第一,确认接收逻辑没有被业务代码阻塞。常见的坑是在OnPacketReceived回调里直接写数据库、发短信、调用外部API,这些操作一卡的,整个接收线程全堵住。
第二,换一个更短的拆包状态机逻辑验证是不是CRC校验拖慢的。CRC16计算量并不大,但如果你用了很重的校验算法或者每次都重新计算大缓冲区,CPU占比会明显上来。
第三,检查锁竞争。会话字典用ConcurrentDictionary还不够,如果每个连接还要抢一个全局锁,连接数一多就完蛋。锁定粒度越小越好,最好能做到每连接一把锁。
第四,用dotnet-counters或者PerfView分析GC和线程池指标。如果看到线程池饥饿(ThreadPool Queue Length持续增长),说明异步回调里有阻塞操作,赶紧找出来。
5. 从接入服务器到一个可扩展网关:后续演进路线
5.1 协议插件化与动态Handler
项目做大了以后,你会发现不同类型的设备命令字不一样、帧格式也可能不一样。如果所有解析代码都堆在同一个方法里,switch会膨胀到没法维护。
我后来把它改成命令字到Handler的映射表,每个命令字对应一个独立处理类:
private readonly Dictionary<byte, IPacketHandler> _handlers = new() { { 0x01, new HeartbeatHandler() }, { 0x02, new TelemetryHandler() }, { 0x81, new CommandAckHandler() }, };新增设备类型时,写一个Handler,注册命令字就行,核心接收层完全不用动。这样既能保持接入层稳定,又方便多协议共存。
5.2 多实例部署与数据落库方案
单机扛不住时,优先想到的是横向扩展。TCP接入层本身是无状态的,多个服务器实例可以同时监听同一个负载均衡端口,设备随机连到其中一台。难点在于设备在线状态和下行指令。
在线状态可以用Redis统一维护,设备连上哪台实例,就把实例地址写入Redis。下行指令先查Redis找到设备所在实例,再通过内部消息队列把指令转发过去。这样接入层只管连接,业务层不管连接,就是物联网平台的经典分层。
数据落库方面,单实例接入层不要直接写数据库,把解析好的数据推到Kafka或者RabbitMQ,由独立消费者负责入库、告警、计算。这一层解耦之后,数据库挂了不影响设备接入,系统整体可用性会高很多。
5.3 安全与稳定性加固
自定义协议不代表裸奔。如果设备量到达一定规模,私网环境还好,一旦跨公网,至少要做设备鉴权和数据加密。
我常用的做法是:每个设备在网关或服务器端预置一组密钥,首次连接时做一次挑战应答校验。服务器下发一个随机数,设备用密钥加密后回传,服务器验证通过才允许加入会话。后续数据帧可以整体加密,也可以只加密关键字段,看设备性能。
稳定性方面,除了心跳和CRC,我强烈建议加一个“最大失败计数”机制。连续多次解析失败或鉴权失败的IP,加入黑名单并临时封禁一段时间,防止恶意连接刷端口。
整个项目做完,我最深刻的体会是:C#处理高并发Socket并没有想象中那么难,真正难的是把所有细节管住。你用了异步模型,却还在回调里new大数组;你写了心跳,却没有处理半开连接;你拆包拆对了,但一个异常没捕获导致整个Accept链条断掉——这些都是运行一周之后半夜报警的根源。如果让我重新做一次,我会在一开始就把对象池、异步日志、批量入库、会话监控这四件事做进去,而不是等压测出问题再补。最后分享一个调试技巧:排查自定义协议时,直接在设备侧或者服务器侧抓包,比打印100行日志都快,粘包、字节序问题看报文一眼就明白了。