
WebSocket 连接泄漏导致服务雪崩我如何用连接池监控把在线数从 10 万稳定到可控上周三凌晨两点我被一通电话叫醒。监控告警显示在线用户连接数飙到了 10 万而服务器内存使用率已经顶到了 95%。更诡异的是活跃用户实际上只有 2 万左右——剩下的 8 万连接都是僵尸。说白了WebSocket 连接泄漏了。而且已经泄漏了将近一周只是之前没到临界点没人发现。背景业务为啥选 WebSocket我们做的是实时数据推送服务客户端需要持续接收行情更新。HTTP 轮询太烧钱SSE 单向不够灵活最后选了 WebSocket 全双工。技术栈很简单Spring Boot Netty 自定义的 WebSocket 服务端。每个连接进来我们把它注册到一个 ConcurrentHashMap 里用心跳检测保活。听起来没问题对吧但问题就藏在这个看起来没问题的设计里。问题爆发从偶发卡顿到全面雪崩事情最早发生在上周一。运维群里有人说每隔几个小时服务会短暂卡顿十几秒然后自己恢复。当时大家以为是 GC看了日志也没发现 Full GC就先把这事放下了。到了周三凌晨监控直接炸了。连接数曲线呈指数型增长内存占用同步飙升。我登录服务器一看lsof -i 显示 ESTABLISHED 连接有 10 万但业务层的活跃会话只有 2 万。剩下的 8 万连接都是客户端断开了但服务端没清理掉的孤儿。为什么会这样排查根因定位的 3 小时第一步确认连接泄漏点我用 netstat -ant | awk ‘{print $6}’ | sort | uniq -c 统计连接状态发现 CLOSE_WAIT 状态的连接有 4 万多。这说明客户端已经发了 FIN但服务端没响应也没关闭连接。再查业务日志发现一个规律每次连接数暴涨都伴随着一批客户端同时掉线——通常是用户网络切换比如 WiFi 切 4G。这些客户端发完断开信号就消失了但服务端的心跳检测间隔是 30 秒在这 30 秒内如果服务端没正确处理 close 事件连接就会一直挂在那里。第二步代码审查翻代码才发现我们在channelInactive和exceptionCaught里做了连接清理但漏了一个场景客户端正常发送 close 帧后服务端收到了WebSocketCloseFrame但处理逻辑里只做了日志打印没调用channel.close()也没从连接池里移除这个 channel。也就是说客户端礼貌地说我要走了服务端回了好的但忘了把人家从名单里划掉。这种礼貌断开的场景反而成了泄漏的重灾区。第三步验证根因为了确认我写了个简单的复现脚本importasyncioimportwebsocketsasyncdefpolite_disconnect():uriws://localhost:8080/wsasyncwithwebsockets.connect(uri)asws:awaitws.send(hello)# 正常发送 close 帧awaitws.close()# 模拟 1000 个客户端礼貌断开asyncdefmain():tasks[polite_disconnect()for_inrange(1000)]awaitasyncio.gather(*tasks)asyncio.run(main())跑完之后服务端的连接计数器纹丝不动——1000 个连接全泄漏了。实锤。修复三层防护方案第一层补全 close 帧处理在 Netty 的WebSocketServerProtocolHandler后面加一个自定义的ChannelDuplexHandlerOverrideprotectedvoidchannelRead0(ChannelHandlerContextctx,WebSocketFrameframe){if(frameinstanceofCloseWebSocketFrame){// 收到 close 帧主动关闭 channel 并清理StringclientIdctx.channel().attr(CLIENT_ID_KEY).get();connectionPool.remove(clientId);ctx.channel().close();log.info(Client {} closed connection gracefully,clientId);return;}// ... 其他帧处理}第二层连接池强制保活检查原来的心跳检测只是读超时判断现在加了一个双向校验每次心跳 pong同时检查连接是否还存活。如果 channel 的isActive()返回 false立即从连接池移除。Scheduled(fixedRate10000)publicvoidhealthCheck(){connectionPool.forEach((clientId,channel)-{if(!channel.isActive()){connectionPool.remove(clientId);log.warn(Removed inactive connection: {},clientId);}});}第三层连接数上限 优雅拒绝设置单实例最大连接数阈值比如 5 万超过后对新连接返回 503 并引导到备用节点。同时加了一个 Prometheus 指标websocket_active_connections配了告警规则-alert:WebSocketConnectionLeakexpr:websocket_active_connections-websocket_authenticated_sessions10000for:5mlabels:severity:criticalannotations:summary:WebSocket 连接泄漏可能正在发生效果从 10 万到 2 万修复上线后连接数在 10 分钟内从 10 万降到了正常的 2 万左右。内存使用率从 95% 回落到 45%。更重要的是 polite disconnect 的测试脚本再跑连接计数器能正确归零了。踩坑记录1. 不要只依赖心跳检测心跳检测能发现读超时但发现不了客户端已发 close 但服务端没处理的场景。这种半开连接比完全断开的更隐蔽。2. Netty 的 channelInactive 不是万能的客户端正常发 close 帧时Netty 不会触发channelInactive而是会触发userEventTriggered或channelRead收到CloseWebSocketFrame。如果只处理channelInactive会漏掉一大片。3. 连接池用 ConcurrentHashMap 别忘了清理很多教程教你怎么 put但很少教你怎么及时 remove。长连接服务里清理逻辑和创建逻辑同等重要。4. 监控要对比连接数和活跃会话数单看连接数没意义。一定要看两者的差值差值异常就是泄漏信号。写在最后这次事故让我意识到WebSocket 这种长连接服务最大的风险不是连接不上而是连接了断不开。短连接服务HTTP天然有生命周期长连接反而容易因为忘了清理而慢慢积累最终拖垮整个服务。如果你也在用 WebSocket建议现在就去检查一下你的 close 帧处理逻辑。说不定你也有几万条僵尸连接正在默默吃内存。有问题欢迎评论区交流。