Netty 做 WebSocket 长连接,最容易被忽视的其实不是握手、不是编解码,而是连接建立之后的那套“存活体系”。我最初做网页版在线聊天模块时,以为把 WebSocketServerProtocolHandler 接上、消息能收发就完事了,结果线上跑了一段才发现,人一多、连接一杂,各种半开连接、空闲占线、假死重连乱七八糟的问题全冒出来。这篇文章就围绕“Netty 高级应用”这条线,把我做 WebSocket 长连接、网络聊天、读写空闲检测和心跳机制的全过程、踩坑和最终方案完整梳理一遍。
从架构选型到管道设计,从聊天室消息模型到空闲检测参数,再到最终的线上问题排查,我都会按照实际开发顺序来写。适合已经会 Netty 基本用法的读者,手里正打算做长连接服务、聊天服务、推送服务或者物联网网关的,这篇文章可以直接拿来参考。
1. 整体架构与方案选型:为什么长连接服务必须用 Netty
1.1 选型思考:原生 WebSocket 容器 vs Netty
很多框架都内置了 WebSocket 支持,比如基于 Servlet 容器的那套,还有 Spring 的 WebSocket 模块。一开始我也试过用现成框架,开发速度确实快,但遇到三个问题之后,我果断切回 Netty。
第一,连接生命周期控制不够细。框架帮你把连接封装好了,但像全连接数统计、单用户连接管理、按用户精准踢下线、连接级别的流量控制,做起来都很别扭,经常要反射拿底层 Session。
第二,编解码链路的定制能力不够。聊天系统里除了文本消息,还会有二进制帧、Ping/Pong 帧、压缩帧。框架自带的编码器能处理常规场景,但遇到自定义帧类型、混合帧分发、细粒度异常处理时,可扩展性就不够灵活了。
第三,也是最重要的,空闲检测和心跳机制必须是应用层的自主逻辑。框架虽然有握手超时、会话超时之类的配置,但到“多久没读到客户端的包就判定死亡”“死亡后清理哪些本地缓存”“要不要自动发送 Ping 探测”这种业务级控制,框架给不了,全得自己写。
Netty 的 ChannelPipeline 机制在这些场景下就非常顺手,每个 Handler 只做一件事,我可以像搭积木一样把 HTTP 编解码、WebSocket 升级、空闲检测、心跳处理、业务消息分发独立编排,随时调整顺序而不影响其他模块。所以我的结论是:只要你的长连接服务存在自定义业务协议、复杂状态管理或高并发连接需求,就直接绕开容器自带的 WebSocket 方案,用 Netty 从底层掌控一切。
1.2 连接生命周期:HTTP 握手如何升级为 WebSocket 长连接
理解 Netty 里 WebSocket 的运作方式,核心是搞清楚一次连接从建立到数据交换经历了什么。长连接不是说 TCP 连上就完了,在 WebSocket 协议里,连接要先以 HTTP 请求的形式进来,服务端处理完升级握手后,这条连接才变成真正的 WebSocket 长连接。
简单顺序是这样的:
- 客户端发起 HTTP 请求,请求头里带上
Upgrade: websocket和Sec-WebSocket-Key。 - 服务端接收到 HTTP 请求后,校验 Upgrade 头和 Key,计算 Sec-WebSocket-Accept,返回 101 状态码。
- 握手成功后,这条 TCP 连接上双方开始以 WebSocket Frame 格式交换数据。
- 连接保持,直到任意一端发送 Close Frame 或底层 TCP 断开。
在 Netty 里,握手这个动作不是我们手动写的,而是 WebSocketServerProtocolHandler 帮我们处理的。它会自动判断是否为 WebSocket 升级请求,处理协议升级和握手响应。我只需要保证,Pipeline 里排列在它前面的 Handler 能正确解析 HTTP 请求。
这个设计有一个容易踩的坑:握手之后,数据帧里走的不再是 HTTP 协议了,所以 HttpServerCodec 不能继续参与数据处理。Netty 的设计方式是握手成功后自动移除 HTTP 编解码器相关的处理逻辑,WebSocketServerProtocolHandler 内部已经处理了这件事。我们要做的,就是不要在自定义 Handler 里强制把每一条消息都按 HTTP 请求去解析,否则长连接阶段会收到奇怪的数据。
1.3 模块划分:长连接服务要拆成哪几块
我把整个长连接模块拆成了四层。第一层是协议层,负责 HTTP 编解码、WebSocket 帧编解码、协议升级;第二层是检测层,负责空闲检测和心跳事件响应;第三层是业务层,负责在线用户管理、消息广播、私聊转发;第四层是状态层,负责把不活跃的连接从在线表里清理掉,统计掉线原因。
这种分层在之前用 Servlet 容器做项目时很难做到,因为框架的过滤器链虽然是链式的,但对连接的底层细节介入能力有限。Netty 里每一层对应一个 ChannelHandler,各自职责独立,中间通过事件传递状态。这样处理的好处很明显:我的空闲检测逻辑只关心最后一个数据包是什么时候读到的,不用关心消息内容是什么;我的业务逻辑只关心消息怎么转发,不用关心这个用户是否已经掉线。
2. 通道管道设计与 WebSocket 帧处理逻辑
2.1 依赖引入:最小的 Netty 环境
我用的是 Maven 构建,Netty 引入单个 netty-all 包就足够。版本上我建议不要用太老也不要用太新的,选一个稳定发布版本即可。我用的版本是 4.1.x 系列,这个系列是目前生产环境的主流,API 稳定,资料也多。
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.100.Final</version> </dependency>如果你的项目只需要用到部分模块,也可以拆开引入 netty-codec-http、netty-handler、netty-transport 等子模块,但工程实践中用 netty-all 最省事,不会因为遗漏某个子模块导致运行期 NoClassDefFoundError。
2.2 ChannelPipeline 的 Handler 编排顺序
这不是随便排的,每个 Handler 都有它存在的理由和位置。我在服务端初始化时,ChannelPipeline 是这么编排的:
ChannelPipeline pipeline = ch.pipeline(); // 1. HTTP 编解码器,负责解析 HTTP 请求/响应 pipeline.addLast(new HttpServerCodec()); // 2. 聚合 HTTP 请求,把 HttpMessage 和 LastHttpContent 合并成 FullHttpRequest pipeline.addLast(new HttpObjectAggregator(65536)); // 3. WebSocket 协议处理器,负责握手、帧编解码、Close 帧处理 pipeline.addLast(new WebSocketServerProtocolHandler("/ws", null, true, 1048576)); // 4. 空闲检测处理器 pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS)); // 5. 自定义处理器,负责握手后的事件通知和业务消息处理 pipeline.addLast(new WebSocketFrameHandler());逐个解释一下为什么是这么排的。
HttpServerCodec 必须放在最前面,因为客户端第一个包一定是 HTTP 升级请求,我们需要先把字节流解析成 HTTP 对象。HttpObjectAggregator 的作用是处理 HTTP 消息的聚合。WebSocket 握手请求虽然通常不大,但有些客户端实现可能把握手请求分成了多个 HTTP Chunk,如果不聚合,WebSocketServerProtocolHandler 会拿不到完整的握手请求。
WebSocketServerProtocolHandler 放在第三位,它会检测握手请求、处理协议升级、分配 WebSocket 帧类型。之后所有的数据到达这个 Handler 时,都已经从原始字节流变成了 WebSocketFrame 对象。
IdleStateHandler 的位置我放在 WebSocket 协议处理之后。为什么要放在这里而不是最开始?因为空闲检测需要感知的是“应用层数据”,也就是 WebSocket 帧,而不是底层 TCP 的原始字节。如果放在最前面,那么 TCP 层一个 ACK 包或者底层的一些网络控制字符就可能刷新读时间戳,导致心跳检测失真。放在 WebSocket 处理器后面,意思是只有真正的 WebSocket 数据帧到达才算是活跃状态。
自定义的 WebSocketFrameHandler 放在最后,它是业务处理链的终点,前面所有的管道处理结束之后,业务消息才送到这里。还有一点要注意,WebSocketServerProtocolHandler 在握手成功之后会在 ChannelPipeline 里触发一个自定义事件,类型是 WebSocketServerProtocolHandler.HandshakeComplete,业务 Handler 可以通过捕获这个事件来感知新连接已经具备长连接能力。
2.3 WebSocket 帧类型与消息分发策略
WebSocket 协议的帧类型有几种,在 Netty 里对应不同的类。我这个聊天服务主要用的是 TextWebSocketFrame,也就是文本帧,业务消息以 JSON 字符串形式承载。除了文本帧,还有 BinaryWebSocketFrame,二进制帧,适合传文件块或二进制协议;PingWebSocketFrame 和 PongWebSocketFrame,控制帧,专门用于连接保活探测;CloseWebSocketFrame,关闭帧,用于正常关闭连接。
业务 Handler 里的分发逻辑我写成了这样:
@Override protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception { if (frame instanceof TextWebSocketFrame) { String text = ((TextWebSocketFrame) frame).text(); handleChatMessage(ctx, text); } else if (frame instanceof PingWebSocketFrame) { ctx.writeAndFlush(new PongWebSocketFrame(frame.content().retain())); } else if (frame instanceof CloseWebSocketFrame) { // 客户端主动关闭 removeUser(ctx.channel()); ctx.close(); } else { // 其他帧类型按需处理 super.channelRead0(ctx, frame); } }这里有一个我第一次做容易忽略的坑:收到 Ping 帧时,要回复 Pong 帧,而且要注意帧内容的引用计数问题。如果直接把 Ping 帧的内容转发给 Pong,需要先 retain 一次,或者复制内容,否则原来的 Ping 帧被释放之后,Pong 的内容也就失效了。
2.4 握手完成与连接断开的事件处理
WebSocketServerProtocolHandler 有一个特性,握手完成后会在 Pipeline 中传播 HandshakeComplete 事件。这个事件对我们做用户状态管理很重要。用户什么时候算真正上线?不是 TCP 连接建立的时候,也不是 HTTP 请求进来的时候,而是握手完成之后。我在这个事件里注册用户的 Channel 和用户信息。
@Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) { // 握手完成,连接已经升级为 WebSocket // 在这里可以获取到请求路径、QueryString 里的 userId 参数等 QueryStringDecoder decoder = new QueryStringDecoder( ((WebSocketServerProtocolHandler.HandshakeComplete) evt).requestUri()); String userId = decoder.parameters().get("userId").get(0); UserManager.getInstance().addUser(userId, ctx.channel()); } else { super.userEventTriggered(ctx, evt); } }通过 requestUri 携带用户身份信息,要比在握手后第一条业务消息里再传 userId 靠谱得多。因为第一条消息可能因为网络问题延迟到达,而 HandshakeComplete 事件是和握手过程同步触发的,时机最准确。
连接断开时,需要做资源清理。用户掉线以后,在线表里如果还留着这个用户的 Channel,后续私聊消息就会写入一个已经失效的管道,产生异常。我在 channelInactive 里做的第一件事就是把当前 Channel 从全局管理器中移除。
3. 网络聊天消息系统:在线状态管理、群聊广播与私聊实现
3.1 全局连接管理器:ChannelGroup 与用户映射表怎么搭配
聊天室系统最基础的需求是:知道当前谁在线,并且能定位到某个用户对应的 Channel。Netty 提供了一个现成的 ChannelGroup,它可以批量地向所有连接发送消息,还自带了全局搜索能力,但实际业务场景里,我并没有只用 ChannelGroup,它是做“发广播”用的,而“找某个用户”还需要一个用户维度的映射表。
我最终用了组合方案:ChannelGroup 负责管理所有活跃连接,用来做全局广播;ConcurrentHashMap 负责维护 userId 到 Channel 的映射,用来做私聊和定向推送。
public class UserManager { private static final UserManager INSTANCE = new UserManager(); private final ChannelGroup allChannels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); private final ConcurrentHashMap<String, Channel> userChannelMap = new ConcurrentHashMap<>(); public static UserManager getInstance() { return INSTANCE; } public void addUser(String userId, Channel channel) { // 如果同一个用户重复登录,踢掉旧连接 Channel oldChannel = userChannelMap.put(userId, channel); if (oldChannel != null && oldChannel != channel) { oldChannel.close(); } allChannels.add(channel); } public void removeUser(Channel channel) { userChannelMap.entrySet().removeIf(entry -> entry.getValue() == channel); allChannels.remove(channel); } public Channel getChannel(String userId) { return userChannelMap.get(userId); } public void broadcast(Object msg) { allChannels.writeAndFlush(msg); } }addUser 方法里有一个很重要的处理:同一个 userId 重复连接时,要把旧连接踢掉。这个场景很常见,用户开两个标签页连接、或者断线之后又重连,旧连接还没有经过空闲检测被清理,如果不处理,就会出现私聊消息随机发给两个连接中的一个,用户感知到自己收消息时有时有有时没有。踢旧连接的策略,保证了同一时刻一个用户只有一个有效连接,逻辑非常清晰。
ChannelGroup 不建议自己手动 new,Netty 已经封装了 DefaultChannelGroup,并且绑定了一个 GlobalEventExecutor 来统一调度。它的 writeAndFlush 是异步批量通知的,所以广播群聊消息时,一条消息并不会因为某个连接慢就阻塞其他连接。
3.2 消息协议设计:JSON 里要包含哪些字段
聊天消息的协议我设计得比较轻量,没有引入太重的东西,就是 JSON 字符串。结构分成几个必要的字段:type 表示消息类型,用户上线通知、聊天消息、心跳消息、系统通知都通过 type 区分;from 表示发送者 id;to 表示接收者 id,群聊时填 groupId 或者空;content 表示消息内容;timestamp 表示消息时间戳。
message = { "type": "CHAT" | "PRIVATE" | "SYSTEM" | "HEARTBEAT", "from": "发送者id", "to": "接收者id 或 null", "content": "消息文本", "timestamp": 1700000000000 }代码层面解析 JSON 我直接用了主流的序列化库,在 Netty 的 handler 里把它解析成 Map 或者一个 ChatMessage POJO。这里有个经验:不要在 Pipeline 里做 json 字节和 POJO 的双向转换时硬编码到业务代码里,最好单独抽取一个 MessageCodec,让编解码和业务分发解耦。
type 字段的语义我解释一下:CHAT 表示群聊广播,服务端收到后转发给所有人;PRIVATE 表示私聊,服务端根据 to 去 userChannelMap 里查目标 Channel 并发送;SYSTEM 是服务端主动下发的系统通知;HEARTBEAT 是客户端发送的心跳消息,服务端只刷新活跃时间,不往业务层转发。
3.3 群聊广播与私聊发送的核心代码
先看群聊。客户端发来 CHAT 类型的消息,服务端的处理逻辑就是把这条消息再次打包成 TextWebSocketFrame 广播出去。
private void handleChatMessage(ChannelHandlerContext ctx, String text) { ChatMessage message = parseMessage(text); if (message == null || !"CHAT".equals(message.getType())) { return; } // 过滤掉心跳消息,不让心跳参与广播 if ("HEARTBEAT".equals(message.getType())) { return; } // 这里可以选择把 from 字段强制替换成当前连接对应的 userId,防止伪造消息 UserManager.getInstance().broadcast(new TextWebSocketFrame(text)); }这里我要求自己必须重视一件事:消息内容里的 from 字段不能盲信。因为服务端并不知道发送者的真实 id 是不是消息里写的那样,如果直接把客户端传的 from 广播出去,用户可以伪装成任意人发言。我实际项目里就是从连接和用户映射关系里拿到当前 Channel 对应的 userId,然后覆盖消息里的 from 字段:
String actualUserId = getUserIdByChannel(ctx.channel()); message.setFrom(actualUserId);私聊更简单,去映射表里查目标 channel,然后发送即可。
private void handlePrivateMessage(ChannelHandlerContext ctx, ChatMessage message) { if (!"PRIVATE".equals(message.getType())) { return; } Channel targetChannel = UserManager.getInstance().getChannel(message.getTo()); if (targetChannel != null && targetChannel.isActive()) { targetChannel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(message))); } else { // 目标不在线,返回一个系统提示给发送者 ChatMessage reply = new ChatMessage(); reply.setType("SYSTEM"); reply.setContent("对方不在线或连接已断开"); ctx.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(reply))); } }3.4 心跳消息和业务消息的隔离
因为心跳消息和业务消息走的是同一个 WebSocket 帧通道,所以我在业务 Handler 里第一步就会判断 type 是不是 HEARTBEAT,是的话直接返回,不进入广播逻辑。这样做的原因很直接:心跳消息的目的是保活,不包含任何业务语义,如果它也进入群聊广播,聊天界面就会不断刷出无意义的系统消息,用户端体验极差。
我见过一些项目把心跳直接做成 Ping 帧,而不是业务层 JSON 消息。两者都能达到保活目的,但差别在于:Ping 帧对业务层完全透明,服务端不需要解析内容;而 JSON 心跳消息可以携带客户端时间戳、客户端状态等附加信息,便于追踪网络延时。我用的是 JSON 心跳,因为服务端可以把收到的时间戳回传,客户端能计算一次 RTT,用于后续的网络质量统计。
4. 读写空闲检测与心跳机制:应用层保活的核心实现
4.1 为什么一定要做应用层心跳:TCP Keep-Alive 到底靠不靠谱
网上很多教程说 TCP 自带 Keep-Alive 机制,不需要应用层自己操心。这句话在局域网小规模测试时可能没错,但到真实公网环境下,完全依赖 TCP Keep-Alive 是非常危险的。TCP Keep-Alive 的默认探测周期很长,一个空闲连接可能要等数小时才被系统探测到,而且它只能感知 TCP 层的存活,如果对端应用已经死锁但系统没有崩溃,TCP 层探测依然可能正常。
还有一个典型场景:用户手机从 WiFi 切到 4G 网络时,IP 地址变了,原连接对客户端来说是“消失”的,但服务端 TCP 层并不知道,这条连接会一直挂着。如果不靠应用层空闲检测去清理,这类半开连接会不断累积,最终把服务端的可用连接数耗尽。
所以,应用层心跳的意义不只是保活,更是服务端主动发现并清理死连接的依据。没有心跳机制的长连接服务,在用户量大的时候,连接数会持续上涨,永远不会回到正常水位。
4.2 IdleStateHandler 触发原理与三大状态
Netty 提供的 IdleStateHandler 是一个功能非常聚焦的 Handler,它的职责就是监听连接的空闲状态,然后触发对应事件。初始化时传三个参数:
- readerIdleTimeSeconds:读空闲时间,超过该时间没有读到任何数据就触发 READER_IDLE 事件。
- writerIdleTimeSeconds:写空闲时间,超过该时间没有发送任何数据就触发 WRITER_IDLE 事件。
- allIdleTimeSeconds:读写全空闲时间,超过该时间既没有读也没有写就触发 ALL_IDLE 事件。
我服务端传入的参数是new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS),意思是只检测读空闲,60 秒内没有读到任何数据就触发事件。这里解释一下为什么只检测读空闲。
对服务端来说,判断客户端是否活跃的核心是“有没有收到对方的数据”。如果客户端还在线,它会定期发送心跳包,服务端的读事件就会刷新;如果客户端已经死掉或者网络断开,服务端在 60 秒内读不到任何字节,说明这个连接已经不健康了。写空闲对服务端意义不大,因为服务端没有消息时本来就允许不发送数据,不能因为服务端没写就断定连接坏了。
底层实现上,IdleStateHandler 的触发机制是向 EventLoop 提交一个定时任务,每次执行时检查从最后一次读或写到现在的时间差。当时间差超过阈值时,就会触发 IdleStateEvent 事件,同时将这次触发的时间作为新的基准点,重新计算下一次空闲时间。这保证了触发事件不会在短时间内反复执行多次,而是每隔一个周期触发一次。
4.3 服务端空闲事件处理逻辑:三步走
在自定义 Handler 里重写 userEventTriggered 方法,专门处理 IdleStateEvent。我的处理逻辑三步走。
第一步,判断事件类型,只有 READER_IDLE 才处理;第二步,统计当前连接的空闲超时次数;第三步,连续空闲达到阈值就判定连接死亡,关闭连接并清理资源。
这里要特别说一个参数经验:空闲检测 60 秒只是第一层判定,如果要提高容错率,不要一触发 READER_IDLE 就立即关闭连接。因为网络抖动、CPU 尖刺、GC 停顿都可能导致客户端心跳包晚到几秒钟。如果一超时立即杀连接,会出现大量误杀。
我的做法是给一个缓冲窗口,比如连续 3 次 READER_IDLE 都没收到数据,才真正关闭连接。这意味着实际判定死亡的时间约为 60 秒 × 3 = 180 秒。这个值对客户端 30 秒一个心跳包来说,余量是足够的。
private int idleCount = 0; @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent idleEvent = (IdleStateEvent) evt; if (idleEvent.state() == IdleState.READER_IDLE) { idleCount++; if (idleCount >= 3) { // 连续多次没有收到客户端心跳 UserManager.getInstance().removeUser(ctx.channel()); ctx.close(); } else { // 还可以尝试发送一个 Ping 帧,主动探测客户端 ctx.writeAndFlush(new PingWebSocketFrame()); } } } else { super.userEventTriggered(ctx, evt); } }4.4 客户端心跳定时发送:用时间轮还是直接 ScheduledExecutor
客户端的心跳发送有两种常见实现:低频固定周期,高频固定周期带退避。我建议固定周期 20 到 30 秒,不要太短。心跳太频繁有两个问题:一是无谓地消耗服务端资源,同一个用户每秒发一个心跳包,服务端要处理大量无业务价值的帧;二是一次网络波动期间,如果客户端不断重发心跳,服务端连续收到冗余数据,空闲检测的计数会被不断重置,反而掩盖了真实断线。
客户端的发送我封装成了一个心跳调度器,用 Netty 的 EventLoop 自带能力执行周期性任务,不额外引入时间轮。代码大致如下:
ctx.channel().eventLoop().scheduleWithFixedDelay(() -> { if (ctx.channel().isActive()) { ChatMessage heartbeat = new ChatMessage(); heartbeat.setType("HEARTBEAT"); heartbeat.setTimestamp(System.currentTimeMillis()); ctx.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(heartbeat))); } }, 10, 30, TimeUnit.SECONDS);这里有几个容易踩的坑。第一个坑:如果直接用 EventLoop 的 scheduleWithFixedDelay 启动任务,任务会被绑定到当前 Channel 所在的 EventLoop 线程上,任务逻辑不能做阻塞操作,否则会卡住该 EventLoop 上的所有其他 Channel。我目前只是写数据,不阻塞,所以没问题。
第二个坑:判断 channel.isActive() 是必须的。连接关闭后,EventLoop 上挂着的定时任务不会自动取消,它会继续尝试往一个已经关闭的 Channel 里写数据。如果 isActive() 不检查,过段时间会看到一堆 ClosedChannelException 日志。
第三个坑:scheduleWithFixedDelay 是固定间隔,上一次任务执行完成后再等 30 秒执行下一次。如果一次发送因为网络阻塞耗时很长,心跳周期会自动变长,这个比 scheduledAtFixedRate 更安全,不会造成任务堆积。
4.5 心跳参数配置的实战经验表
我把整理好的参数放在这里,按照我的生产环境经验给出推荐值,你根据自己的业务情况调整。
| 参数项 | 推荐值 | 说明 |
|---|---|---|
| 客户端心跳发送间隔 | 20-30秒 | 太短浪费资源,太长服务端判定延迟高 |
| 服务端读空闲检测时间 | 30-60秒 | 至少是客户端心跳间隔的1.5倍 |
| 连续空闲次数阈值 | 3次 | 给网络抖动留出缓冲 |
| 实际死亡判定时间 | 90-180秒 | 心跳间隔乘以连续次数,可容忍普通抖包 |
| 主动 Ping 探测 | 空闲1次后发送 | 二次确认,避免误杀 |
| 服务端写空闲检测 | 这些场景用不上 | 服务端并不需要持续写心跳 |
这个表是怎么定的?核心逻辑是服务端的判定时间必须大于客户端心跳周期的两倍以上。心跳间隔 30 秒,服务端读空闲设 60 秒,那就是至少漏了两到三个心跳包才触发,再加上 3 次缓冲,时间窗口就非常宽裕。
4.6 客户端收到 Pong 帧和心跳回执时的处理
客户端和服务端配合的细节,我用一个真实调试经历说明。当时客户端发心跳包,服务端一直正常接收,但客户端却频繁重连。排查后发现,客户端没有处理服务端的 Pong 帧,导致服务端认为客户端还在活跃,而客户端因为收不到预期中的消息回执就主动重连了。
后来我在客户端的 WebSocket onMessage 回调里加了对 Ping/Pong 帧的判断:收到 Pong 帧就刷新本地的“最后服务端响应时间”;客户端每次发心跳后启动一个超时定时器,如果 15 秒内没有收到任何服务端数据,再增加一个重连退避策略。
这种事特别容易出现在两端开发由不同人负责的项目里,约定好心跳交互协议很重要。
5. 长连接系统中常见的坑与排查实录
5.1 握手成功了但业务消息一直收不到
遇到过一次比较诡异的现象:客户端显示连接已建立,服务端也能看到连接进来,但服务端就是收不到业务消息。我抓包看请求响应都是正常的,后来加日志才发现问题出在数据到达的 Handler 顺序上。
我在 Pipeline 里放了两个自定义 Handler,一个负责处理心跳,一个负责处理业务消息。心跳处理器继承的是 SimpleChannelInboundHandler,而业务处理器继承的是 ChannelInboundHandlerAdapter。SimpleChannelInboundHandler 有一个特性:处理完消息后会自动释放引用计数,如果业务消息先把消息处理掉又传给下一个 Handler,下一个 Handler 读到的 Frame 内容已经被释放了,读取就会得到空数据。
解决方案是在心跳处理器里不要调super.channelRead0(ctx, msg),让消息只停在业务处理器那里;或者在业务处理器里对传入的消息做一次retain(),确保内容读取不受到引用计数释放影响。我从这个坑里总结的规范是:自定义 Handler 的继承类型要保持一致,消息传递链路上只在一个点做“消费”。
5.2 内存吃满:连接数涨上来之后 OOM
长连接服务和普通 HTTP 服务的内存模型完全不同。HTTP 服务是请求进来处理完就释放内存,长连接是每个连接都保持着一套缓冲区。我在压测 2 万个并发连接的时候发现内存占用急剧上升,最后 OOM。
排查定位到两个原因。第一个是 Netty 默认的 write buffer 水位线是按每连接设置的,连接多了以后积少成多。我通过ChannelOption.WRITE_BUFFER_WATER_MARK调整了低水位和高水位,把默认的 32KB/64KB 调低到 16KB/32KB,同时让业务层在消息量过大时主动做丢弃或降级。
第二个原因是一个死循环 Bug:某个用户掉线后,我没有清理 userChannelMap 里的过期映射,私聊消息不断往这个已经关闭的 Channel 发送,Netty 的写缓冲不断堆积未发送的数据,最终吃满内存。这个问题的根因就是连接清理不及时,所以心跳检测在这里不仅保活,也是内存保护机制。
5.3 客户端断网重连后消息重复推送
在线用户列表里出现了同一个人存在多个连接的情况,群聊消息被重复发送。原因是用户从 WiFi 切到移动网络时,旧连接没有被服务端及时检测到死亡,客户端又建立了新连接,userChannelMap 里旧连接占着位置,新连接也进来了。
我解决的办法在上面 addUser 里已经提到了:同一个 userId 进来直接踢旧连接。但这带来一个新的问题:如果用户只是短暂网络切换,立刻又重连,踢掉旧连接之后,服务端向旧连接发送的断线通知就发不出去了。这里我做了个折中,踢旧连接之前不再等待,直接 close,新连接完全接管该用户的所有状态。
5.4 心跳风暴:客户端一直重连,服务端被打垮
有一次我调小了空闲检测时间参数,从 60 秒改成 20 秒,结果客户端数量一多,服务端开始大面积关闭连接,客户端检测到断开后立刻重连,重连成功后不一会儿又被判死,形成了心跳风暴。服务端处理连接建立和关闭的开销急剧上升,CPU 打满。
问题在于我这里只调了服务端的空闲检测阈值,没有同步调客户端的重连策略。客户端重连太激进,每次断开后立即重新连接,没有退避机制。解决方式是在客户端加入指数退避重连策略,比如第一次断开后等待 1 秒、第二次 2 秒、第三次 4 秒,最多延迟 30 秒。服务端这边把空闲检测调回合理范围,客户端和服务端的参数能够形成协调的节奏。
5.5 空闲检测误杀高 CPU 场景下的慢客户端
有一次业务方反馈:某些用户偶尔掉线,时间都集中在固定时段。排查发现这个时间段系统有定时任务在做重计算,EventLoop 线程被长时间占用,导致客户端的心跳包没有被及时处理,空闲检测器误以为超时。
这个坑本质上是 Netty 线程模型被阻塞了。解决方案有两个层面:一是把耗时计算任务移出 EventLoop 线程池,扔到业务侧的独立线程池去执行,不阻塞网络线程;二是把定时任务执行时间安排在业务低峰期,避开用户活跃时段。这让我意识到,长连接服务和后端计算任务部署在同一个 JVM 里时要特别小心,EventLoop 线程是千万不能做阻塞操作的。
6. 关于 Netty 长连接服务,我还想多说几句
做 WebSocket 长连接服务,表面上是 Netty 管道的连接与消息收发,实际上是把连接生命周期、系统资源、用户状态、网络异常这些要素全部纳入一个自动化的管理闭环。连接来了怎么注册,数据来了怎么分发,空闲多久算死,死了怎么清理,清理怎么通知业务层,每一个环节都要想清楚。
我个人在实际开发中体会最深的一点是,心跳机制和空闲检测参数的设置永远不是一次性固定的。上线初期可以按推荐值配置,但运行一段时间后一定要根据真实用户活跃曲线、网络环境、服务器负载来做回调。比如我后来把客户端心跳间隔从 15 秒调到 30 秒,就是因为日志显示部分用户长时间停留的页面产生了大量无效心跳包,白白消耗带宽。
如果你现在正准备基于 Netty 做长连接相关的项目,最实在的建议是:先把连接管理器的踢重和清理写好,再把空闲检测参数预留成配置项,不要硬编码在代码里。这两件事做好了,后面加业务功能会顺畅很多。另一个小技巧是,自测时一定要同时测“正常断开”和“异常断开”两种场景,正常断开走 CloseWebSocketFrame,异常断开直接拔网线,你会发现服务端对这两种情况的处理路径是完全不同的,而这种差异才是长连接系统稳定性考验的真正开始。