☰
.NET Core WebSocket实战:从中间件配置到连接管理与消息广播
2026/10/13 3:36:18 网站建设 项目流程

WebSocket 堪称实时通信场景里的常青树,加上 .NET Core 原生支持,这个组合在服务端方案里其实被低估了。实际开发中总会遇到这种需求:服务端要主动往客户端推数据,不管是聊天室、行情页、任务进度条,还是多人协作、在线文档、监控大屏,本质上都是同一件事——有没有一种方式,让服务端不用等客户端请求,就能把数据送到对方手里。

我这两年的几个实时项目里,WebSocket 都是核心通信底座。这篇文章不是概念科普,而是把真实项目里从选型、中间件配置、握手逻辑、消息聚合、广播机制到线上排障的完整过程复盘一遍。适合正在做 ASP.NET Core 实时功能,或者准备把 WebSocket 接入现有系统的开发者参考,无论是刚接触长连接的新手,还是已经写过几个 Demo 但没上过生产环境的人,应该都能在里面找到自己用得上的东西。

1. 为什么在实时通信场景里,我最终选了WebSocket

先聊一个看似基础但特别值得想清楚的事:团队里一提“实时”,总会冒出几种方案,HTTP 轮询、Server-Sent Events、WebSocket、现成实时通信框架。不同方案之间的差距,不光是“能不能实时”,而是“实时到什么程度、主动推送还是双向交互、连接怎么维护”这几个维度。

早些时候的主流做法是轮询。前端每隔几秒发一次 HTTP 请求,服务端有数据就返回,没数据也返回,纯粹是空转。数据量小的时候看不出啥,一旦连接规模上来,连接数激增、请求头重复传输、数据库被反复打,资源基本都耗在“没有数据也要走一遍完整 HTTP 请求”这件事上。轮询还会带来延迟,除非你把轮询间隔压得很低,否则体验总差那么半拍。把一个本该毫秒级响应的功能做成秒级,用户体感就很明显.

后来有人用 Server-Sent Events,服务端往客户端单向流式推送文字数据,这个类似对讲机,能听不能抢话。SSE 在“只需要服务端往客户端推”的场景里是够用的,但很多业务不是单向的:用户发了消息要广播给所有人、前端改了单元格要同步给其他人,这些都需要客户端也有能力随时往服务端发消息,而且不想为此重新建立一条连接。这时候就得 WebSocket 出马。

WebSocket 是一次 HTTP 升级到长连接的产物。注意“升级”这个措辞,客户端先发一个带 Upgrade 头的 HTTP GET 请求,服务端返回切换协议的响应码,之后双方就在同一个 TCP 连接上互相发数据帧,不再走 HTTP 的请求响应模式。在 .NET Core 里,这类支持被做到了框架内部,不引入任何第三方包,原生中间件就能直接把长连接挂进请求管道。这一点是它在我这里的最大竞争优势——依赖少、链路透明、出了问题能直接翻框架代码对着查。

关于选型,我的观点是这样的:如果客户端只有自家浏览器,产品希望快速落地一套实时通信能力,那现成的实时通信框架会更省事,因为它把连接管理、断线重连、分组通知这些常见功能都封装好了。但如果客户端是别的技术栈、自己已经有一套通信协议,或者就是想让服务端和前端用最原始的方式互相传原始字节,原生 WebSocket 是最合适的。它不是比封装框架更高级,而是更透明,协议完全可控,出问题的时候排查责任也少一层。

为了给后面的实操打底,放一张方案选型的对比表,能帮你快速定位自己属于哪种场景:

方案通信方向实时性实现成本适合场景
普通HTTP轮询客户端主动发取决于轮询间隔低低频、一次性更新
SSE服务端单向推毫秒级中日志流、通知、数据看板
WebSocket双向实时毫秒级中高聊天、实时协作、消息广播
现成实时通信框架双向,带自动重连等毫秒级低多类客户端快速落地

