news 2026/10/24 4:20:28

.NET WebSocket 长连接实战:连接管理、心跳重连与广播

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET WebSocket 长连接实战:连接管理、心跳重连与广播

简介:这份围绕 .NET WebSocket 的示例资料面向具备 C# 基础、希望在 WinForms 中实现实时通信的开发者,重点解决客户端与服务端连接建立、消息收发、异常关闭及与网页测试端联调等问题。压缩包共 174 个文件,约 984KB,以 47 个 cs 源码为核心,配合 config 配置、resx/resources 界面资源、exe/pdb/dll 编译产物,以及 html 测试页面和 md 说明文档,覆盖从工程配置到运行验证的完整结构。内容围绕 ClientWebSocket 客户端、HttpListener 服务端和 JavaScript WebSocket 页面展开,涉及异步连接、发送接收、连接管理、SSL/TLS 加密与并发性能等知识点,便于对照双端代码理解通信流程。已有 454 人学习,适合用作课程实验、项目原型或排错参考,也可在现有示例上二次改造,快速搭建可运行的实时通信验证环境。

1. .NET 里做 WebSocket:先分清三条技术路线

某公司的设备状态看板一开始靠 3 秒一轮的轮询,接口 QPS 翻了六倍,前置网关先顶不住了。改成 WebSocket 之后延迟从秒级压到毫秒级,但新问题立刻冒出来:连接怎么统一管、断线怎么重连、为什么上线后客户端每隔 60 秒准时掉一次。这套 .NET WebSocket 工程模板要解决的就是这一段——把服务端握手、连接注册表、心跳保活、广播分发、客户端退避重连这些散落环节收成一套能直接跑起来的代码。适合已经会用 C# 写接口、但第一次把长连接推上生产的人;也适合手上堆着一批轮询接口、想找个改造起点的团队。下面按「先跑通最小闭环、再管住连接、最后压到一万条」的顺序讲,中间会穿插几段我自己翻过车的记录。

2. 原生 WebSocket 落地:从握手到收发的最小闭环

2.1 中间件顺序决定握手成败

.NET 里的 WebSocket 服务端能力挂在HttpContext.WebSockets上,前提是管道里先执行过UseWebSockets()。这个中间件干的事不复杂:它给上下文挂上 WebSocket 管理器,并把Upgrade请求的后续处理接管过来。顺序放错是最常见的第一次翻车——把UseWebSockets()写在UseRouting()后面、或者干脆写在MapXXX之后,表现是IsWebSocketRequest恒为 false,客户端收到 400,日志里什么都没有。

最小可跑的服务端长这样:

// Program.cs —— 最小可跑的 WebSocket 服务端 var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); // 必须早于路由/端点映射执行,否则 IsWebSocketRequest 恒为 false app.UseWebSockets(new WebSocketOptions { // 服务端每隔 30 秒发一个 Ping 帧,探测死连接 KeepAliveInterval = TimeSpan.FromSeconds(30) }); app.Map("/ws", async (HttpContext ctx) => { if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; } // 子协议要和服务端约定一致,写死容易在灰度期出问题 using var socket = await ctx.WebSockets.AcceptWebSocketAsync( subProtocol: null, cancellationToken: ctx.RequestAborted); await EchoLoopAsync(socket, ctx.RequestAborted); }); app.Run();

几个参数值得单独说。KeepAliveInterval不是应用层心跳,它是协议层的 Ping 帧,由运行时自动发、对端自动回 Pong,业务代码完全感知不到;把它从默认的 2 分钟调到 30 秒,是为了让 NAT 网关和负载均衡的空闲连接表不至于提前把你踢掉。AcceptWebSocketAsync的第二个参数传ctx.RequestAborted很关键——客户端一断开,这个令牌立刻触发,接收循环能顺势退出,不用等下一次读超时。subProtocol留空表示不协商子协议,如果客户端硬要带Sec-WebSocket-Protocol而你这边不返回,部分严格实现的客户端会直接判握手失败。

另外提醒一句,别试图在 MVC 控制器里接 WebSocket。能在 action 里拿到HttpContext.WebSockets,但控制器管道有自己的模型绑定和结果序列化阶段,长连接一旦建立就没法正常走完 action 生命周期,using释放的时机也很难控制。挂在端点路由上或者写一个自定义中间件,是更省心的做法。

