网络通信:Netty WebSocket 服务端
一句话速览:Netty 用 Reactor 线程模型 + ChannelPipeline 责任链,让少量线程处理海量长连接;本项目 IM 模块的所有坑都指向同一个认知——Pipeline 上”放行”不等于”能处理”,不匹配预期的事件会在责任链上被无声吞掉。
涉及原始记录:
07-面试问题/开发问题/02-框架与中间件集成问题.mdP0-04;07-面试问题/开发问题/01-依赖与配置问题.mdP0-04、P1-05、P0-05;07-面试问题/验证问题/01-业务逻辑问题.mdP0-04、P1-04最近修订:2026-08-07
目录
- 技术点 0:Netty 底层原理 —— Reactor 线程模型与零拷贝
- 技术点 1:Netty Pipeline —— 请求处理的流水线模型
- 技术点 2:会话管理 —— 单一数据源原则
- 技术点 3:WebSocket 鉴权 —— 网关模式和直连模式的区别
- 技术点 4:TCP 粘包拆包与心跳 —— 长连接的必修课
- 术语表
技术点 0:Netty 底层原理 —— Reactor 线程模型与零拷贝
难度:⭐⭐⭐ | 掌握要求:能画出 boss/worker 模型,说清 EventLoop 与 Channel 的绑定关系
这部分没有对应踩坑记录,但它是理解 Pipeline 行为、以及”为什么业务 Handler 里不能做阻塞操作”的地基。
Reactor 线程模型:少量线程扛住海量连接
传统 BIO 是”一个连接一个线程”,一万个长连接就要一万个线程,线程栈内存和上下文切换直接把机器拖垮。Netty 基于 NIO(IO 多路复用)实现 Reactor 模型:一个线程通过 Selector 监听成千上万个 Channel 的事件,事件到达才处理,没有事件的连接不占任何线程资源。
flowchart TD
C1[客户端连接] & C2[客户端连接] & C3[客户端连接] --> B[Boss EventLoopGroup
通常1个线程]
B -->|只负责 accept 新连接| R[把新 Channel 注册到
Worker 组的某个 EventLoop]
R --> W1[Worker EventLoop 1
负责 N 个 Channel 的读写]
R --> W2[Worker EventLoop 2
负责 M 个 Channel 的读写]
W1 --> P[ChannelPipeline
依次调用 Handler 链]
W2 --> P
三个必须记住的要点:
- Boss/Worker 分工:
bossGroup只干一件事——accept 新连接;workerGroup负责所有已建立连接的读写。这就是为什么启动代码里总是new NioEventLoopGroup()两次。 - Channel 与 EventLoop 终身绑定:一个 Channel 从注册到销毁,所有 IO 事件都由同一个 EventLoop 线程处理。这意味着同一个连接上的所有 Handler 回调天然是串行的,不需要加锁——这是 Netty 并发模型的精髓,也是
WebSocketSessionManager这类按 Channel 维度维护状态的代码可以写得比较简单的原因。 - 业务 Handler 里绝对不能做阻塞操作(查 DB、调 RPC、sleep):你阻塞的不是”这个连接”,而是这个 EventLoop 负责的几百上千个连接。耗时的业务逻辑必须扔到自己的业务线程池里异步执行,这是 Netty 应用性能的第一铁律。
ByteBuf 与零拷贝
- ByteBuf:Netty 自己实现的字节缓冲区,对比 JDK
ByteBuffer的优势是读写双指针(不用flip())、支持池化复用(PooledByteBufAllocator,避免频繁分配/回收大数组造成的 GC 压力)、支持引用计数(retain()/release())。 - 引用计数是一把双刃剑:ByteBuf 用引用计数管理内存,Handler 里拿到消息如果不再往下传,必须手动
release(),否则内存泄漏;反过来release()后再访问就是非法操作。前面 Pipeline 部分提到的”消息被无声丢弃”,底层往往就伴随着一次自动 release。 - 零拷贝:Netty 的
CompositeByteBuf(逻辑合并多个 buffer 不复制数据)、FileRegion(transferTo直接在内核态把文件发到 socket,不经过用户态)都是”零拷贝”思想的体现——零拷贝不是没有拷贝,而是避免”内核态↔用户态”之间的冗余拷贝。
技术点 1:Netty Pipeline —— 请求处理的流水线模型
难度:⭐⭐⭐ | 掌握要求:能默写本项目 Pipeline 编排顺序,说清”放行不等于处理”
核心概念
Netty 的核心抽象是 ChannelPipeline:一个连接上的每一次数据读写,都会依次经过 Pipeline 里注册的一串 Handler,前一个 Handler 处理完的结果会传给下一个。本项目 IM 服务的 Pipeline 编排是:
1 | IdleStateHandler(心跳超时检测) |
理解这个顺序很重要:一个 Handler 如果不满足某个条件就把请求”放行”给下一个(调用 fireChannelRead),并不代表这个请求最终会被正确处理——如果后面所有 Handler 都基于”这应该是一个 WebSocket 帧”的假设写代码,一个不匹配预期格式的普通 HTTP 请求被放行过去,就会在管道尽头被无声地丢弃。
我们踩过的坑
P0-04:WebSocket 握手不识别带 query string 的路径,请求被静默丢弃
浏览器原生 WebSocket API 有一个限制:不支持自定义请求头,所以像 JWT token 这种鉴权信息,没法放在 Header 里传,只能拼在 URL 上(ws://host/ws?token=xxx)。
但 Netty 4.1.109 的 WebSocketServerProtocolHandshakeHandler.isWebSocketPath() 内部实现是简单的字符串全等比较:拿请求 URI 跟配置的 websocketPath("/ws") 直接 equals,完全不识别 query string。/ws?token=xxx 显然不等于 /ws,判断结果是 false。
这里最关键、也是最容易被忽略的一点:判断为”不是 WebSocket 路径”之后,Netty 不会报错,而是把这个请求当作一个普通的 HTTP 请求,通过 fireChannelRead 继续往 Pipeline 下游传。而 Pipeline 下游的业务 Handler 只写了处理 TextWebSocketFrame 的逻辑,收到一个 FullHttpRequest 类型对象时什么也不做(甚至可能因为类型不匹配被自动 release 掉)。最终结果是:服务端不返回 101 Switching Protocols,不抛任何异常,不打印任何日志,客户端只能感知到”握手超时”,服务端这边完全没有线索。
修复思路是在真正的握手 Handler 之前插入一个自定义 Handler,把 URI 里的 query string 剥离掉,让路径能匹配上:
1 | private static class QueryStrippingHandler extends ChannelInboundHandlerAdapter { |
握手完成后(userEventTriggered 收到 HandshakeComplete 事件),再从 Channel 属性里取出之前存的原始 URI,解析出 token 做验签。
必须记住的结论
- Netty 的 Handler 链是”责任链”模式,一个 Handler 判断不满足条件时的”放行”操作本身不会报错,但可能导致请求在后面某个环节被无声吞掉。排查”客户端超时但服务端毫无日志”这类问题时,思路要从”业务逻辑哪里错了”切换成”这个请求是不是压根没有走到我的业务 Handler”。
- 浏览器原生 WebSocket 不支持自定义 Header,鉴权信息只能通过 URL 传递,这是设计 WebSocket 鉴权方案时的硬约束,不是能绕开的选择。
- 用到 Netty 内置组件(
WebSocketServerProtocolHandler之类)时,不能假设它的实现覆盖了所有实际场景,遇到”官方组件行为不符合预期”,先去翻一下具体版本的源码实现(这里是isWebSocketPath()方法),不要凭直觉猜。 - 排查责任链类框架(Netty Pipeline、Sentinel Slot、Spring Filter)问题的通用工具是”这个请求/事件实际走到了链上的哪一环“,而不是”我的代码逻辑对不对”。
技术点 2:会话管理 —— 单一数据源原则
难度:⭐⭐ | 掌握要求:能说清”两套独立实现”为什么必然出问题
核心概念
一个 WebSocket 服务需要维护”用户 ID 和物理连接(Channel)之间的映射关系”,用来支持”给指定用户推送消息””判断用户是否在线””踢掉旧连接实现单端登录”等功能。这份映射关系应该有且只有一处地方维护,其他所有代码都通过它读写,不能各写各的。
我们踩过的坑
验证问题 P0-04:两套独立实现的路由/存储机制,导致功能实际不通
这是 IM 模块里影响最大的架构性问题。项目里其实分别在两个地方实现了”消息应该发给谁、怎么发”的逻辑:
ImServiceImpl(WebSocket 层):自己维护了一个 Redis Channel(im:message:channel)用来跨节点转发消息,自己维护了一个 Redis List(im:offline:msg:{userId})做离线消息队列。ImMessageService(REST 层,供历史消息查询等 HTTP 接口使用):基于 MongoDB 持久化消息 + 独立的im:user:{userId}Pub/Sub Channel 做跨节点路由。
这两套实现是完全独立开发出来的,从未打通:WebSocket 收到聊天消息后,只走了 ImServiceImpl 自己的 Redis 逻辑,从未调用 ImMessageService.sendMessage 写入 MongoDB。后果是:用 WebSocket 发的消息,通过 HTTP 接口查历史记录永远查不到——因为查询走的是 MongoDB,而消息根本没存进去。
更隐蔽的是:ImServiceImpl 发布消息用的 Channel 名(im:message:channel)和 ImMessageService 那边的监听器实际订阅的 Pattern(im:user:*)根本不匹配,跨节点消息路由其实完全断链,只是因为本地开发环境永远是单节点部署,消息优先走”本节点直投”这条捷径,才没有暴露出这个问题。
修复方式是废弃 ImServiceImpl 里那套独立实现,把 WebSocket 收到消息后的处理统一委托给 ImMessageService.sendMessage,由后者一个入口完成”存 MongoDB + Redis 未读计数 + 推送(本地直投或跨节点 Pub/Sub)”这三件事:
1 | // ImServiceImpl.handleMessage 的 CHAT 分支 |
必须记住的结论
- 同一份状态(这里是”用户在哪个节点、消息该怎么路由”),项目里只应该有一处实现,其他所有入口(无论是 WebSocket 还是 HTTP)都应该调用同一个 Service,而不是各自为政写一套相似但不共享的逻辑。两套独立实现看起来能各自跑通(WebSocket 能聊天、HTTP 接口能查询别的数据),但一旦数据需要在两者之间流转(WebSocket 发的消息要能被 HTTP 接口查到),”看起来都对”的两套代码合起来就是错的。
- 排查这类问题的标志性信号:某个功能”看起来实现了”,但另一个应该消费同一份数据的功能”完全查不到”——这通常意味着背后有两条本该合并但没有合并的数据流。
- 单节点部署会掩盖”跨节点路由是否真的打通”这类问题,因为本地直投这条捷径永远存在。验证跨节点能力,必须要么真的启动多个实例测试,要么仔细做代码走查确认发布方和订阅方的 Channel/Pattern 完全对齐,不能只靠”单机跑起来没问题”下结论。
技术点 3:WebSocket 鉴权 —— 网关模式和直连模式的区别
难度:⭐⭐ | 掌握要求:能说清为什么 IM 服务必须自己持有公钥
核心概念
本项目里 REST 接口的鉴权是”网关统一验签,业务服务信任 Header”模式(详见 09-认证鉴权与网关架构.md),但 WebSocket 走的是完全不同的路径:客户端直连 IM 服务的独立端口(9999),完全绕开 Gateway。这意味着 IM 服务必须自己具备完整的 JWT 验签能力,不能像其他服务一样只读 Header 里现成的用户信息。
我们踩过的坑
开发问题 P0-05:忘记给 IM 服务配 JWT 公钥
其他 REST 服务因为信任 Gateway 已经验证过 token,本身不需要持有 JWT 公钥。但这个假设被直接套用到了 IM 服务上——IM 服务的 application.yml 一开始没有配置 livin.jwt.public-key。握手时调用 JwtUtil.parseToken 验签,getPublicKey() 因为配置为空抛出 IllegalStateException,这个异常又被 authenticateUser 方法的 catch 块吞掉、统一返回 null,最终表现是无论 token 有效还是无效,认证都必然失败,日志只有一句语焉不详的”Token 认证失败”,看不出真正原因是”公钥没配置”还是”token 真的不对”。
修复很简单,把 auth 服务签发 token 用的 RSA 公钥同步配置到 IM 服务(只需要公钥验签,不需要私钥):
1 | livin: |
关联问题:MongoDB 连接串密码含 @ 符号解析失败(依赖与配置问题 P0-04)
跟 WebSocket 本身无关,但同样发生在 IM 服务的配置阶段,放在这里一起记:mongodb://root:livin@2026@127.0.0.1:27017/... 这种写法里,密码 livin@2026 本身含有 @,MongoDB 驱动按照 user:pass@host:port 的语法解析连接串时,会把第一个 @ 就当成用户信息和主机的分界,导致解析错乱。任何连接串里的密码,只要包含 @、:、/ 这类连接串本身的语法保留字符,都需要做 URL 编码(@ 编码成 %40)。这是一个很基础但容易被忘记的规则,凡是密码里出现特殊符号,配置连接串前先过一遍 URL encode。
关联问题:缺少 DataSource 自动配置排除(依赖与配置问题 P1-05)
IM 服务的消息数据全部存 MongoDB,完全不需要 MySQL,但因为传递依赖引入了 mybatis-plus/mysql-connector/druid,Spring Boot 会自动尝试装配 DataSource,而项目里根本没配 spring.datasource.url,直接启动失败。这类”服务本不需要某个技术栈,但因为传递依赖被强制拉了自动配置”的问题,通用解法是在 application.yml 里显式排除对应的自动配置类:
1 | spring: |
必须记住的结论
- 不是所有服务的鉴权方式都一样,要看这个服务的入口流量是否经过网关。经过网关的可以信任 Header,不经过网关的(独立端口直连)必须自己具备完整的鉴权能力(持有公钥、自己验签)。引入一个新的、走独立协议/端口的服务时,要重新审视一遍它的鉴权链路是不是能直接照抄其他服务的做法。
- 鉴权失败类的异常不应该被 catch 块笼统吞掉、返回一个模糊的失败结果。本项目这里的教训是”公钥没配”和”token 真的无效”在日志里表现完全一样,导致排查者要靠猜。写鉴权逻辑时,内部区分具体的失败原因、分别打日志,能大幅减少这类问题的排查时间。
- 连接串密码含特殊字符要做 URL 编码;引入了用不到的技术栈依赖,主动在自动配置里排除,不要等它自己报错才处理。
技术点 4:TCP 粘包拆包与心跳 —— 长连接的必修课
难度:⭐⭐ | 掌握要求:粘包成因、三种解码方案、IdleStateHandler 三个时间参数
本项目用的是 WebSocket(协议自带帧边界,应用层感知不到粘包),但任何基于原生 TCP 的长连接协议都必须处理这两个问题,是 Netty 面试的必考题:
粘包/拆包
TCP 是字节流协议,没有消息边界:发送方连发两条消息,接收方可能一次读到一条半(拆包)或两条粘在一起(粘包)——TCP 层的 MSS 分段、Nagle 算法合并小数据包都会造成这种现象。注意这不是”故障”,是 TCP 的正常行为,应用层协议必须自己定义消息边界:
| 方案 | 原理 | 适用 |
|---|---|---|
| 定长消息 | 每条消息固定字节数,不足补空 | 协议极简场景,浪费带宽 |
| 分隔符 | 消息结尾加特殊分隔符(如 \n) |
文本协议(Redis RESP 等) |
| 长度字段 | 消息头带 body 长度(LengthFieldBasedFrameDecoder) |
最通用,二进制协议首选 |
本项目选择 WebSocket 作为应用层协议,等于把这个问题交给了协议本身(WS 帧自带长度字段),这也是”选现成协议 vs 自定义协议”的一个隐性收益。
心跳与空闲检测
长连接”看起来还连着”不代表对端还活着(NAT 超时、网络静默断开都不会触发 TCP 的 FIN)。IdleStateHandler 的三个时间参数分别检测:读空闲(多久没收到对方数据)、写空闲(多久没向对方发数据)、读写空闲——触发后抛出 IdleStateEvent,业务 Handler 在 userEventTriggered 里决定是发心跳 ping 还是主动断开。本项目 Pipeline 第一个 Handler 就是它,典型配置是”读空闲超过 N 秒判定连接已死,关闭释放资源”;客户端则定时发 PING,服务端回 PONG(本项目协议里的 PONG 控制帧就是干这个的,不需要持久化,走本地处理即可)。
术语表
| 术语 | 含义 |
|---|---|
| Reactor 模型 | 基于 IO 多路复用的事件驱动模型,少量线程处理海量连接 |
| Boss / Worker | Netty 两个 EventLoopGroup:accept 新连接 / 处理已建立连接的读写 |
| EventLoop | 一个绑死单线程的事件循环,Channel 与其终身绑定,同连接事件天然串行 |
| ChannelPipeline | Handler 责任链,IO 事件沿链依次传递处理 |
| ByteBuf | Netty 的池化字节缓冲区,读写双指针 + 引用计数管理 |
| 零拷贝 | 避免内核态与用户态间冗余数据拷贝的技术总称 |
| 粘包/拆包 | TCP 字节流无消息边界导致的多条合并/一条拆碎现象,应用层须自定边界 |
| IdleStateHandler | Netty 空闲检测 Handler,按读/写空闲时长触发事件,心跳保活的基础 |
| WebSocket 握手 | HTTP 请求带 Upgrade: websocket 头,服务端回 101 完成协议升级 |
| QueryStrippingHandler | 本项目自定义 Handler,剥离握手 URI 的 query string 使路径可匹配 |




