一句话速览:Netty 用 Reactor 线程模型 + ChannelPipeline 责任链,让少量线程处理海量长连接;本项目 IM 模块的所有坑都指向同一个认知——Pipeline 上”放行”不等于”能处理”,不匹配预期的事件会在责任链上被无声吞掉

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P0-04;
07-面试问题/开发问题/01-依赖与配置问题.md P0-04、P1-05、P0-05;
07-面试问题/验证问题/01-业务逻辑问题.md P0-04、P1-04

最近修订:2026-08-07

目录


技术点 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 不复制数据)、FileRegiontransferTo 直接在内核态把文件发到 socket,不经过用户态)都是”零拷贝”思想的体现——零拷贝不是没有拷贝,而是避免”内核态↔用户态”之间的冗余拷贝

技术点 1:Netty Pipeline —— 请求处理的流水线模型

难度:⭐⭐⭐ | 掌握要求:能默写本项目 Pipeline 编排顺序,说清”放行不等于处理”

核心概念

Netty 的核心抽象是 ChannelPipeline:一个连接上的每一次数据读写,都会依次经过 Pipeline 里注册的一串 Handler,前一个 Handler 处理完的结果会传给下一个。本项目 IM 服务的 Pipeline 编排是:

1
2
3
4
5
6
7
IdleStateHandler(心跳超时检测)
→ HttpServerCodec(HTTP 编解码)
→ HttpObjectAggregator(把分片的 HTTP 请求聚合成完整对象)
→ QueryStrippingHandler(自定义,处理带 query string 的握手路径)
→ ChunkedWriteHandler(支持大数据分块写)
→ WebSocketServerProtocolHandler(处理协议升级握手)
→ WebSocketServerHandler(业务逻辑)

理解这个顺序很重要:一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static class QueryStrippingHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof FullHttpRequest request) {
String originalUri = request.uri();
String rawPath = new QueryStringDecoder(originalUri).path();
if (rawPath.startsWith("/ws") && !rawPath.equals(originalUri)) {
// 原始完整 URI(含 token)先存起来,供握手完成后取出解析
ctx.channel().attr(ORIGINAL_URI_KEY).set(originalUri);
request.setUri(rawPath); // 重写成纯路径,让后面的 Handler 能匹配上
}
}
ctx.fireChannelRead(msg);
}
}

握手完成后(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
2
3
4
5
6
7
8
// ImServiceImpl.handleMessage 的 CHAT 分支
public void handleMessage(Channel channel, ImMessage message) {
if (message.getType() == MessageType.CHAT) {
imMessageService.sendMessage(message); // 统一委托,不再自己维护路由和存储
return;
}
// ACK/PONG 等无需持久化的控制帧,仍直接走本地 WebSocketSessionManager
}

必须记住的结论

  • 同一份状态(这里是”用户在哪个节点、消息该怎么路由”),项目里只应该有一处实现,其他所有入口(无论是 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
2
3
livin:
jwt:
public-key: ${JWT_PUBLIC_KEY:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...}

关联问题: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
2
3
4
5
6
spring:
autoconfigure:
exclude:
- com.alibaba.druid.spring.boot3.autoconfigure.DruidDataSourceAutoConfigure
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration

必须记住的结论

  • 不是所有服务的鉴权方式都一样,要看这个服务的入口流量是否经过网关。经过网关的可以信任 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 使路径可匹配