2.2 一条消息要走几步:分片、缓冲区与 EndOfMessage

新手最容易写错的地方,是默认「一次ReceiveAsync= 一条完整消息」。实际不是。WebSocket 在协议层保证了帧边界,但一个逻辑消息可以被拆成多个帧;更麻烦的是,运行时给你的WebSocketReceiveResult只保证「这次读到了 Count 个字节」,以及「这条消息读完了没有」。所以收一条消息必须循环,直到EndOfMessage为 true。

// 收一条完整消息,处理分片 + 缓冲区复用 static async Task<string?> ReceiveMessageAsync(WebSocket socket, CancellationToken ct) { // 从共享池借,避免每条连接每次读都产生一次大对象分配 var buffer = ArrayPool<byte>.Shared.Rent(4 * 1024); var ms = new MemoryStream(); try { while (true) { var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), ct); // 对端主动关闭:这里是唯一能拿到关闭码的时机 if (result.MessageType == WebSocketMessageType.Close) return null; ms.Write(buffer, 0, result.Count); // 单条消息超过 1MB 直接掐断,防止对端塞爆内存 if (ms.Length > 1024 * 1024) { await socket.CloseAsync( WebSocketCloseStatus.MessageTooBig, "message too large", ct); return null; } if (result.EndOfMessage) break; // 只有这里才算收到完整一条 } var payload = Encoding.UTF8.GetString(ms.GetBuffer(), 0, (int)ms.Length); return payload; } finally { ms.Dispose(); ArrayPool<byte>.Shared.Return(buffer); // 借了必须还,否则池会持续膨胀 } }

缓冲区定 4KB 是个折中:消息平均在几百字节到几 KB 之间时,4KB 基本能一次读完,池的复用率也高。如果你的业务消息普遍在 64KB 以上,把池改成按大小分级(ArrayPool<byte>.Create(64 * 1024, 50)),并且第一条ReceiveAsync之后就用result.Count判断是否要扩容,别一股脑用大缓冲区,一万条连接每人 64KB 就是 640MB 常驻。

MemoryStream只在跨分片时才有意义,单帧读完的常态下它反而是浪费。追求极致可以做一个快速路径:第一次读如果EndOfMessage为真,直接Encoding.UTF8.GetString(buffer, 0, result.Count)返回,不碰MemoryStream。批量推送场景下这条优化能省掉可观的 Gen0 分配。

还有一点容易忽略:MessageType为Binary时同样走这套逻辑,只是不要做UTF8.GetString。把文本和二进制混在一个方法里判断,比开两个接收循环要省事,也避免了一条连接两个循环同时读同一个 socket 的并发问题——WebSocket实例上同时发起多个ReceiveAsync是未定义行为,运行时不会帮你拦。

2.3 客户端:ClientWebSocket 的连接超时与退避重连

ClientWebSocket有个坑:ConnectAsync没有内置超时,DNS 卡住或者对端半开连接时,这个调用可能挂几十秒。必须用CancellationTokenSource自己兜。

// 带指数退避的客户端连接骨架 static async Task RunClientAsync(CancellationToken appStopping) { var delay = TimeSpan.FromSeconds(1); var maxDelay = TimeSpan.FromSeconds(30); while (!appStopping.IsCancellationRequested) { using var socket = new ClientWebSocket(); // 客户端侧的 Ping 周期,比服务端略短,保证先发现死链 socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); // 关键:连接阶段必须有独立超时,否则会挂住整个重连循环 using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(appStopping); connectCts.CancelAfter(TimeSpan.FromSeconds(5)); try { await socket.ConnectAsync(new Uri("wss://gateway.internal/ws"), connectCts.Token); delay = TimeSpan.FromSeconds(1); // 连上了就把退避重置 await SubscribeAsync(socket, appStopping); // 重连后必须重新订阅房间/主题 await ReceiveLoopAsync(socket, appStopping); } catch (OperationCanceledException) when (appStopping.IsCancellationRequested) { break; // 进程正在退出,不算异常 } catch (Exception ex) { // 记录后继续退避,不要在这里 throw 把整个循环打死 Log(ex.Message); } await Task.Delay(delay, appStopping); // 退避翻倍但封顶 30 秒,避免网络恢复后还在长睡 delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, maxDelay.TotalSeconds)); } }

