本节目标:说清 Netty 在「内核套接字」与「业务协议」之间具体接管了哪一段,以及 linkd-v2 为什么按四个模块、四个端口来切。
适用版本:JDK 25(Temurin 25.0.5+8)+ Netty 4.2.19.Final。
承接的问题
卷一最后一章把 linkd 跑起来了:EventLoop 管 Selector,Connection 管写队列,ReactorEchoHandler 把读到的字节原样写回。它能证明事件驱动是通的,但只能证明这一件事。
真实服务要在同一个进程里接 HTTP/1.1、WebSocket、h2c、gRPC 和自定义二进制协议;每种协议都有自己的帧边界、超时语义和关闭流程。如果继续在卷一的 EventLoop 上加 if (port == 9090),半包处理、写队列、心跳和异常关闭会迅速缠在一起。Netty 提供的不是「更快的 Socket」,而是一套把传输、缓冲、编解码、处理器分开的骨架,让每种协议各占一条 pipeline。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。本节不涉及性能数字。
为什么不在卷一的 EventLoop 上继续加功能
卷一的 EventLoop 大约一百行:select、跑任务、处理就绪 key,加上 Connection 的写队列和 interestOps 切换。它能支撑 Echo,也能支撑一个简化版的 HTTP 请求行解析。问题出在「简化版」这三个字上。
把缺的能力列出来,会发现每一项都不是加一个分支能解决的:
| 能力 | 卷一的状态 | 真正落地要补的东西 |
|---|---|---|
| HTTP/1.1 | 能读请求行和少量头 | chunked 编码、keep-alive、100-continue、头部大小上限 |
| WebSocket | 无 | 握手校验、Sec-WebSocket-Accept 计算、掩码、分片、控制帧 |
| HTTP/2 | 无 | HPACK、流状态机、流量控制、GOAWAY |
| TLS | 无 | 握手、证书链、ALPN、会话复用 |
| 原生传输 | 无 | epoll/kqueue 的注册与事件翻译 |
| 内存诊断 | 只有 BufferPool 计数 | 泄漏检测、池化指标、分配器调优 |
这些能力各自都是一个独立项目。linkd-v2 的目标不是再写一遍,而是把卷一理解的机制映射到 Netty 已经实现的结构上,然后把精力放在协议语义、连接管理和验收上。
但「用 Netty」不等于「不用理解」。不懂半包,就看不懂 LinkdFrameDecoder 为什么要在 decode 里循环;不懂写队列,就解释不了慢客户端为什么让内存上涨;不懂 EventLoop 的线程归属,就会在 handler 里调用 sync() 把循环卡死。Netty 省掉的是重复实现,不是理解成本。
一次请求在 linkd-v2 里经过谁
先看 GET http://127.0.0.1:9090/healthz 这条最短的路径:
client
│ TCP 连接,目的端口 9090
▼
内核:accept 队列 + socket 接收缓冲区
│ 可读事件
▼
JDK NIO:Selector 返回就绪的 SelectionKey
│ Netty transport 读取并封装
▼
Netty:NioEventLoopGroup → NioServerSocketChannel → NioSocketChannel
│ ByteBuf
▼
ChannelPipeline:HttpServerCodec → HttpObjectAggregator → LinkdHttpHandler
│ FullHttpRequest
▼
业务:/healthz 直接写回 200;/books/{id} 交给 GatewayHandler 转发 libraryd:8080
这张图里,Netty 的职责从「Selector 返回就绪 key」开始,到「pipeline 产出 FullHttpRequest」为止。它负责把字节流变成一条一条可处理的 HTTP 消息,也负责把响应写回内核;但它不知道 /healthz 应该返回什么,不知道 /books/{id} 要转发到哪台机器。
这也是理解 Netty 的第一条分界线:Netty 管传输与编解码的机制,业务管协议语义与策略。 HttpServerCodec 保证你拿到完整的请求行、头和体,LinkdHttpHandler 决定 200 还是 404;LinkdFrameDecoder 保证你拿到一个完整的 LKDF 帧,业务 handler 决定这个 requestId 对应哪个响应。
内核与 JDK 之间还有一层常被忽略:Selector 只告诉你「这个连接可读」,不告诉你「读到了几条消息」。TCP 是字节流,一次可读事件可能带来半个帧,也可能带来三个帧。把这件事从业务代码里拿掉,是 Netty 最实际的价值之一。
Netty 封装了什么、没封装什么
把常见能力拆开看,边界会清楚很多:
| 能力 | Netty 提供 | linkd-v2 仍要自己写 |
|---|---|---|
| 就绪事件分发 | EventLoop 内部的 Selector 循环、任务队列、wakeup | 不要在 handler 里阻塞;线程隔离策略 |
| 半包与粘包 | ByteToMessageDecoder 的累积缓冲区 | LinkdFrameDecoder 的魔数、长度上限、版本校验 |
| 写队列与部分写 | Channel 的出站缓冲、ChannelFuture | 高水位策略、慢客户端丢弃或断开 |
| 缓冲区管理 | ByteBuf、ByteBufAllocator、池化分配器 | 引用计数纪律、谁 retain 谁 release |
| 协议编解码 | HTTP/1.1、WebSocket 握手、HTTP/2 帧 | 路由、认证、业务响应、错误码 |
| 连接生命周期 | bind、close、shutdownGracefully | SIGINT 优雅关闭的编排与期限 |
| 资源诊断 | ResourceLeakDetector、分配器指标 | 指标口径、告警阈值、压测方法 |
第一行到第四行是「机制」,第五行往下是「语义」。机制可以复用,语义不能。Netty 不会因为你引入了 netty-all 就自动知道 LKDF 的 16 字节头;反过来,你也不应该为了一个长度域解码器再手写一遍累积缓冲区。
一个具体的对比例子:卷一 6.2 的 ReactorEchoHandler 在 onReadable 里 channel.read(inbound),读到多少写回多少;它没有「消息」的概念。到了 linkd-v2,9093 端口上的 LinkdFrameDecoder 必须先判断 magic == 0x4C4B4446,再读 length,确认不超过 1 MiB,最后才把 LinkdFrame 交给业务。前者是字节回显,后者才是协议。
与 Tomcat、Jetty、OkHttp 的层次差异
这四个项目经常被放在一张「Java 网络框架」清单里,但它们不在同一层:
| 项目 | 定位 | 你直接得到什么 | 你不直接得到什么 |
|---|---|---|---|
| Tomcat | Servlet 容器 | HTTP 连接器、Servlet 生命周期、部署与 session | 自定义二进制协议、细粒度的 pipeline 控制 |
| Jetty | Servlet 容器 / 嵌入式 HTTP | 连接器、Handler 体系、嵌入式启动 | 与 Servlet 无关的协议栈默认不提供 |
| OkHttp | HTTP 客户端 | 连接池、拦截器、HTTP/1.1 与 HTTP/2 | 服务端监听、自定义协议服务端 |
| Netty | 异步事件驱动网络库 | 传输、缓冲区、编解码器、pipeline、原生传输 | 容器、路由、会话、部署模型 |
Tomcat 和 Jetty 解决的是「把 WAR 或 Servlet 跑起来」,它们的抽象中心是请求与容器生命周期;Netty 解决的是「把字节流可靠地拆成消息并分发」,抽象中心是 Channel 与 ChannelPipeline。用 Netty 写 HTTP 服务,等于自己承担容器通常替你做的路由、生命周期和优雅关闭。
OkHttp 则是另一个方向:它是客户端,而且只讲 HTTP。linkd-v2 的 linkd-client 要和 9093 的自定义协议通信,RpcClient 需要自己维护连接、请求关联和重连,OkHttp 的拦截器模型帮不上忙;这部分用 Netty 的 Bootstrap 更直接。反过来说,如果只是调用第三方 HTTP API,OkHttp 的成熟度更高,不必为了统一技术栈硬上 Netty。
linkd-v2 选择 Netty 的理由很具体:一个进程要同时监听 9090、9091、9092、9093,四种协议共享同一套 EventLoop、同一套内存分配器和同一套关闭流程。用容器做这件事,要么起多个进程,要么在容器里挂非 HTTP 的连接器,复杂度不会更低。
Netty 4.2 的模块划分
Netty 4.2 把类拆在多个 jar 里,常用的几块如下:
| 模块 | 主要包 | 典型类 | linkd-v2 谁用 |
|---|---|---|---|
netty-transport | io.netty.channel、io.netty.channel.nio | Channel、ChannelPipeline、NioEventLoopGroup | 四个模块都用 |
netty-buffer | io.netty.buffer | ByteBuf、PooledByteBufAllocator | linkd-core、linkd-server |
netty-codec | io.netty.handler.codec | ByteToMessageDecoder、MessageToByteEncoder | linkd-core 的帧编解码 |
netty-codec-http | io.netty.handler.codec.http | HttpServerCodec、HttpObjectAggregator | linkd-server 的 9090 |
netty-codec-http2 | io.netty.handler.codec.http2 | Http2FrameCodecBuilder、Http2MultiplexHandler | linkd-server 的 9091 |
netty-handler | io.netty.handler.timeout、io.netty.handler.ssl | IdleStateHandler、SslContext | 心跳与 TLS |
netty-common | io.netty.util | ReferenceCountUtil、ResourceLeakDetector | 泄漏排查 |
io.netty:netty-all 是聚合 POM:它的 jar 只有几 KB 的元数据,真正的类在 netty-transport、netty-buffer 等模块里,由 Maven 传递依赖带进来。1.3 会用 jar tf 和依赖树验证这一点;现在只需要记住:编译时引一个坐标,运行时靠一整棵依赖树。
在 linkd-core 上执行依赖树命令,可以看到这层传递关系:
mvn -pl linkd-core dependency:tree -Dincludes=io.netty
本机输出(节选,省略了原生传输的 classifier 与后半段模块):
[INFO] com.plume.linkd.v2:linkd-core:jar:0.2.0-SNAPSHOT
[INFO] \- io.netty:netty-all:jar:4.2.19.Final:compile
[INFO] +- io.netty:netty-buffer:jar:4.2.19.Final:compile
[INFO] +- io.netty:netty-codec:jar:4.2.19.Final:compile
[INFO] +- io.netty:netty-codec-http:jar:4.2.19.Final:compile
[INFO] +- io.netty:netty-transport:jar:4.2.19.Final:compile
netty-all 在树里只占一行,但它把 HTTP、HTTP/2、WebSocket、原生传输的模块全部带了进来。这也是为什么 linkd-core 的 POM 只写一个依赖,编译时却能用 ByteToMessageDecoder、ByteBuf 和 NioEventLoopGroup。
模块划分对应到代码,就是 pipeline 的装配位置。9090 的初始化逻辑大致是这样一段:
ch.pipeline()
.addLast(new HttpServerCodec())
.addLast(new HttpObjectAggregator(1 << 20))
.addLast(new LinkdHttpHandler());
HttpServerCodec 把字节变成 HttpRequest / HttpContent,HttpObjectAggregator 把分片的请求聚合成 FullHttpRequest,LinkdHttpHandler 只处理完整请求。9093 的装配则是另一条链:
ch.pipeline()
.addLast(new LinkdFrameDecoder())
.addLast(new LinkdFrameEncoder());
两条链共享 EventLoop 和 ByteBufAllocator,但消息类型完全不同。Netty 的价值就在这种「同一套传输骨架,按端口装配不同协议」的结构里。
linkd-v2 的端口与模块图
工程按职责拆成四个模块,依赖方向单一:
linkd-v2/
├── pom.xml # 父 POM,packaging=pom,统一 netty.version
├── linkd-core/ # LKDF 帧、编解码、共享常量
├── linkd-server/ # HTTP、WebSocket、HTTP/2、网关
├── linkd-client/ # 客户端、重连、连接池
└── linkd-app/ # main、配置、生命周期
linkd-core 不依赖任何其他模块;linkd-server 和 linkd-client 都只依赖 linkd-core;linkd-app 依赖另外三个模块并把它们组装起来。这个方向在第 1 章就要定死,后面加功能只能沿着箭头走,不能反向。
端口分工与模块的对应关系:
| 端口 | 协议 | 入口类 | 归属模块 |
|---|---|---|---|
| 9090 | HTTP/1.1 | LinkdHttpHandler、GatewayHandler | linkd-server |
| 9091 | h2c(明文 HTTP/2) | HTTP/2 pipeline | linkd-server |
| 9092 | gRPC | LinkdService 的 Ping | linkd-server |
| 9093 | LKDF 自定义协议 | LinkdFrameDecoder、LinkdFrameEncoder | linkd-core + linkd-server |
| 8080 | libraryd(被代理方) | 无,外部进程 | 不在本工程 |
9090 上的 /healthz 由 LinkdHttpHandler 直接返回 200 OK 与 ok;/books/{id} 交给 GatewayHandler 转发到 http://127.0.0.1:8080/books/{id},不可达时返回 502。9093 上的连接由 ConnectionRegistry 登记,供广播和关闭时遍历。WebSocket 的 /ws/echo 与 /ws/broadcast 也挂在 9090,由 WebSocketHub 持有 ChannelGroup。
9093 的路径值得单独画一遍,因为它和 HTTP 那条完全不同:
TCP :9093
▼
NioEventLoopGroup(worker 线程)
▼
LinkdFrameDecoder:累积 ByteBuf,校验 magic、version、length
▼
LinkdFrame(type, flags, requestId, payload)
▼
业务 handler(第 7 章接入)
▼
LinkdFrameEncoder → ByteBuf → Channel
LinkdFrameDecoder 的产出是 LinkdFrame,不是 FullHttpRequest;requestId 用来把响应关联到请求,ConnectionRegistry 用来找到目标连接。这两件事 Netty 都不知道,必须由 linkd-v2 定义。也正因为两条链的消息类型不同,它们可以共用一个 EventLoopGroup,却不能共用一个 handler。
LinkdV2Main 只做三件事:读取配置、按端口启动对应的 ServerBootstrap、注册 SIGINT 钩子。第 1 章先把它跑成一个能打印协议常量的骨架,2.1 再接入第一个监听端口。
判断一段代码该放在哪一层
读 Netty 代码时,先问三个问题,能避免把逻辑塞错位置:
第一,这是字节边界问题,还是消息语义问题? 魔数校验、长度域、粘包拆包属于字节边界,放进 ByteToMessageDecoder,也就是 LinkdFrameDecoder;/books/{id} 应该返回 404 还是转发,属于消息语义,放进 LinkdHttpHandler 或 GatewayHandler。
第二,这段代码跑在哪个线程? handler 默认在 Channel 绑定的 EventLoop 线程执行。查询数据库、调用 libraryd、读大文件都会占住这个线程;这类工作要么改成异步,要么交给独立线程池。Netty 不会替你判断,它只保证同一个 Channel 的事件串行。
第三,谁负责释放? 入站 ByteBuf 由 SimpleChannelInboundHandler 自动释放;ByteToMessageDecoder 的入参由父类管理;自己 retain() 或 retainedDuplicate() 出来的引用,必须自己 release。把这三个问题问清楚,5.2 的泄漏排查会轻松很多。
常见错误与边界
错误一:把 Netty 当 Web 框架。 引入 Netty 后仍在找「路由注解」和「过滤器链」。Netty 没有这些;/books/{id} 的匹配、404 的返回、502 的语义都要在 LinkdHttpHandler 或 GatewayHandler 里自己写。
错误二:以为换成 Netty 就会更快。 Netty 减少的是线程与拷贝的开销,不是业务耗时。如果在 LinkdHttpHandler 里同步查数据库,EventLoop 线程照样被占住,所有连接一起排队。3.3 会讲隔离方式。
错误三:把 netty-all 当成 fat jar 部署。 它的 jar 里只有 META-INF,没有 io/netty/channel/Channel.class。java -cp netty-all.jar ... 会在加载第一个 Netty 类时失败;正确做法是让 Maven 或打包插件带上整棵依赖树。
错误四:在 macOS 上找 epoll。 Epoll.isAvailable() 在本机返回 false,这是平台事实,不是配置错误。macOS 的原生传输是 kqueue,且需要 osx-aarch_64 的 classifier;没有对应 jar 时回退 NioEventLoopGroup。12.1 会给出选择逻辑。
错误五:每个端口各建一组 EventLoopGroup。 四个端口各建一个 boss 和一个 worker,线程数会按端口数翻倍,连接迁移和关闭也要重复四遍。linkd-v2 共享一组 boss(只负责 accept)和一组 worker(负责所有已建立连接),每个端口只新增自己的 ServerBootstrap 和 pipeline。
这五类错误有一个共同点:都把 Netty 当成了一个会自动兜底的容器。它只提供机制,兜底策略要自己写。
边界:Netty 隐藏的是机制,不是策略。 它替你处理 accept 循环、半包累积、部分写重试和任务调度,但「写队列多长开始拒绝」「业务阻塞放哪个线程池」「关闭时等多久」这些策略必须由 linkd-v2 自己定义。把这些策略误当成 Netty 的默认保证,是后面几章反复出现的故障来源。
小结
- Netty 位于 JDK NIO 与业务协议之间:向下接管就绪事件与字节流,向上产出
ByteBuf或解码后的消息。 - 它封装事件分发、半包累积、写队列、缓冲区和常见协议编解码;不封装路由、认证、业务语义与关闭策略。
- Tomcat/Jetty 是 Servlet 容器,OkHttp 是 HTTP 客户端,Netty 是更底层的异步网络库,四者不在同一抽象层。
- linkd-v2 按 core、server、client、app 四模块组织,依赖方向单一;9090/9091/9092/9093 分别承载 HTTP、h2c、gRPC 与 LKDF。
netty-all只是聚合坐标,真正的类在netty-transport、netty-buffer、netty-codec-*等模块里。- 判断代码位置时先分清字节边界、线程归属和释放责任,再决定写进 decoder、handler 还是关闭流程。
- 四个端口共享 EventLoopGroup,但各自持有独立的 pipeline;这是「一套传输骨架、多种协议」的落点。
下一节把卷一手写的 EventLoop、Connection、Handler、BufferPool、FrameDecoder 逐个对应到 Netty 的组件,并用一个可运行的最小 Echo 验证这套映射。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 从 Reactor 到 Netty 组件映射 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。