表格里每一行背后都有真实项目对应。我在某消息推送系统里用 WebSocket 做过消息同步,在某监控大屏里用 SSE 做过数据增量推送,也见过别人用轮询硬扛一个实时看板,结果数据库连接被打穿。判断标准其实很简单:如果服务端要在无人操作的情况下主动往客户端推数据,那 HTTP 轮询基本就该被淘汰;如果还需要客户端随时反推,SSE 也不够,只能上 WebSocket 或者现成的实时框架。

还要说一个很多人忽略的点:连接建立后长时间不释放、客户端频繁收发数据、服务端主动发起推送,这三个特征里占了至少两个,就值得认真考虑 WebSocket。反过来,如果数据频率很低,一天也就几条更新,轮询或者 SSE 就够,不需要为维护一堆常驻的内存连接付出代价。我有一次帮人评审架构,对方坚持所有接口都用 WebSocket,理由是“以后总是要实时的”,我看了下业务,一天推送不到几十条,纯属给自己增加复杂度。技术选型最怕的不是选错,而是明明场景不需要却硬选。

2. 项目搭建与WebSocket中间件的关键配置

从零开始的话,强烈建议直接用空 Web 项目模板,它不会塞太多用不到的文件。创建 ASP.NET Core Web 应用时选空模板,只保留必要文件,项目启动就是一个最基础的管道:

var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.Run();

一个极简但可以启动的应用。接下来把它变成能接 WebSocket 的项目,核心就两件事:注册中间件、配置接收连接的处理分支。

2.1 注册WebSocket中间件,让HTTP请求具备升级能力

默认的 HTTP 请求是到不了“升级”这一步的,得先在管道里启用 WebSocket 支持。下面是完整的启用代码,我把配置项也一并写上了:

var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), AllocatedBufferSize = 4 * 1024 }); app.Map("/ws", async context => { if (context.WebSockets.IsWebSocketRequest) { using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); // 这里已经建立了一条WebSocket连接 } else { context.Response.StatusCode = 400; } }); app.Run();

这段代码里有两个特别容易被忽略的地方。

第一个是UseWebSockets的调用位置。它必须放在能处理请求的位置,一般放在路由映射之前、鉴权中间件之后。它本身不拦截任何普通 HTTP 请求,只是告诉框架:如果一个请求带着升级到 WebSocket 的意愿,就交给它处理。所以我们在后面写了Map("/ws")分支,在分支里用IsWebSocketRequest判断请求是不是真的想升级,是则握手,否则返回 400。

第二个是AcceptWebSocketAsync之后返回的WebSocket对象。不少新手会误以为只要注册了中间件,后续在任意类里都能new一个 WebSocket 出来收发消息。服务端其实不能主动连接客户端,只能被动地等在端点里,等客户端带着升级请求过来。握手成功那一刻返回的 WebSocket 实例,就是这一条连接上全部收发操作的基础,必须妥善保存起来,后续“连接管理”一节会专门讲怎么保存。

2.2 WebSocketOptions 里的两个关键参数,到底怎么调

WebSocketOptions可调项不少,日常真正需要关注的其实就两个。

第一个是KeepAliveInterval。它决定了服务端每隔多久主动向客户端发送一个 Ping 帧。默认值是 30 秒,基本够用。但如果你确认客户端会长时间挂着页面,而且网络链路上可能有对空闲连接不友好的 NAT 设备,建议调短到 20 秒左右。调短的好处是能更快发现“假死”连接,坏处是 Ping 帧本身也是流量,太短了会白白消耗带宽。我的习惯是先保持默认 30 秒,等服务端日志里出现明显的“连接空闲超时被断开”现象再往下调,而不是一上来就调成 5 秒。

第二个是AllocatedBufferSize。这个参数是每次调用ReceiveAsync时底层缓冲区的初始大小,默认 4KB。很多人误把它理解成“连接能收的最大数据量”,这是错的。实际上当消息超过这个大小,框架不会扔数据,而是把消息拆成多个分片,接收循环里通过EndOfMessage标记判断一段消息是否结束。缓冲区大小影响的是分配效率和分片次数,如果你确定聊天消息体基本在几 KB 以内,默认值就行;如果要处理大批量 JSON,建议调大到 64KB,能减少分片循环的次数。我在生产环境调过 64KB,内存压力不大,但要注意接收代码不能用“一次 Receive 拿到的就是全部消息”这种假设,第 3 节我会专门写这个。

这里有个真实的坑要提醒:AllocatedBufferSize只是“单次ReceiveAsync调用的缓冲区大小”,不等于“连接上限”。调大之后,如果接收代码不按照分片聚合的方式去写,照样会在大数据量下丢消息,只不过表现从“频繁出现 4KB 截断”变成“偶尔出现 64KB 截断”,问题反而更隐蔽。

2.3 中间件顺序这回事,顺序错了连接真的会失败

管道顺序是 ASP.NET Core 里很老的话题,在 WebSocket 这里同样敏感。我遇见过一个真实案例:把UseWebSockets写在某个会改写响应头的中间件之前,结果升级握手一直失败。原因是那个中间件把响应改成非升级状态码,浏览器立刻报连接失败。

建议的通用顺序是:

  1. 异常处理、请求日志这类基础设施中间件放最前面。
  2. 需要鉴权的中间件放第二层,如果连接需要身份校验,这里可以拦住未授权握手。
  3. 然后才是UseWebSockets。
  4. 最后是普通路由映射和静态文件。

如果发现浏览器控制台一直报WebSocket connection to 'ws://...' failed,优先怀疑前置中间件是否干扰了升级响应,而不是一上来查防火墙。这个经验我在第 5 节排障速查表里会再提一次。

还有一个容易忽略的点:WebSocketOptions是进程级配置,一个服务端配置对当前进程内所有 WebSocket 连接生效。如果你需要为不同端点设置不同的心跳策略,那就得在握手前自己读取请求信息,决定接受还是拒绝,而不是依赖全局 Options。

3. 服务端核心接收逻辑:从握手到消息聚合

这一节是整篇最核心的内容。我把一个能直接运行的完整接收循环拆开讲,先看整体结构,再解释每个关键判断。

前面的代码停在了AcceptWebSocketAsync()返回 WebSocket 的位置。接下来,服务端要进入一个长期运行的任务,反复调用ReceiveAsync读取客户端发来的数据,直到连接关闭。

3.1 先写一个能跑的接收循环,理解连接生命周期

第一版先写成最朴素的回声逻辑,把收到的文本原样返回给客户端:

private static async Task HandleConnection(WebSocket webSocket) { var buffer = new byte[4 * 1024]; var receiveResult = await webSocket.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); while (!receiveResult.CloseStatus.HasValue) { if (receiveResult.MessageType == WebSocketMessageType.Text) { var message = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); Console.WriteLine($"[收到] {message}"); var echoBytes = Encoding.UTF8.GetBytes($"Echo: {message}"); await webSocket.SendAsync( new ArraySegment<byte>(echoBytes), WebSocketMessageType.Text, true, CancellationToken.None); } receiveResult = await webSocket.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); } await webSocket.CloseAsync( receiveResult.CloseStatus.Value, receiveResult.CloseStatusDescription, CancellationToken.None); }

这段代码有三个关键点必须讲清楚。

第一点,receiveResult.CloseStatus。只要这个值还是 null,就说明连接没被对端主动关闭,循环继续。一旦对端发来关闭帧,CloseStatus会被赋一个协议状态码,循环退出,随后服务端回发一个关闭帧并释放资源。注意这个状态码不是给业务用的,是给 WebSocket 协议握手层用的,业务层不要拿它做文章。

第二点,MessageType。它能取到的值包括 Text、Binary 和 Close。真实业务里,文本消息我们一般解析成 JSON,二进制消息用来传文件分片或者自定义二进制协议。还有一个容易被忽略的类型是 Pong,心跳回复在框架层会被自动处理,开发者不需要手动写处理逻辑,但如果你想自己实现一套客户端心跳检测,需要记住这一点:经常收不到 Pong,可能不是对端没回,而是框架已经把 Pong 帧消化掉了。