退避从 1 秒翻倍到 30 秒封顶,是长连接客户端的常规做法。翻倍是为了避免服务端刚重启就被几万个客户端同时打回来(惊群),封顶是为了不要在服务端恢复后还傻等。真正容易被忘掉的一行是重连成功后的重新订阅——连接是新的,服务端那边的订阅关系早没了。血泪经验:某次灰度发布后数据不刷新,排查两小时才发现重连逻辑少调了一次Subscribe,连接是通的,就是收不到推送。

ClientWebSocket还建议显式设置socket.Options.CollectHttpResponseDetails = true,握手失败时能拿到状态码,否则只有一句The server returned status code '401' when status code '101' was expected,几乎没法定位。

2.4 关闭握手:别拿 Abort 当收尾

CloseAsync、CloseOutputAsync、Abort三个方法语义完全不同。CloseAsync发关闭帧并等对端回,CloseOutputAsync只发不等,Abort直接掀桌子不发帧。服务端在using里释放 socket 时如果走的是Abort,对端会收到 1006(异常关闭),很多前端库会把它当错误弹提示。

// 正确的收尾顺序:先发关闭帧,再释放 static async Task GracefulCloseAsync(WebSocket socket, CancellationToken ct) { if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) { try { // 1001 = 服务端下线,客户端应根据这个码决定是否立刻重连 await socket.CloseOutputAsync(WebSocketCloseStatus.EndpointUnavailable, "server shutdown", ct); } catch (WebSocketException) { // 对端已经断了,这里吞掉即可,不要让它冒泡打断关闭流程 } } socket.Dispose(); }

关闭码要商量着用:1000 表示正常关闭,客户端不该重连;1001 表示一侧要下线,客户端应该重连并且可以把退避重置;1011 表示服务端内部错误,客户端应该退避后再来。前端拿着关闭码做分支,比在应用层再包一层{type:"bye"}要省事。

3. 连接管理:把 5000 条长连接稳稳握在手里

3.1 连接注册表:键怎么设计,什么时候写,什么时候删

连接一多,第一件事是建注册表。用ConcurrentDictionary<string, Session>,键选什么直接决定后面好不好用。用Guid或者自增 ID 的问题是:业务侧根本不知道自己该往哪个 ID 推。常见做法是客户端连上来后先发一条{type:"auth", userId:"...", topics:[...]},服务端校验通过再把这个连接挂到用户和主题两个维度上。

