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 最小可用的服务端Demo
ASP.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 ArraySegment<byte>(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 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); }这个Demo能跑通,但它有几个隐藏问题:缓冲区固定4KB,收到的消息一旦超过4KB就会被截断;消息可能跨多个帧到达,只读一次远远不够;没有做连接管理和异常处理。下面逐条解决。
注意:
ReceiveAsync返回的Count表示本次实际读到多少字节,不是缓冲区大小。EndOfMessage表示当前这条完整消息是否已经读完,这是WebSocket分片机制的关键。消息体大时,一条消息会拆成多个帧发过来,必须循环读取到EndOfMessage为true才算收完整。
2.2 中间件配置,参数别乱调
app.UseWebSockets()可以传入一个WebSocketOptions对象。我用一个表格把参数的默认值和实际建议写清楚:
| 参数 | 默认值 | 建议 | 说明 |
|---|---|---|---|
KeepAliveInterval | 30秒 | 30秒或60秒 | 服务端主动发送Ping帧的间隔,用于检测死连接 |
ReceiveBufferSize | 4KB | 4KB~16KB | 单次接收缓冲区大小,不决定单条消息上限 |
SendBufferSize | 4KB | 4KB~16KB | 发送缓冲区大小 |
AllowedOrigins | 空(允许所有) | 配置白名单 | 防止跨站WebSocket劫持(CSWSH),必须配 |
DangerousDeflateOptions | null(不压缩) | 视业务而定 | 开启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 多客户端管理与消息广播
真实项目不会只有一条连接。需要维护一个线程安全的连接池,一般用ConcurrentDictionary<string, WebSocket>,key是用户标识或连接ID:
public class WebSocketConnectionManager { private readonly ConcurrentDictionary<string, 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 ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, ct); } } } public IReadOnlyList<WebSocket> 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 ConcurrentDictionary<string, 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 ArraySegment<byte>(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 ArraySegment<byte>(sendBytes), WebSocketMessageType.Text, true, cts.Token); var buffer = new byte[1024 * 4]; var result = await client.ReceiveAsync( new ArraySegment<byte>(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 ArraySegment<byte>(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匹配路径 |
| 握手400 | Origin被拒或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秒刷一次”的体验强了不止一个档次。