第三点,ArraySegment<byte>缓冲区包装。ReceiveAsync需要一个用来落数据的缓冲区,每次调用之后,只有buffer里前Count个字节是有效的。如果继续复用同一个 buffer,上一次残留的数据还在,所以每次都要用Count取有效部分。我见过有人直接Encoding.UTF8.GetString(buffer),结果把整个 4KB 都转码,消息尾部多出一堆 NUL 字符,这个错误非常隐蔽,因为业务场景里消息本身就有随机文本,肉眼很难立刻发现是缓冲区尾部带来的脏数据。

3.2 大消息和分片:安全聚合完整消息才是正解

上面的标准版能跑通,但在生产环境里不够。因为当一条消息超过缓冲区大小,ReceiveAsync不会一次性返回全部数据,而是先返回一段,此时EndOfMessage为 false;继续调用 Receive,直到最后一段EndOfMessage为 true,才算收到一条完整消息。所以接收代码必须按“分片 + 聚合”的方式去写,尤其当你不知道对方会发多大多快的数据。

实践中我习惯封装一个函数,内部用一个MemoryStream把多次 Receive 拿到的字节拼起来,直到消息结束:

private static async Task<byte[]> ReceiveFullMessage(WebSocket webSocket, CancellationToken ct) { using var ms = new MemoryStream(); var buffer = new byte[4 * 1024]; WebSocketReceiveResult receiveResult; do { receiveResult = await webSocket.ReceiveAsync( new ArraySegment<byte>(buffer), ct); ms.Write(buffer, 0, receiveResult.Count); } while (!receiveResult.EndOfMessage); return ms.ToArray(); }

这段代码没有处理 Close 情况,完整的大循环里要额外判断CloseStatus和MessageType。但核心逻辑已经足够说明问题:缓冲区大小根本不限制你收多大消息,只是影响分片的次数。如果你在做过文件传输类的需求,知道这些之后就不会再被默认 4KB 吓到了。

还有一个性能细节:MemoryStream默认从一个小容量开始动态扩容,如果你知道业务里存在高频大消息,建议创建时就给一个合理的初始容量,比如直接new MemoryStream(64 * 1024),避免频繁扩容。扩容背后是内存申请和拷贝,在长连接场景下是会累积的,虽然单次不大,但架不住多次。

3.3 连接池设计:怎么统一管理并广播到多个客户端

现在有了单条连接,它得被保存下来,因为其他连接发来消息时,服务端可能要转发给它;管理后台可能也要找到它主动推数据。用一个线程安全的字典保存比较合适,Key 用连接的唯一标识,Value 就是 WebSocket 实例:

private static readonly ConcurrentDictionary<string, WebSocket> _connections = new();

接入时添加,断开时移除的完整流程是这样:

var connectionId = Guid.NewGuid().ToString("N"); _connections[connectionId] = webSocket; try { await HandleConnection(webSocket); } finally { _connections.TryRemove(connectionId, out _); }

广播就变成遍历字典,向每个还处于 Open 状态的连接发送数据:

private static async Task BroadcastText(string message) { var data = Encoding.UTF8.GetBytes(message); var tasks = new List<Task>(); foreach (var pair in _connections) { if (pair.Value.State != WebSocketState.Open) { _connections.TryRemove(pair.Key, out _); continue; } tasks.Add(pair.Value.SendAsync( new ArraySegment<byte>(data), WebSocketMessageType.Text, true, CancellationToken.None)); } try { await Task.WhenAll(tasks); } catch (WebSocketException) { // 有连接已经失效,下一轮广播时会通过 State 过滤清理 } }

这里有几个设计细节非常重要。

细节一:广播时不能立刻从字典移除连接。虽然SendAsync可能因为对端断开而抛异常,但移除操作应该优先依赖“连接关闭后的 finally 块”,否则广播里移除、另一条线程正好要往里写,字典就会乱。广播里只需要对State != Open的连接做过滤,顺手清理那些还没走到 finally 的异常情况。

细节二:ConcurrentDictionary虽然线程安全,但“读取然后修改”这种复合操作不是原子的。广播遍历时先读出这个连接、再判断 State,与此同时另一个线程可能把它断开,你依然可能对即将断开的连接调用 SendAsync,所以发送代码外面必须包 try-catch,这是 WebSocket 实现里最容易漏掉的一个安全设计。

细节三:Task.WhenAll(tasks)看起来是并发发送,但如果连接数量很大,比如上千条,一次性把所有 SendAsync 任务全部启动,可能导致底层同时输出大量数据,出现网络背压。经验做法是分组发送或者用通道队列串行。一般业务几百个连接直接 WhenAll 没问题,上万的话就要设计限流方案,别拿我的代码直接硬套。

3.4 主动关闭和异常断开,两种断开方式差别很大

WebSocket 连接断开大致分三类:对端发来关闭帧、服务端主动关闭、网络异常导致的静默断开。

对端发关闭帧时,ReceiveAsync会返回一个带CloseStatus的结果,服务端需要回应一个关闭帧,然后释放资源,这就是标准关闭流程。

服务端主动关闭则直接调用CloseAsync(WebSocketCloseStatus.NormalClosure, "正常关闭", ct)。这里有个容易被忽略的点:主动关闭和 ReceiveAsync 不能同时进行。如果在业务线程 A 里主动关闭,线程 B 还挂在一个 ReceiveAsync 上等待,这个 ReceiveAsync 会抛异常。解决方案是在自己的连接对象里维护一个状态位,或者每次接收都带上同一个 CancellationToken,关闭时先取消令牌,让 Receive 先退出再调用 CloseAsync。

网络异常断开则没有关闭帧,ReceiveAsync会抛WebSocketException或者IOException。这种情况下连接已经不可用,捕获异常之后不要尝试 CloseAsync,因为没有对端会回应,直接释放资源、从字典里移除即可。我在生产代码里的做法是:try 块里跑正常循环,catch 块里记录错误并跳过 CloseAsync,finally 块统一做清理。这样能避免无效调用,也让日志更干净。

4. 前后端联调:从浏览器页面连到消息广播

服务端写得再完整,还是得有一个客户端。WebSocket 最常见的客户端就是浏览器。这一节先演示一个最简单的联调页面,再讲一点协议设计的事。

4.1 写一个能跑的联调页面,五分钟打通链路

假设服务端映射在/ws,开发时监听的是本地地址,前端直接ws://127.0.0.1:端口/ws,注意不是http://:

<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>WebSocket 联调页</title> </head> <body> <input id="msg" placeholder="输入消息" /> <button id="sendBtn">发送</button> <ul id="log"></ul> <script> const socket = new WebSocket('ws://127.0.0.1:5213/ws'); socket.onopen = function () { appendLog('连接已建立'); }; socket.onmessage = function (event) { appendLog('收到: ' + event.data); }; socket.onclose = function (event) { appendLog('连接关闭,code=' + event.code + ', reason=' + event.reason); }; socket.onerror = function () { appendLog('连接出错'); }; document.getElementById('sendBtn').onclick = function () { const input = document.getElementById('msg'); socket.send(input.value); input.value = ''; }; function appendLog(text) { const li = document.createElement('li'); li.textContent = text; document.getElementById('log').appendChild(li); } </script> </body> </html>

在浏览器打开页面,同时运行服务端,控制台会打印“连接已建立”。如果这一步通了,整个链路就算打通了。注意端口要和服务端实际监听的端口保持一致,开发模式下在配置文件里确认一下,不要照抄我的 5213。

4.2 别让客户端随便发,先商量好一套消息格式

联调页面里socket.send可以直接发纯文本,但正式项目不能让客户端乱发。没有约定的实时消息,服务端解析起来会变成一堆 switch-case 地狱。

我的习惯是第一层先统一一个 JSON 外壳:

{ "type": "message.create", "data": { "touser": "用户A", "content": "你好" }, "traceId": "abcd1234" }

type对应业务动作,服务端拿到后先反序列化这个外壳,再根据 type 反序列化 data,以后扩展新的消息类型时不用改消息分发入口。

traceId是后来加的经验。实时消息排查起来最痛苦的问题是“到底哪条消息丢了”,有 traceId 之后,前端、服务端日志、网关三方可以拿它串起来。哪怕最初没有这个需求,我也建议在协议里留一个可选字段,避免后面需要层层改造。

4.3 浏览器自带调试面板,比想象中好用

联调时直接在浏览器开发者工具的 Network 标签页里找到 WebSocket 连接,点进去有一个 Messages 子面板,能看到每一帧的发送和接收内容。帧类型会标成 Text、Binary 或 Ping/Pong,还能看到连续的帧序列。

我有一次排查“为什么部分消息收不到”,就是靠这个面板发现其实是某个中间请求把连接关闭了,浏览器以为连接还活着,服务端已经在等待重连。在 Messages 面板里看到的那次 Close 帧,帮我把问题的注意力从网络层拉回应用层。平时写客户端逻辑时,多养成看消息帧的习惯,比事后猜来猜去强得多。

5. 真实项目中踩过的坑,整理成排障速查表

这一节直接按“现象-原因-解决”的方式写,方便对号入座。内容不只是理论,很多是我花了实际时间才定位的问题。

5.1 连接一直失败,服务端日志却没有记录

先查请求到底有没有走到UseWebSockets这一步。

第一,确认请求方法是 GET。WebSocket 握手协议要求通过 GET 请求发起,如果客户端用了 POST,大多数实现直接认为不是 WebSocket 请求。第二,确认浏览器地址前缀是ws://或wss://,不是http://。第三,确认路径完全一致。我见过服务端把路径从/ws改成/ws/,前端还在连旧地址,怎么都连不上。第四,如果中间件前面有鉴权管道拦截了请求,也会出现“日志里什么都没有”的现象,需要先看鉴权是否放行。

如果这些都正常但依然失败,再看反向代理层。用反向代理转发到后端服务时,需要额外开启协议升级支持,并转发Upgrade和Connection这两个头部。不配这一层,请求到后端时已经被剥掉 Upgrade 头,后端自然无法识别这是 WebSocket 请求。很多“生产环境连不上、本地能连上”的经典问题都是这个原因。

5.2 能连上,但发送消息报错或者消息丢了

能连上说明握手成功,之后的问题多半在消息发送这层。

最常见的是并发发送。WebSocket 的双向机制里,接收和发送是独立管道,但同一个连接上多个地方同时调 SendAsync 会冲突。两个人同时点了某个按钮,服务端这里收到两条消息、再并发广播,可能就有一次 SendAsync 抛出异常。解决办法有两个:要么给每个连接定义独立的发送锁,要么把发送请求统一入队,由一个消费者串行发送。我建议先做发送锁,实现量小且足够覆盖大多数场景。

还有一种情况是发送时连接已经关闭,State已经不是 Open,但代码没检查就 Send。这种异常用 try-catch 接住即可。真正的教训是:不要试图在 catch 里立刻重试发送,因为对端可能真的不在了,重试除了让日志更多,没有意义。

消息丢失则多半和分片有关。接收端代码如果只按一次ReceiveAsync处理,大消息的后半段根本没被读,消息就“丢了”。这种丢法不会报错,只是业务数据不完整。排查方式很简单:服务端日志对每条消息记录长度,如果发现反复出现的长度总是某个固定值,比如 4KB,基本就是分片处理缺失。

5.3 连接看起来活着,但客户端收不到任何推送

连接没有关闭,说明链路是通的;客户端收不到推送,就考虑是不是服务端压根没发出来,或者发了但被网络层拦截。

先看服务端有没有定时心跳。KeepAliveInterval是服务端的 Ping 帧周期,很多客户端会自动响应 Pong 帧。如果连接长时间没有消息,某些中间网络设备可能把这条“空闲连接”判断为无用并断开。这类问题最直观的表现:页面放着不动一晚上,第二天刷新前消息全收不到。

针对假死,我建议在应用层做补偿:服务端单独开一个后台定时任务,每隔 15 秒左右向所有在线客户端发送一条应用层心跳消息,不依赖协议层的 Ping。客户端收到这个消息,就知道连接还活着;如果超过某个阈值没收到,就主动关闭连接然后重新建一条。这比纯粹依赖浏览器层自动处理更可控,也方便在日志里统计在线率。

5.4 部署多个实例后,同一个连接的消息会乱广播

单机部署时,字典里放的是本进程所有连接。一旦上了多实例,这个字典只是“本机”的连接集合。实例 A 里的用户发了消息,实例 B 里的用户收不到,除非做了实例间同步。

这个问题的标准解法是引入发布订阅机制:任何实例收到需要广播的消息,先把它发布到公共消息通道,所有实例都订阅这个通道,再往自己本地的连接集合广播。这样一个实例持有的连接只是本地视图,但广播范围能覆盖整个集群。顺带一提,多实例情况下,请求如何路由到对应实例也是个问题。WebSocket 长连接一旦建立就绑定在某个实例上,所以负载均衡层必须配置粘性会话,否则每次请求可能被路由到不同实例,连接就会反复建立。

5.5 长时间运行后内存涨上去了,连接数却不高

如果连接数没有异常,但内存持续涨,大概率是连接对象没被释放。重点检查两个地方。

一是连接结束之后,有没有在 finally 里把字典项移除,同时释放 WebSocket 对象本身。二是每个连接上有没有挂事件订阅或者定时器。我之前碰到过一个场景:每个连接每隔几秒给自己发一个心跳,定时器实例被连接引用着,连接关闭时定时器没停,回收器根本收不掉整条对象链。内存泄漏往往不在连接本身,而是在连接“外面挂着的东西”上。

排查办法是连续抓两份内存转储,对比 WebSocket 相关对象的数量。如果存活的连接对象数量在减少,但内存占用不变,说明泄漏源不在连接本身,去查它持有的其他对象。

5.6 排障速查表汇总

现象优先检查点定位思路
连接失败,服务端无日志请求方法、路径、中间件位置抓包确认是否发出 Upgrade 头
生产连不上,本地能连反代是否转发 Upgrade 和 Connection 头直接访问后端端口对比
发送消息即抛异常并发 SendAsync、State 状态加发送锁,catch 后不再重试
消息内容不完整receive 循环没处理分片检查是否按 EndOfMessage 聚合
连接假死网络设备空闲断开、心跳周期加应用层心跳,客户端超时重连
多实例消息不互通发布订阅、粘性会话引入公共通道,配置负载均衡粘性
内存持续增长连接未移除、外部对象未释放抓内存快照对比对象数量

6. 把经验沉淀成代码前,最后想提醒的几个设计点

核心代码和排障经验都写完了,最后不做大总结,只讲几个我在实际项目里沉淀出来的设计原则,算给看完代码的同学一点额外参考。

第一,连接对象别裸奔。我的习惯是定义一个自己的连接包装类,里面持有 WebSocket、连接 ID、最近活动时间、当前状态等字段。这样无论是后台查看在线列表,还是做心跳清理,都有处可查。裸用一个 WebSocket 实例做业务,后面加字段的时候会非常被动。

第二,业务逻辑一定要和协议处理分离。接收循环里只做“收完整消息、解析外壳、按 type 分发”这三件事,具体业务操作放到独立方法里。为什么强调这个?因为实时通信业务调试本身就难,如果协议解析和业务混在一起,一个异常就能把整个接收循环搞挂,其他消息全都进不来。

第三,写日志要带连接 ID。每个连接进出手续都记录日志,心跳和广播格外要带 connectionId、type、消息长度、耗时这几个字段。出了线上问题,没有这类日志基本只能靠猜。

第四,生产环境的 WebSocket 服务都要准备一个连接观察接口。不需要复杂 UI,一个能展示当前在线连接数、最近一小时连接建立和断开次数的接口就够。大多数实时通信问题的排查起点就是“连接数到底有没有异常涨跌”,有这个基础数据,排查效率能高一大截。

如果你正准备把现有系统接入 WebSocket,建议从最简单的回声服务开始跑通链路,再逐步加入业务消息格式、连接池、广播这三个核心模块。一个能正常收发消息的最小闭环,比直接堆完整功能可靠得多。希望这篇东西能帮你少走几步弯路,我们下次在代码里见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询