public sealed class ConnectionRegistry { // 主表:连接 ID -> 会话 private readonly ConcurrentDictionary<string, ClientSession> _byId = new(); // 主题倒排:主题 -> 连接 ID 集合,广播时直接查这张表 private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _byTopic = new(); public string Register(WebSocket socket, string? userId) { var id = Guid.NewGuid().ToString("N"); var session = new ClientSession(id, userId, socket); // 先写主表,再挂主题;顺序反了会出现"主题里有、主表查不到"的悬空引用 _byId[id] = session; return id; } public void BindTopic(string connId, string topic) { var set = _byTopic.GetOrAdd(topic, _ => new ConcurrentDictionary<string, byte>()); set[connId] = 0; // 用 byte 占位,等价于一个无锁的 HashSet } public void Unregister(string connId) { if (!_byId.TryRemove(connId, out var session)) return; foreach (var topic in session.Topics) { if (_byTopic.TryGetValue(topic, out var set)) { set.TryRemove(connId, out _); // 主题空了就顺手删掉,否则主题表会随着业务维度缓慢泄漏 if (set.IsEmpty) _byTopic.TryRemove(topic, out _); } } } }

注销顺序要反过来:先从主表摘,再清主题。因为广播线程是「查主题 → 查主表 → 发送」,先摘主表能保证正在广播的那批连接至少拿不到一个已释放的 socket。反过来做,广播线程有可能拿到一个刚被Dispose的 socket,抛ObjectDisposedException,而且这个异常会打断整个广播循环。

ConcurrentDictionary的TryRemove(key, out value)是原子的,用它做「谁先删谁负责清理」的判定,比加锁判断存在性再删要靠谱。这个方法返回值直接告诉你「是不是我删掉的」,用来防止重复注销。

3.2 广播:foreach 里直接 await 的三个后果

最多人写的广播是这样:

// 反面示例,不要这么写 foreach (var session in _byId.Values) { await session.Socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct); }

三个后果都很实在。第一,5000 个连接串行发,一个慢客户端(TCP 窗口满、对端卡住)就能把整个广播拖到几秒,后面所有人都跟着等。第二,SendAsync在同一个 socket 上并发调用是未定义行为,如果两条业务线程同时广播,直接翻车。第三,任何一条连接抛异常,循环中断,后面的人收不到。

改成每个会话自带一把发送锁 + 并行分发:

public sealed class ClientSession { // 每连接一把锁,保证同一个 socket 上的发送严格串行 private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly WebSocket _socket; public async Task SendAsync(ReadOnlyMemory<byte> payload, CancellationToken ct) { if (_socket.State != WebSocketState.Open) return; await _sendLock.WaitAsync(ct); try { // 发送队列长度是背压信号,超过阈值说明这个客户端跟不上了 await _socket.SendAsync(payload, WebSocketMessageType.Text, true, ct); } catch (WebSocketException) { // 单条连接发送失败不能影响其他人,这里吞掉,交给心跳线程去回收 } finally { _sendLock.Release(); } } } public async Task BroadcastAsync(string topic, ReadOnlyMemory<byte> payload, CancellationToken ct) { if (!_byTopic.TryGetValue(topic, out var ids)) return; // 最多 64 路并发,避免一次唤醒几千个线程把线程池打穿 var gate = new SemaphoreSlim(64); var tasks = new List<Task>(ids.Count); foreach (var id in ids.Keys) { if (!_byId.TryGetValue(id, out var session)) continue; await gate.WaitAsync(ct); tasks.Add(Task.Run(async () => { try { await session.SendAsync(payload, ct); } finally { gate.Release(); } }, ct)); } await Task.WhenAll(tasks); }

SemaphoreSlim(64)这个并发闸门是血泪经验。不设闸门直接Task.WhenAll,一万条连接会瞬间创建一万个任务,线程池扩容跟不上,延迟反而比串行还高。64 这个数字跟机器核数和单次发送的耗时有关,一般取核数的 4~8 倍,本地实测 32~128 之间差异不大,超过 256 反而开始退化。

真正的背压处理在发送锁外面:给每个会话配一个有界Channel<byte[]>(容量 100 左右),广播只负责往 channel 里写,独立的发送循环从 channel 读。写不进去说明这个客户端已经跟不上了,直接断开重连,比无限堆积内存要明智。这套结构在模板里是可选开关,小规模场景用上面的锁就够了。

3.3 心跳、超时与半开连接回收

KeepAliveInterval的 Ping 只能发现「协议层没响应」的连接。很多真实故障是协议层还活着、业务层已经死了——客户端进程被挂起、移动端切后台、虚拟机被冻结,Ping 帧由操作系统内核代回,你的业务代码永远收不到任何东西。所以服务端必须自己维护一个应用层的心跳时间戳。

// 每 15 秒扫一遍,90 秒没消息的直接清掉 public sealed class HeartbeatService : BackgroundService { private readonly ConnectionRegistry _registry; private readonly ILogger<HeartbeatService> _logger; protected override async Task ExecuteAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15)); while (await timer.WaitForNextTickAsync(ct)) { var now = DateTimeOffset.UtcNow; var dead = new List<string>(); foreach (var (id, session) in _registry.Snapshot()) { // LastSeen 在每次收到任意消息时刷新,包括客户端主动发的 pong if (now - session.LastSeen > TimeSpan.FromSeconds(90)) dead.Add(id); } foreach (var id in dead) { _logger.LogInformation("reap idle connection {Id}", id); _registry.Unregister(id); await _registry.CloseAsync(id, WebSocketCloseStatus.EndpointUnavailable, "idle timeout"); } } } }

客户端侧对应地每 25 秒发一条{"type":"ping"},服务端收到回{"type":"pong"}并且刷新LastSeen。这个周期要小于服务端的判定阈值,也要小于网关的空闲超时,一般取网关超时的三分之一。90 秒的阈值不是拍脑袋:它要能容忍一次 30 秒的 GC 停顿和一次网络抖动重传,又不能长到让前端用户看着「在线」其实早就断了。

Snapshot()这个方法别省。直接遍历_byId的时候如果同时有连接注册进来,ConcurrentDictionary的枚举器给出的是弱一致视图——大多数情况下没问题,但你在循环里调Unregister改同一张表,某些版本下会出现漏删。先拷一份 key 列表再处理,代价是每 15 秒一次几十 KB 的分配,换来的是行为可预测。

4. SignalR 与原生 WebSocket 的边界

4.1 手写 WebSocket 要自己补的七件事

用原生System.Net.WebSockets意味着下面这些都得自己实现,一件都跑不掉:

能力原生实现要点不做的后果
分组订阅主题倒排表 + 连接注销时清理主题表随业务维度缓慢泄漏
断线重连客户端指数退避 + 重连后重新订阅重连成功但收不到推送
心跳保活应用层 ping/pong +LastSeen时间戳半开连接长期占资源
背压控制每连接有界队列 + 丢弃策略慢客户端拖垮整个进程内存
消息序列化自己选 JSON / MessagePack / 二进制帧大消息 CPU 打满
关闭语义关闭码约定 + 优雅关闭前端反复弹「连接异常」
集群广播Redis / 消息队列做跨节点转发多副本部署后推送只到一半人

最后一项最容易被低估。单机跑得好好的,一上双副本就开始「有时候刷新有时候不刷新」——因为用户的连接落在 A 节点,业务事件在 B 节点产生。原生方案必须自己接一层跨节点转发,通常是 Redis 的 pub/sub 或者任意一个消息队列,每节点订阅同一个频道,收到后只推给本机持有的连接。这一层的代码量不大,但设计时就得留好接口,事后补会很痛苦。

反过来,如果业务就是「单机、内网、连接数几千、只要推文本」,自己写反而是最优解:没有额外依赖,行为完全可控,排查问题时链路清晰。不要因为 SignalR 名气大就上,多一层抽象就多一层黑匣子。

4.2 SignalR 与原生 WebSocket 混跑在一条管道上

现实里更常见的是混合场景:新前端用 SignalR,老设备只认裸 WebSocket,还得共用同一个端口和同一份鉴权逻辑。这两者可以共存,因为 SignalR 本身就有一种传输方式是 WebSocket,它挂在/hub路径下,原生端点挂在/ws路径下,UseWebSockets()只注册一次。

var app = builder.Build(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); app.UseRouting(); // 新前端走 SignalR,自带分组、重连、序列化 app.MapHub<DeviceHub>("/hub"); // 老设备走裸 WebSocket,协议由设备固件决定,改不了 app.Map("/ws", async ctx => { /* 上面的原生处理逻辑 */ }); app.Run();

顺序上UseWebSockets()必须早于UseRouting(),MapHub和Map谁先谁后无所谓,按路径匹配。鉴权这里有个坑:原生端点在Map里拿不到[Authorize]那套过滤器,得手写校验,通常是从 query string 里取 token(浏览器 WebSocket API 传不了自定义头),验完再决定是否AcceptWebSocketAsync。直接返回 401 之前记得别 accept,否则客户端会拿到一个已经建立又被关掉的连接,报错信息很难看懂。

4.3 选型对照:什么规模用什么

维度原生 WebSocketSignalR
首次跑通成本半天到一天一两个小时
分组广播自己写倒排表内置Groups.AddToGroupAsync
传输降级无,握手失败就是失败自动降级到 SSE / 长轮询
序列化完全自选默认 JSON,可换 MessagePack
集群自己接消息队列需要背板(Redis 等)
排查难度链路短,日志自己打抽象层多,但日志埋点齐全
适合场景内网设备、固定协议、连接数可控浏览器端、功能迭代快、需要降级

我的一般判断是:如果客户端是浏览器且需求里出现「分组、重连、多端兼容」,直接 SignalR,别折腾;如果客户端是嵌入式设备、固件协议写死了、或者要对接第三方只给你裸 WebSocket 接口,那原生实现更省事。中间地带(浏览器端但连接数上万、对延迟极敏感)可以用 SignalR 做控制通道、原生 WebSocket 做数据通道,两套并存,各干各擅长的事。

5. 上线前的排查清单:网关、超时与内存

5.1 网关层的 Upgrade 与空闲超时

本地跑通、一放到前置网关后面就连不上,是这套东西最常见的上线事故。原因是 WebSocket 握手依赖 HTTP 的Upgrade机制,很多网关默认不开隧道透传。以 nginx 为例:

# WebSocket 路径必须显式透传 Upgrade 头 location /ws { proxy_pass http://backend_pool; # 默认是 1.0,不支持 Upgrade,必须显式改成 1.1 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # 空闲超时从默认 60s 拉到 1 小时,否则连接会被定时切断 proxy_read_timeout 3600s; proxy_send_timeout 3600s; # 关掉缓冲,否则小消息会被攒着不发,前端表现为延迟忽高忽低 proxy_buffering off; # 长连接要透传真实客户端地址,否则限流和日志全部失真 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }

proxy_read_timeout是最容易漏的一行。默认 60 秒,正好对应「每隔 60 秒准时掉线」这个经典症状——业务心跳设成 30 秒的话勉强能续命,但凡心跳周期调大一点立刻暴露。Windows 上如果走 IIS 前置,对应的是web.config里的requestTimeout,以及必须装 WebSocket 协议模块,否则握手直接 400。这条在本地开发和测试环境都发现不了,只有上了生产才见真章。

还有个隐蔽点:部分负载均衡会做「连接数上限」,超过就悄悄拒掉新连接,客户端看到的是握手超时。压测时一定要在网关侧看连接数曲线,别只看服务端。

5.2 五个真实踩坑记录

一、客户端每隔 60 秒准时断开现象:本地不复现,上线后所有客户端整分钟断一次,重连后又能用。 原因:网关proxy_read_timeout保持默认 60 秒,期间没有任何上行数据就切断隧道。 解决:把网关超时拉到 3600 秒,同时把服务端KeepAliveInterval调到 30 秒,让协议层 Ping 帧持续刷新网关的空闲计时。

二、内存每小时涨 200MB,重启才降现象:dotnet-counters看 Gen2 持续增长,连接数平稳。 原因:接收循环里每次都new byte[4096],而且用了MemoryStream却没有Dispose,大对象堆碎片化。 解决:改用ArrayPool<byte>.Shared.Rent/Return,MemoryStream放进using,加上单条消息 1MB 的上限保护。改完之后 Gen2 曲线基本拉平。

三、广播的时候偶发ObjectDisposedException现象:高峰期日志里每分钟几十条,不影响功能但很吵。 原因:广播线程拿到会话引用后,另一条线程刚好注销并Dispose了 socket。 解决:发送前判State == Open不够(中间有时间窗),把「摘除注册表」放在Dispose之前,发送方法内部用try/catch (WebSocketException)兜住,让注销先于释放成为唯一路径。

四、多副本部署后推送只到一半用户现象:单副本正常,扩到两个副本后大约一半的推送收不到。 原因:业务事件产生在 A 节点,用户连接落在 B 节点,广播只遍历了本机注册表。 解决:接一层 Redis pub/sub,每个节点订阅同一频道,收到事件后只推本机持有的连接。同时给消息加一个单调递增的序号,方便客户端丢弃重复。

五、前端一直「连接中」,服务端日志干干净净现象:握手请求根本没打到服务端。 原因:客户端用的是wss://,网关那一层只配了http的 location,TLS 终止后路径不匹配,静态文件服务返回了 200 的首页 HTML。 解决:检查网关的 TLS 终止配置和路径前缀,另外在客户端把CollectHttpResponseDetails打开,握手异常时能直接看到返回的状态码和内容类型,比对着日志猜快得多。

提示:这五条里有三条只在「本地正常、上线异常」时出现,本地无论压多久都复现不了。前置网关的配置要提前拿到手,别等发布当天才开始对参数。

6. 压测与验证:把一万条连接跑起来看内存曲线

上线前的最后一关是压测,但不是拿 ab 打 QPS,而是模拟长连接的生命周期。我一般写一个小的控制台客户端,按批建立连接、每条连接定时发心跳、随机断开一部分再重连,跑 30 分钟看服务端的曲线。

// 压测客户端:并发建连 + 心跳 + 随机断开重连 var total = 10_000; var semaphore = new SemaphoreSlim(100); // 建连限速,防止瞬间打满 var clients = new List<ClientWebSocket>(total); var tasks = Enumerable.Range(0, total).Select(async i => { await semaphore.WaitAsync(); try { var ws = new ClientWebSocket(); ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await ws.ConnectAsync(new Uri("ws://gateway.internal/ws"), cts.Token); lock (clients) clients.Add(ws); // 每 25 秒一条应用层心跳,模拟真实客户端 _ = Task.Run(async () => { while (ws.State == WebSocketState.Open) { await Task.Delay(25_000); var ping = Encoding.UTF8.GetBytes("{\"type\":\"ping\"}"); await ws.SendAsync(ping, WebSocketMessageType.Text, true, CancellationToken.None); } }); } finally { semaphore.Release(); } }); await Task.WhenAll(tasks);

三个指标必看:一是服务端 RSS(常驻内存),一万条连接稳定后应该在几百 MB 量级,如果线性上涨说明有泄漏;二是 GC 的 Gen2 次数,30 分钟内超过十次就得回头看分配;三是每条连接的发送延迟 P99,超过 200ms 说明并发闸门或者锁竞争有问题。

一个反直觉的结论:连接数上到八千以后,瓶颈往往不在 socket,而在心跳服务的定时器。PeriodicTimer每 15 秒遍历一万个会话,如果Snapshot()里做了深拷贝或者在里面打日志,光这一项就能吃掉 10% 的 CPU。把日志降级、遍历改成只读快照,比换序列化库管用得多。

从那以后我每次上线长连接服务,都强制走一遍「网关超时确认 → 一万连接压 30 分钟 → 看 Gen2 和 RSS 曲线」这三步,哪个环节跳过,后面一定会以另一种方式还回来。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 4:19:01

Visual Studio Win32五子棋:C++ GUI工程与AI实战

简介&#xff1a;本资源是一个基于Visual Studio开发的五子棋桌面游戏完整工程&#xff0c;面向C/C#初学者与图形界面编程学习者&#xff0c;解决从零实现人机对战与人人对战游戏的核心难点。项目采用Windows Forms框架&#xff08;含EasyX图形库调用&#xff09;&#xff0c;涵…

作者头像 李华
网站建设 2026/10/24 4:17:40

VS2003安装配置指南:XP SP3虚拟机与ASP.NET 1.1环境

简介&#xff1a;面向.NET初期开发者的Visual Studio .NET 2003简体中文安装包&#xff0c;适合需要回顾微软早期开发平台、维护旧项目或研究IDE演进的技术人员。该版本以.NET Framework 1.1为核心&#xff0c;提供C#、VB.NET、C .NET等多语言支持&#xff0c;并集成智能感知、…

作者头像 李华
网站建设 2026/10/24 4:17:38

MQTTnet 同进程 Broker/Client 联调与落盘实践

简介&#xff1a;这是一份面向.NET桌面开发者的MQTT通信示例代码包&#xff0c;围绕WinForm与MQTTnet展开&#xff0c;重点演示服务端与客户端之间的连接、订阅/发布流程&#xff0c;以及将订阅到的消息保存到文件。包内包含服务端与客户端两个独立工程&#xff0c;8个cs源码文…

作者头像 李华
网站建设 2026/10/24 4:17:34

大模型推理框架源码实战:KV Cache、连续批处理与压测调参

简介&#xff1a;VLLM-0.7.3 源码包面向大语言模型推理引擎的学习者、研究者与工程实践者&#xff0c;聚焦超大规模模型在高速令牌生成、高效内存管理与企业级稳定推理上的性能优化问题&#xff0c;适合具备一定 Python 与深度学习基础、希望深入理解推理调度与算子实现的读者。…

作者头像 李华
网站建设 2026/10/24 4:16:51

JavaEE网上书店课程设计:三层架构、购物车与下单事务实战

简介&#xff1a;面向课程设计与毕业设计场景的JavaEE网上书店项目&#xff0c;提供完整源代码与MySQL建库脚本&#xff0c;适合需要完成Web课程作业、理解企业级应用分层与全栈流程的学生参考。压缩包共88个文件&#xff0c;约519KB&#xff0c;以40个JSP页面、11个Java源文件…

作者头像 李华
网站建设 2026/10/24 4:15:08

v0 入门:用对话和 Design Mode 做出一个作品集网页

适合人群&#xff1a;想快速做一个好看的网页界面&#xff08;个人作品集、活动介绍页、小工具&#xff09;&#xff0c;但前端基础薄弱的同学。需要说明&#xff1a;v0 由 Vercel 推出&#xff0c;发布功能依托 Vercel&#xff1b;套餐与额度以官方定价页为准&#xff1b;界面…

作者头像 李华