应基于last_activity时间戳识别僵尸连接,即每次收到任何数据帧(含ping/pong)均更新该时间戳,超时(如90秒)未活动即强踢;需惰性分片扫描、异步发CloseFrame而非直接close,并确保清理与状态更新原子性。

如何用 last_activity 时间戳识别僵尸连接
真正有效的“强踢”不是靠客户端是否回复 pong,而是看它最后一次发消息是什么时候。只要 last_activity 距今超过阈值(比如 90 秒),就该清理——哪怕它刚发过一个 pong,但之后再没动静,照样算僵尸。
关键点在于:这个时间戳必须在每次收到任何数据帧(text/binary/ping/pong)时都更新,不能只在业务消息里更新。很多实现漏掉 ping/pong 帧的处理,导致心跳正常但业务停滞的连接被长期保留。
- Node.js +
ws库中,要在ws.on('message')和ws.on('ping')、ws.on('pong')三个地方都调用updateLastActivity() - Spring Boot +
spring-websocket中,需重写WebSocketSession.sendMessage()不够,得监听TextMessage/BinaryMessage之外的PingMessage,这需要自定义WebSocketHandlerDecorator - Netty 的
WebSocketServerProtocolHandler默认不暴露 ping/pong 事件,得在 pipeline 中插入自定义ChannelInboundHandler拦截WebSocketFrame子类
定时扫描 vs. 惰性清理:哪种更适合百万连接
每秒扫一遍所有连接?在 50 万连接规模下,一次全量遍历可能耗时 200ms+,且频繁 GC 容易引发 STW。更稳的做法是惰性清理 + 分片扫描。
把连接按哈希分片(比如 connectionId % 100),每个线程只负责其中 1/100 的连接;再让定时器每 3 秒轮询一个分片。这样单次扫描最多处理 5000 个连接,CPU 峰值可控,也不会阻塞主线程。
- 分片数不宜过小(1000),会增加调度开销和内存碎片
- 扫描间隔建议设为超时阈值的 1/3(如超时 90s,扫描间隔 30s),保证僵尸连接最多滞留 2 个周期
- 避免在扫描中直接
close()socket——应投递到 IO 线程执行,否则可能触发线程安全问题(尤其 Netty 中)
强踢时为什么不能直接 close(),而要 sendClose() + wait
直接调用 ws.close() 或 session.close() 是粗暴中断,TCP 层可能来不及发 FIN 包,客户端收不到标准关闭帧,会卡在 CLOSING 状态甚至重连风暴。
WebSocket 8.18.2 是该协议规范的一个重要迭代版本,主要优化了连接稳定性与数据传输效率。它通过全双工通信机制,允许客户端与服务器在单一长连接上实时交换数据,大幅降低传统 HTTP 轮询的开销。该版本增强了心跳保活、自动重连及二进制帧传输能力,适用于即时通讯、在线游戏及金融行情推送等低延迟场景,为开发者提供更可靠的实时网络交互基础。
正确流程是先发 CloseFrame(状态码 4000+ 自定义原因),等对方回 ACK,或等待最多 5 秒后强制断开。uWebSockets 和 Netty 都支持 writeAndFlush(new CloseWebSocketFrame()) + channel.closeFuture() 监听。
- Spring WebSocket 的
session.close(CloseStatus.SERVICE_RESTART)会发标准帧,但若底层容器(如 Tomcat)禁用了 WebSocket 关闭帧,则退化为直接断连 - 某些云 WAF(如 Cloudflare)会拦截非标准 CloseFrame,建议服务端统一用
1001 (Going Away)或4000+自定义码,避免被中间件吞掉 - 强踢前最好记录日志:
force-close: conn=abc123, idle=128s, threshold=90s,方便后续分析误踢是否因网络抖动引起
分布式环境下怎么同步“该踢谁”
单机清理只能管本机连接。如果用户连接落在 Server B,但 Server A 收到踢人指令(比如账号异地登录),就得跨节点通知。Redis Pub/Sub 是最轻量方案,但要注意延迟和丢包。
更可靠的做法是:踢人指令写入 Redis Hash(kick_queue:{userId}),字段为 server_id 和 timestamp;所有节点每秒检查自己是否匹配 server_id,再查本地是否有对应 userId 的活跃连接。
- 不要依赖 Pub/Sub 的实时性——它不保证送达,适合做“尽力通知”,真正清理逻辑必须走可查可重试的状态存储
- Redis key 过期时间应设为 30 秒(略大于扫描周期),防止指令堆积;同时用
SETNX避免重复踢同一用户 - 如果用 Kafka,需确保每个节点消费自己 server_id 对应的 partition,否则无法精准定位连接所在节点
真正难的不是“怎么踢”,而是“踢完之后客户端重连时,怎么不让新旧连接并存”。这要求清理动作和会话状态(如 token 绑定、房间成员列表)必须原子更新——多数团队在这里漏掉 Redis 的 EVAL 脚本或事务校验,结果踢了连接,但用户还在房间列表里,造成状态不一致。










