
WebSocket在服务端开发里早就不是什么新鲜东西但每次写.NET Core项目总有人来问我这套到底怎么接、怎么用才不出幺蛾子。这篇我把实际项目中WebSocket从服务端到客户端的完整用法、参数配置、踩坑经验一次讲清楚不绕弯子直接能抄。先说个基础判断WebSocket解决的是HTTP协议“请求-响应”这种半双工模式的尴尬。以前做聊天室、实时行情、协同编辑要么靠前端定时轮询要么靠SSE单向推送要么自己用TCP长连接包一套私有协议。轮询浪费带宽且延迟高SSE只能服务器往客户端推。WebSocket则是一次HTTP握手之后双方在同一个TCP连接上随意双向收发数据说白了就是给浏览器和服务器开了一条全双工的数据管道。.NET Core从1.0时代就内置了WebSocket服务端支持不需要任何第三方包底层由Kestrel承载API集中暴露在System.Net.WebSockets命名空间下。客户端既可以用ClientWebSocket服务端也能用也可以用浏览器的原生WebSocket对象。这篇主要以服务端实现为主线兼顾客户端与部署环境适合正在做实时通信功能、想了解完整接入方案的后端开发者也适合刚入行想搞明白长连接怎么实现的同学。1. 先搞懂WebSocket的连接模型1.1 一次握手的全过程WebSocket虽然叫协议但它不是从零发明的传输层协议而是依赖HTTP/1.1的Upgrade机制。客户端发一个带特殊头部的HTTP GET请求服务器在响应头里返回101 Switching Protocols之后这个TCP连接就从HTTP模式切换成WebSocket模式。双方在这个连接上传输的就不再是HTTP报文而是WebSocket帧每帧可以是文本帧、二进制帧、Ping/Pong控制帧或Close帧。这个机制决定了两个关键点第一前面只能走HTTP所以WebSocket天然兼容现有Web基础设施。客户端经过Nginx、负载均衡、CDN都可能只要中间层支持TCP转发和必要的Upgrade头透传就能正常工作。第二一次握手只能建立一个连接之后双方复用同一条TCP连接。服务端要维护的是连接状态而不是请求状态。一旦某个客户端断网或断线服务端只能通过心跳超时、TCP连接异常等间接手段感知不能像HTTP请求一样天然知道请求已经结束了。这是实际开发中最容易出问题的地方连接到底什么时候该判定为死掉了。1.2 该用WebSocket还是用别的方案判断一个实时功能要不要上WebSocket我一般先问三个问题数据是双向还是单向如果只需要服务器推送SSE就够省心得多。频率有多高低频同步比如每分钟一次状态刷新用轮询反而比WebSocket简单可靠客户端断线恢复也容易。服务端要不要主动下发指令要那么WebSocket几乎是必然选择。另外要注意WebSocket是长连接每个连接在服务端都占用文件描述符、内存缓冲区和线程资源虽然.NET的异步模型不占线程连到上万的量级之后资源控制就成了重点。如果你做的是类似手机推送那种场景服务端和客户端之间还要走APNs这类系统通道根本不是WebSocket的主场。别为了我有WebSocket而强行所有功能都挂上去。2. 服务端接入从中间件配置到消息循环2.1 最小可用的服务端DemoASP.NET Core里启用WebSocket本质上是在请求管道中注册一个WebSocket中间件然后在自己的中间件或路由处理器里判断请求是否为WebSocket握手请求如果是就调用AcceptWebSocketAsync接管连接。新建一个空的ASP.NET Core项目后Program.cs先注册服务var builder WebApplication.CreateBuilder(args); var app builder.Build(); // 注册WebSocket中间件必须在任何需要访问WebSocket的中间件之前 app.UseWebSockets(); // 处理WebSocket请求 app.Use(async (context, next) { // 限定路径避免所有请求都被这个中间件拦下来 if (context.Request.Path /ws) { if (context.WebSockets.IsWebSocketRequest) { using var webSocket await context.WebSockets.AcceptWebSocketAsync(); await EchoLoopAsync(webSocket); } else { context.Response.StatusCode StatusCodes.Status400BadRequest; } } else { await next(); } }); await app.RunAsync();AcceptWebSocketAsync返回的WebSocket对象代表这条已经握手成功的连接之后的操作全部基于这个对象。using确保连接关闭时释放底层资源这一点非常重要后面讲内存问题会展开。EchoLoopAsync是核心的读取循环我们用一个4KB的缓冲区反复等待消息直到收到关闭帧或发生异常private static async Task EchoLoopAsync(WebSocket webSocket) { var buffer new byte[1024 * 4]; var receiveResult await webSocket.ReceiveAsync( new ArraySegmentbyte(buffer), CancellationToken.None); while (!receiveResult.CloseStatus.HasValue) { if (receiveResult.MessageType WebSocketMessageType.Text) { var message Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); // 处理收到的消息比如原样返回 var echoBytes Encoding.UTF8.GetBytes($echo: {message}); await webSocket.SendAsync( new ArraySegmentbyte(echoBytes), WebSocketMessageType.Text, true, CancellationToken.None); } receiveResult await webSocket.ReceiveAsync( new ArraySegmentbyte(buffer), CancellationToken.None); } // 客户端发来关闭帧我们回应一个关闭帧 await webSocket.CloseAsync( receiveResult.CloseStatus.Value, receiveResult.CloseStatusDescription, CancellationToken.None); }这个Demo能跑通但它有几个隐藏问题缓冲区固定4KB收到的消息一旦超过4KB就会被截断消息可能跨多个帧到达只读一次远远不够没有做连接管理和异常处理。下面逐条解决。注意ReceiveAsync返回的Count表示本次实际读到多少字节不是缓冲区大小。EndOfMessage表示当前这条完整消息是否已经读完这是WebSocket分片机制的关键。消息体大时一条消息会拆成多个帧发过来必须循环读取到EndOfMessage为true才算收完整。2.2 中间件配置参数别乱调app.UseWebSockets()可以传入一个WebSocketOptions对象。我用一个表格把参数的默认值和实际建议写清楚参数默认值建议说明KeepAliveInterval30秒30秒或60秒服务端主动发送Ping帧的间隔用于检测死连接ReceiveBufferSize4KB4KB~16KB单次接收缓冲区大小不决定单条消息上限SendBufferSize4KB4KB~16KB发送缓冲区大小AllowedOrigins空允许所有配置白名单防止跨站WebSocket劫持CSWSH必须配DangerousDeflateOptionsnull不压缩视业务而定开启permessage-deflate压缩需确认攻击面实际使用中我基本只改两个东西把AllowedOrigins填上把KeepAliveInterval从默认的30秒调到自己想要的心跳节奏。缓冲区大小不要贪大4KB到16KB对绝大多数业务足够而且缓冲区只是“临时装一下”真正要接收大消息要在应用层自己累积缓冲不是调高这个值就万事大吉。服务端代码加上配置大概是app.UseWebSockets(new WebSocketOptions { KeepAliveInterval TimeSpan.FromSeconds(60), AllowedOrigins new[] { https://mydomain.example, https://admin.mydomain.example } });AllowedOrigins不配等于把服务端WebSocket接口裸奔在公网上。攻击者可以在自己的恶意页面上向你的接口发起握手浏览器默认跨域限制管不到WebSocket握手这种漏洞叫做跨站WebSocket劫持能冒充登录用户收发消息。哪怕调试阶段先不配上线前一定要补上。2.3 多客户端管理与消息广播真实项目不会只有一条连接。需要维护一个线程安全的连接池一般用ConcurrentDictionarystring, WebSocketkey是用户标识或连接IDpublic class WebSocketConnectionManager { private readonly ConcurrentDictionarystring, WebSocket _connections new(); public void Add(string key, WebSocket socket) _connections[key] socket; public void Remove(string key) { if (_connections.TryRemove(key, out var socket)) { socket.Dispose(); } } public async Task BroadcastTextAsync(string message, CancellationToken ct) { var bytes Encoding.UTF8.GetBytes(message); foreach (var socket in _connections.Values) { if (socket.State WebSocketState.Open) { await socket.SendAsync( new ArraySegmentbyte(bytes), WebSocketMessageType.Text, true, ct); } } } public IReadOnlyListWebSocket GetAll() _connections.Values.ToList(); }在连接建立时加入、关闭时移除即可const string connectionKey user-123; // 实际按使用者标识生成 connectionManager.Add(connectionKey, webSocket); try { await EchoLoopAsync(webSocket); } finally { connectionManager.Remove(connectionKey); }这里有一个我踩过的坑同一个WebSocket实例不能让多个任务同时调用SendAsync。广播是遍历连接池逐个发送如果两条业务逻辑同时触发了对同一个连接的SendAsync会抛出InvalidOperationException。解决思路两个要么在发送时用SemaphoreSlim对每个连接加锁要么把“发送队列”做成串行。小规模项目直接在WebSocketConnectionManager内部给每个连接挂一个信号量成本低也稳。private readonly ConcurrentDictionarystring, SemaphoreSlim _sendLocks new(); public async Task SendTextAsync(string key, string message, CancellationToken ct) { if (!_connections.TryGetValue(key, out var socket) || socket.State ! WebSocketState.Open) { return; } var semaphore _sendLocks.GetOrAdd(key, _ new SemaphoreSlim(1, 1)); var bytes Encoding.UTF8.GetBytes(message); await semaphore.WaitAsync(ct); try { await socket.SendAsync( new ArraySegmentbyte(bytes), WebSocketMessageType.Text, true, ct); } finally { semaphore.Release(); } }顺带一提如果发送目标连接早已断开SendAsync会抛WebSocketException广播方法必须做异常隔离。一个连接发送失败不能让整个广播崩掉。最稳妥的做法是每个连接的发送都包一层try-catch遇到WebSocketState.Aborted或者ObjectDisposedException就把连接从池里清掉。3. 客户端接入连接、收发、重连三板斧3.1 ClientWebSocket的基本用法服务端是自己人客户端多半也是自己写。.NET内置的ClientWebSocket类在控制台程序、后台服务、桌面应用里都能用。最小客户端长这样using var client new ClientWebSocket(); using var cts new CancellationTokenSource(TimeSpan.FromSeconds(30)); await client.ConnectAsync(new Uri(ws://localhost:5000/ws), cts.Token); var sendBytes Encoding.UTF8.GetBytes(hello server); await client.SendAsync( new ArraySegmentbyte(sendBytes), WebSocketMessageType.Text, true, cts.Token); var buffer new byte[1024 * 4]; var result await client.ReceiveAsync( new ArraySegmentbyte(buffer), cts.Token); Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.Count)); await client.CloseAsync( WebSocketCloseStatus.NormalClosure, bye, cts.Token);ClientWebSocketOptions可以配置很多细节常见的有KeepAliveInterval客户端发Ping的间隔设得比服务端短或者一致都行。SetRequestHeader(Origin, https://mydomain.example)握手时带上Origin服务端校验。Credentials需要身份认证时传凭据。Proxy走代理时的代理设置。RemoteCertificateValidationCallback自签名证书环境下用于绕过证书校验生产环境非必要不要用。ClientCertificates双向TLS认证时添加客户端证书。握手时如果服务端返回非101状态码ConnectAsync会抛异常。有些网关层会拦截WebSocket握手返回401、403等状态这时候异常堆栈里可以看到HTTP状态码用来定位是哪一个中间层拦了。3.2 文本和二进制消息处理要分开WebSocket的消息类型分Text和Binary这与HTTP按Content-Type区分道理相似。服务端发文本就设置WebSocketMessageType.Text发字节数据就设置Binary。使用Encoding.UTF8对字符串做转换就行。但要注意一个细节消息是分帧传输的。服务端发送一个很长的文本底层会按某种策略分成多个WebSocket帧而接收端拿到的WebSocketReceiveResult只代表当前这一帧。判断一条消息是否完整必须看result.EndOfMessage是否为true。如果为false要把当前缓冲区里的数据先存起来继续接收下一帧直到EndOfMessage为true再拼成完整消息。using var ms new MemoryStream(); var buffer new byte[16 * 1024]; WebSocketReceiveResult result; do { result await webSocket.ReceiveAsync( new ArraySegmentbyte(buffer), CancellationToken.None); if (result.MessageType WebSocketMessageType.Close) { // 处理连接关闭 break; } ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); var fullMessageBytes ms.ToArray();这里把Do-While写成接收一帧处理一帧是标准的完整消息读取姿势。MemoryStream用完记得释放用using或直接让局部变量走GC都行不过我习惯给大消息场景设计一个上限比如超过64MB直接判定为异常消息断掉连接防止恶意客户端刷爆服务端内存。另外说一下WebSocketReceiveResult.CloseStatus和CloseStatusDescription。正常情况下客户端主动关闭会发Close帧服务端读到CloseStatus有值就应该回一个Close帧并结束循环。异常断开时可能直接抛异常或TCP层面重置这里也要捕获。我的习惯是收到正常Close帧就正常结束读到异常状态或抛异常则直接Abort连接不再尝试优雅关闭因为这时候等也是白等。3.3 客户端断线重连别等到超时才动手WebSocket最坑的一点是连接断开并不总是能被立刻感知。手机切换WiFi、物理断网、服务端重启这些场景下TCP连接可能出现“半开”状态客户端以为还连着服务端已经消失了。心跳机制能解决这个问题但不能只靠服务器端的KeepAliveInterval。客户端自己也要有心跳判断和快速重连策略。我一般会给客户端封装这样一个循环public async Task RunAsync(string uri, CancellationToken ct) { var retryCount 0; while (!ct.IsCancellationRequested) { using var client new ClientWebSocket(); try { await client.ConnectAsync(new Uri(uri), ct); retryCount 0; await ReceiveLoopAsync(client, ct); } catch (Exception ex) when (!ct.IsCancellationRequested) { LogWarning($连接失败或异常断开: {ex.Message}); } // 指数退避重连 retryCount; var delaySeconds Math.Min(Math.Pow(2, retryCount), 30); await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); } }重连的关键点有两个第一重连必须幂等。重连成功后要自动补齐会话信息比如重新鉴权、重新订阅频道。不能直接复用旧连接的上下文不然服务端不认。第二指数退避要有上限。Math.Pow(2, retryCount)到第5次就是32秒封顶30秒不能再涨。一直高频重连不仅浪费资源还可能被网关判定为恶意请求。我见过网络波动后大量客户端同时重连压垮服务端的案例后面会细说。4. 协议细节与性能调优4.1 心跳机制这里有个容易忽略的坑KeepAliveInterval控制的是发送Ping控制帧的间隔。服务端开了之后会定期向客户端发Ping帧客户端协议栈会自动回Pong帧。如果服务端长时间收不到Pong就会把连接标记为超时并关掉。但我这里说一个实际经验客户端如果用浏览器原生WebSocket浏览器会自动回Pong所以服务端的KeepAliveInterval默默就能工作。客户端如果用ClientWebSocket它默认也会自动回Pong。真正需要手动处理的是自定义协议或者经过网关转发时Ping/Pong可能被中间层吞掉或截断。所以我遇到跨网关场景会额外设计应用层心跳。应用层心跳就是在原有业务消息之外定期发一个约定的JSON消息比如{type:ping}服务端收到后回{type:pong}。这套机制的最大优势是穿透性好不受Ping/Pong控制帧被中间层处理的影响。代价是要自己维护判断逻辑如果连续N次心跳都没收到响应就判定连接死了。心跳间隔怎么定我一般定在30秒到60秒之间。太密增加无谓流量太疏导致断线感知慢。判断死连接的条件我常用“超过3个心跳周期未收到pong”也就是最长90秒到180秒。4.2 大消息、分片与缓冲区策略前面提过ReceiveBufferSize调大不等于能收大消息。超出一帧承载能力的消息会被拆成多个帧接收端要做好组装。具体做法上面写过用MemoryStream累积。这里补充几个服务端注意点第一读取循环里buffer要循环复用。每次ReceiveAsync前把buffer清空不用Count会告诉你本次实际写入多少字节直接按Count截取即可。不要每次new一个byte数组那是内存分配的重灾区。第二发送大消息时endOfMessage参数传false可以手动分帧。比如一个大二进制文件可以在业务层切成若干块依次发送最后一块传true。浏览器端收到的MessageEvent.data会自动帮你拼接体验和无分片一样。客户端ClientWebSocket同理协议栈记住了当前拼接状态。第三WebSocket消息的上限我建议在业务层显式设一个。资源是有限的故意或意外发几GB数据都会拖垮服务端。好实现是读取循环里每次累加大小超过阈值直接向对端发Close帧并记录日志。这不算复杂但很多项目都漏了。4.3 压缩、代理与部署环境从.NET 5开始WebSocket支持permessage-deflate扩展压缩。服务端开启时在WebSocketOptions里设置DangerousDeflateOptions客户端与之对应也要启用压缩否则协商失败。名字里有Dangerous是因为压缩和加密组合可能出现安全风险比如压缩侧信道、压缩炸弹内网使用问题不大公网慎用建议在网关层做TLS并用CDN或应用层压缩替代。app.UseWebSockets(new WebSocketOptions { KeepAliveInterval TimeSpan.FromSeconds(60), DangerousDeflateOptions new WebSocketDeflateOptions { ServerMaxWindowBits 15, ClientMaxWindowBits 15, ServerNoContextTakeover true, ClientNoContextTakeover true } });常见的反向代理服务器对WebSocket的支持需要显式开启。比如Nginx这类location块里要设置Upgrade相关头location /ws { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout默认60秒不调大空闲的WebSocket连接会被代理切断然后客户端就会经历莫名其妙的断线。这也是WebSocket部署里最常见的坑之一。Windows服务器上用IIS做宿主需要确认服务器已安装“WebSocket Protocol”功能否则Kestrel层面能跑但IIS转发层会拒绝握手。Linux上部署只要Kestrel直接对外暴露就没这个问题但如果前面挂了代理服务器还是得按代理的配置来。另外一个部署要点是负载均衡。HTTP请求无状态轮询分发没事WebSocket是长连接一旦握手完成连接就固定到了某台后端实例上。负载均衡器必须开启会话保持sticky session否则握手之后的后续帧被分发到别的实例连接直接废掉。做多实例横向扩展时如果要在实例间广播消息还要引入Redis Pub/Sub或类似的消息总线做后端广播不能只靠单实例内存里的连接池。5. 实战排坑常见问题与排查思路5.1 握手失败、404、400先查这一层连接总是建立不起来我会按这个顺序排查路径对不对。我用app.UseWebSockets() 中间件判断Path /ws时如果实际请求的是/ws/或者带查询参数路径匹配可能失败直接返回404。用PathString.StartsWithSegments更稳。中间件顺序对不对。UseWebSockets必须注册在访问context.WebSockets的中间件之前不然IsWebSocketRequest是false请求会被当成普通HTTP。服务端返回400。原因通常是代理或负载均衡没有正确透传Upgrade头或者客户端带了一个服务端AllowedOrigins不认的Origin。HTTPS和WSS混淆。浏览器只允许在HTTPS页面发起WSS连接不能用ws://连一个HTTPS站点。如果域名证书有问题握手也会失败。反向代理没开WebSocket支持。按上文配置好Upgrade相关头再试。5.2 消息发不出去或收不到这类问题先分清是协议层断了还是业务层没处理。我的排查套路是先看连接状态。webSocket.State不是Open那就是连接已经退出了。ReceiveAsync或SendAsync抛异常时先看异常类型WebSocketException一般是连接被重置OperationCanceledException是你传入的CancellationToken被取消。前者要清理连接后者要检查是不是某处取消了Token。再看是否跨帧。自己写的接收循环只调用一次ReceiveAsync就处理EndOfMessage为false时只处理了一半数据剩下的帧没读下一帧数据来了会覆盖或错乱。所有“收不全”的问题几乎都出在这里。再看广播是否并发。多个任务同时SendAsync同一个连接会抛异常服务端日志里能看到InvalidOperationException套信号量解决。最后看心跳。网络正常但长时间没消息中间层可能因为超时静默断开。如果代理配置了短超时空闲连接会被回收这就是为什么之前强调proxy_read_timeout要调大。5.3 连接数爬升与内存增长排查用WebSocket的项目越跑越卡一般先看连接池是否真的释放了连接。最常见的错误// 错误示范只Accept不Dispose var webSocket await context.WebSockets.AcceptWebSocketAsync(); await EchoLoopAsync(webSocket); // webSocket 没有被Dispose连接资源泄漏前文小Demo里用了using var webSocket这真的不是画蛇添足。WebSocket是IDisposable连接关闭后底层TCP连接和内存缓冲要显式释放。如果连接池里一个连接断开了但没有移除State变成了Closed后续广播时还在遍历它就会累积僵尸连接。另一个问题是读取循环必须一直挂起而不是每来一条消息就重新new一个循环。如果你写的逻辑是“收到消息后开个Task去处理然后再ReceiveAsync”小心并发重复Receive。WebSocket同一时间只允许一个接收操作重复调用两个ReceiveAsync会抛异常。正确做法是单循环不断ReceiveAsync处理消息时把耗时的业务逻辑放到独立Task里但循环本身要接着读。用dotnet-counters观察进程的gc-heap-size和working-set如果持续上涨且GC之后不回落多半是消息缓冲没释放或连接没关。再结合netstat看TCP连接数量能定位是不是TIME_WAIT堆积或连接池泄漏。5.4 常见问题速查表现象大概率原因处理办法握手404路径未匹配或中间件顺序错误检查UseWebSockets注册位置用StartsWithSegments匹配路径握手400Origin被拒或Upgrade头缺失核对AllowedOrigins查看代理配置连接秒断代理超时、负载均衡没有会话保持调大proxy_read_timeout开启sticky session偶发收不到消息分片未读完用EndOfMessage循环读完再处理广播某连接报错连接已关闭仍在发送发送前检查State异常时移除连接同时SendAsync报异常同一连接并发发送每个连接挂SemaphoreSlim内存持续上涨连接或缓冲区泄漏确认AcceptWebSocketAsync返回值被Dispose断网后服务端感知慢缺少心跳机制应用层心跳3个周期未响应判死负载均衡后连接不稳定长连接跨实例分发开启会话保持或用消息总线广播5.5 连接风暴上线前必须做的保护WebSocket服务最容易忽略的风险是“连接风暴”。客户端出现网络波动后所有设备同时重连服务端瞬间涌入海量握手请求CPU打满、内存飙升甚至拖垮同一进程里的其他HTTP服务。我会做三层保护。第一层是网关或负载均衡层限流限制每秒握手次数。第二层是服务端连接池上限在AcceptWebSocketAsync之前检查当前连接数超过阈值直接返回503或429。第三层是客户端重连加随机抖动指数退避的基础之上再加一个随机偏移不让所有客户端在同一拍重连。这三层做完线上基本没被连接风暴打穿过。6. 最后再分享一点我自己的体会WebSocket真正让人头疼的地方不在协议本身而在“长连接”这三个字带来的运维复杂度。HTTP请求是即来即走服务端不需要管状态WebSocket一旦建立就是一条长期占用的资源你得操心心跳、断线、重连、连接池、并发发送、代理超时、负载均衡会话保持、消息上限、异常隔离。把这些细节都处理完一个WebSocket服务才算真正可用。我每次做实时通信功能都会先把上面的清单过一遍。等真正跑起来你会发现WebSocket给用户带来的体验提升比轮询那种“每3秒刷一次”的体验强了不止一个档次。