这本书写给谁
这本书写给三类读者。
第一类:读完卷一,想把 linkd 换成 Netty 的人。 你已经知道 Selector 怎么等就绪事件、ByteBuffer 的 flip/compact 怎么用、HTTP/1.1 的消息边界在哪里。现在你想知道:这些手工步骤在 Netty 里分别由谁负责,哪些坑框架替你挡了,哪些坑框架只是换了个形式。
第二类:正在维护一个 Netty 服务的人。 你能照着示例写出 ServerBootstrap 和几个 handler,但遇到引用计数泄漏、连接数不降、写缓冲积压、EventLoop 被阻塞时,缺少一套从现象到根因的排查路径。这本书的第 9、13、14 章就是为这类问题准备的。
第三类:要做协议网关或长连接服务的人。 你需要同时处理 HTTP/1.1、WebSocket、h2c、gRPC 和自定义二进制协议,还要考虑重连、连接池、限流、优雅关闭。第 8、10、11、15 章会把这些能力放进同一个 linkd-v2 工程,而不是各写一个互不相干的 demo。
如果你完全没有写过 Socket 或 NIO,建议先读卷一。卷二的很多「为什么不能这样写」,默认你知道阻塞、非阻塞、半包、粘包分别是什么。
前置知识与卷一映射
下表列出本卷直接依赖的卷一内容。没有读过的部分,至少把对应的概念补上,否则代码能跑,结论理解不了。
| 卷一内容 | 本卷用到的地方 | 需要带走的概念 |
|---|---|---|
| 第 3 章 Socket 与超时 | 第 2 章 Bootstrap、第 8 章客户端 | 连接、读、写超时的边界不同 |
| 第 4 章字节序与成帧 | 第 6 章编解码器、第 7 章协议帧 | 定长、分隔符、长度域三种成帧 |
第 5 章 ByteBuffer 状态 | 第 5 章 ByteBuf | 读写索引、视图、直接缓冲区的成本 |
| 第 6 章 Reactor | 第 3 章 EventLoop | 单线程事件循环、任务队列、线程归属 |
| 第 7 章非阻塞读写 | 第 4 章 Pipeline、第 9 章背压 | 半包、写队列、OP_WRITE |
| 第 8 章 HTTP/1.1 | 第 10、15 章 | keep-alive、chunked、响应顺序 |
| 第 9 章 HTTP/2 帧 | 第 11 章 h2c | 帧、流、多路复用、流量控制 |
| 第 11 章 WebSocket | 第 10 章 | 握手、帧、分片、心跳 |
| 第 14 章水位与优雅关闭 | 第 9、15 章 | 高/低水位、排空、关闭期限 |
不需要提前会 Netty,也不需要会 gRPC。第 11 章之前不会出现 gRPC API;如果你跳过前面直接看第 11 章,至少先读第 4 章的 Pipeline 和第 6 章的编解码器。
版本与机器口径
本书的写作与验证环境固定如下。正文里出现的「本机输出」都来自这套环境;换机器、换 JDK 小版本,输出可能不同。
| 项 | 版本/配置 | 说明 |
|---|---|---|
| 机器 | Apple M1 Pro,10 核,32 GB | Apple Silicon,arm64 |
| 操作系统 | macOS 26.3 (25D125) | 原生传输用 kqueue,不是 epoll |
| JDK | Temurin 25.0.5+8 | javac --release 25 |
| Maven | 3.9.12 | mvn -version 里的 Java 必须是 25 |
| Netty | 4.2.19.Final | netty-all 聚合依赖 |
| JUnit Jupiter | 5.14.4 | 只用于测试片段 |
| gRPC-Java | 1.76.0 | 仅第 11 章 |
| Protobuf | 4.33.2 | 仅第 11 章 |
| 日志 | System.getLogger | 不引 SLF4J 绑定 |
机器口径统一写成:
Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8,Netty 4.2.19.Final
环境变量与版本核对:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
java -version
javac -version
mvn -version
三条命令的输出必须互相对得上。示意输出(节选,构建号以你的安装为准):
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
javac 25.0.5
Apache Maven 3.9.12
Java version: 25.0.5, vendor: Eclipse Adoptium
mvn -version 里的 Java version 不是 25 时,先查 JAVA_HOME 和 PATH,不要继续往下跑。Maven 用的是它启动时看到的 JDK,不是 pom.xml 里写的 release。
linkd-v2 工程结构
全书围绕一个四模块工程推进:
linkd-v2/
├── pom.xml # 父 POM,packaging=pom
├── linkd-core/ # 协议帧、编解码、共享常量
├── linkd-server/ # HTTP、WebSocket、HTTP/2、网关
├── linkd-client/ # 客户端、重连、连接池
└── linkd-app/ # main、配置、生命周期
父 POM 只锁两个关键属性,模块 POM 不再各写一遍版本:
<properties>
<netty.version>4.2.19.Final</netty.version>
<maven.compiler.release>25</maven.compiler.release>
</properties>
groupId 是 com.plume.linkd.v2,版本 0.2.0-SNAPSHOT;linkd-app 依赖另外三个模块,模块之间不循环依赖。入口类为 com.plume.linkd.v2.app.LinkdV2Main。第 1.3 节会把工程搭起来,后面的章节只往里加类,不再改模块划分。
构建命令:
mvn -q -DskipTests package
第一次构建会下载 Netty 及其传递依赖。netty-all 是聚合 POM,jar 很小,真实类在 netty-transport、netty-buffer、netty-codec-http 等模块里,不需要手工下载 jar。
端口与路径
端口是跨章契约,不能在某节里临时换一个:
| 端口 | 协议/路径 | 用途 |
|---|---|---|
| 9090 | HTTP/1.1、WebSocket | /healthz、/books/{id}、/ws/echo、/ws/broadcast |
| 9091 | h2c | 明文 HTTP/2 prior-knowledge,不做 TLS ALPN |
| 9092 | gRPC | LinkdService.Ping |
| 9093 | 自定义二进制协议 | LinkdFrame,16 字节头 + 负载 |
| 8080 | 上游 libraryd | 由 9090 的网关代理 |
启动后先确认监听:
lsof -nP -iTCP:9090 -sTCP:LISTEN
lsof -nP -iTCP:9091 -sTCP:LISTEN
macOS 没有 ss,连接状态用 netstat -anv -p tcp 查看;抓包用 tcpdump -i lo0,需要 sudo。这些命令在第 13、15 章会反复出现。
端口被占用时,不要改端口绕过,先找出占用进程。全书所有验证脚本都假设端口固定,临时改端口会让后面的检查对不上。
四道门禁
「代码能编译」不等于「这一节写对了」。本书对每一节的可运行内容设四道门禁,从便宜到贵依次执行。前三道是每节的最低要求,第四道在涉及缓冲区、连接池、背压和关闭的章节必须执行。
| 门禁 | 命令 | 通过判据 | 失败先看 |
|---|---|---|---|
| G1 编译 | mvn -q -DskipTests compile | BUILD SUCCESS,无编译错误 | 版本、包名、API 白名单 |
| G2 单测 | mvn -q test | 测试全部通过,失败信息可定位 | 断言、测试替身、端口冲突 |
| G3 冒烟 | 启动 LinkdV2Main 并访问端口 | /healthz 返回 200 与 ok,9093 能收到 PONG | 启动日志、端口、handler 顺序 |
| G4 资源 | 开 ADVANCED 泄漏检测跑一轮 | 无 LEAK:,写缓冲回落到低水位,SIGINT 后端口关闭 | 第 5、9、13 章 |
G1:编译门禁
mvn -q -DskipTests compile
-q 只输出错误和必要信息,脚本里用它可以减少噪音;本地排查时去掉 -q,看完整的模块与依赖。编译失败先确认 maven.compiler.release 是 25,再确认代码里的 Netty 类确实在依赖中。不要用 --add-opens 或反射去绕编译错误,那通常意味着用了非公开 API。
G2:单测门禁
mvn -q test
测试只覆盖纯逻辑:帧头解析、长度校验、路由匹配、重连退避计算、连接池借还状态。涉及真实端口的测试要单独标记,避免和正在运行的服务抢 9090。JUnit Jupiter 5.14.4 只用于测试片段,正文不把测试框架当成运行时依赖。
单测失败时,先看断言值和实际值,不要先改测试。很多失败是「实现改了,测试没改」或「测试假设了固定端口」。
G3:冒烟门禁
启动服务后,至少验证三条链路:
curl -fsS http://127.0.0.1:9090/healthz
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/books/7
# 9093:运行第 7 章的客户端,发送 PING 并确认收到 PONG
第一条应返回 ok;第二条在上游 libraryd 可用时返回 200,不可用时返回 502;第三条走自定义协议,由第 7 章的客户端完成。三个结果都要记录,不能只看「进程还活着」。
冒烟阶段还要验证关闭:向进程发送 SIGINT,确认 server channel 停止接收新连接、已建立连接按策略处理、EventLoopGroup 在期限内退出。只 kill -9 不算通过。
G4:资源门禁
java -Dio.netty.leakDetection.level=advanced \
-cp <classpath> com.plume.linkd.v2.app.LinkdV2Main
ResourceLeakDetector 默认级别是 SIMPLE;排查时临时调到 ADVANCED,跑一轮典型流量,检查日志里是否出现 LEAK:。PARANOID 开销大,只适合短时间定位,不要留在生产配置里。
资源门禁还看两个信号:写缓冲超过高水位后,读是否被暂停并在回落后恢复;连接关闭后,连接数、堆外内存和线程数是否回到基线附近。具体命令和指标口径在第 9、13、14 章,这里只要求「有观察、有记录」。
代码约定
包名。 共享协议在 com.plume.linkd.v2.protocol,服务端在 com.plume.linkd.v2.server,客户端在 com.plume.linkd.v2.client,启动与配置在 com.plume.linkd.v2.app。正文提到类时写全包名或写清所属模块,避免跨节复制时找不到。
类型。 所有 public 类必须是顶层类,或 public static 嵌套类;一个代码块里不要放两个 public 顶层类型。共享类型的签名以 BRIEF 的契约为准,不在某一节里单独改名。
代码块语言。 Java 标 java,命令行标 bash,终端输出标 text,pom.xml 标 xml,配置标 yaml 或 properties,JSON 标 json。没有语言标记的围栏在构建检查里会被判失败。
省略。 代码块里的 ... 表示省略,不是可编译语法。完整示例会写全;片段会说明缺了哪些部分。异常栈只保留关键帧,用 ... 省略框架内部调用。
输出。 「本机输出」表示在写作环境真实执行过;「示意输出」表示为了说明结构而简化,不能当成实测结果。单次运行会标注「单次运行,未做统计」。性能数字必须附可复现命令和机器口径。
日志。 统一用 System.getLogger,不引 SLF4J 绑定。Netty 内部日志走 JDK 日志;需要看它的时候,调 java.util.logging 的级别,而不是加一个日志实现。
异步。 EventLoop 线程里不调用 sync() 或 await()。需要知道写结果时用监听器:
ChannelFuture future = channel.writeAndFlush(frame);
future.addListener((ChannelFutureListener) f -> {
if (!f.isSuccess()) {
LOG.log(System.Logger.Level.WARNING, "write failed", f.cause());
}
});
这段代码的重点不是语法,而是线程:监听器在 future 完成时执行,不阻塞 EventLoop。ctx.writeAndFlush() 从当前 handler 往前找出站 handler,ctx.channel().writeAndFlush() 从 tail 开始;两者的差异在第 4 章展开。
API。 只使用公开 API。遇到不在白名单里的类,先确认它属于哪个 netty-* 模块,再决定是否引入;不靠反射访问私有字段,也不把 ChannelOption 当字符串传。
怎么验证一节的内容
每一节的代码按下面五步验证,顺序不要颠倒。
- 对齐版本。
java -version、javac -version、mvn -version三者一致,pom.xml的release是 25。 - 编译。 完整类用
mvn -q -DskipTests compile;单文件片段用javac --release 25 -cp <netty-jars> File.java。 - 运行。 启动
LinkdV2Main或该节的测试入口,确认端口监听、日志没有异常。 - 观察。 用
curl、Python 客户端、lsof、netstat或 JFR 观察行为;不要只看「没有报错」。 - 记录。 把命令、输出、机器口径和结论写下来。没有记录的「验证过」等于没验证。
连接诊断的常用命令:
# 谁在监听 9090
lsof -nP -iTCP:9090 -sTCP:LISTEN
# 连接状态(macOS;没有 ss)
netstat -anv -p tcp | rg '\.9090 '
# 回环抓包,需要 sudo
sudo tcpdump -i lo0 -nn 'tcp port 9090'
验证记录模板:
日期:
机器:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
版本:Temurin 25.0.5+8,Netty 4.2.19.Final
命令:
输出:
结论:通过 / 不通过(原因)
如果输出和书里不一致,先按「版本 → 端口 → 依赖 → 代码」的顺序排查,不要先怀疑结论。版本不一致时,很多「bug」只是行为差异;端口不一致时,很多「连接失败」只是连到了旧进程。
三条阅读路径(简版)
完整版在目录篇,这里先给结论:
| 路径 | 适合的人 | 主线 | 大约投入 |
|---|---|---|---|
| 协议实现线 | 要写客户端/服务端协议的人 | 第 1–8 章,落到 9093 自定义协议 | 6–7 周 |
| 性能排查线 | 正在维护长连接服务的人 | 第 2–5、9、12–14 章 | 5–6 周 |
| 生产网关线 | 要做接入层、网关的人 | 第 1–2、4、8–11、15 章 | 6–8 周 |
三条路径都假设你读过卷一的第 6、7 章。如果没读,至少先补「单线程 Reactor」和「非阻塞读写」两节,否则第 3、4 章会缺少参照。
常见误解
「Netty 一定比 JDK NIO 快。」 不一定。Netty 在大量连接、频繁分配、协议组合的场景下优势明显;在 handler 阻塞、缓冲区持有过久、水位不处理的场景下,它一样会慢,甚至更慢。比较要在同一负载、同一客户端、同一机器上做,指标看吞吐、p50/p99、GC 和 RSS,不能只看一个 QPS。
「netty-all 是一个大 jar,里面什么都有。」 它是聚合 POM,jar 很小,真实类分布在多个模块里,由 Maven 传递依赖。不要手写下载命令把它当成运行时依赖。
「macOS 上也能用 epoll。」 不能。macOS 用 kqueue,Epoll.isAvailable() 返回 false 是正常现象。代码要按平台选择,并在构件缺失时回退 NioEventLoopGroup。
「SimpleChannelInboundHandler 会自动释放所有消息。」 它默认释放入站消息,但如果你覆写 autoRelease 为 false、把消息传给下一个 handler,或者自己 readRetainedSlice(),释放责任就转移了。第 5 章会逐条说明。
「EventLoop 里等一小会儿没关系。」 有关系。EventLoop 是单线程执行器,一条连接上的等待会挡住同一个 EventLoop 上的所有连接。业务阻塞要移到业务线程池或虚拟线程,不能靠「只等 5 毫秒」自我安慰。
小结
- 本书面向读完卷一、正在维护 Netty 服务、或要做协议网关的读者;前置是 Socket、成帧、Reactor、HTTP/1.1 与 WebSocket。
- 版本口径固定:Temurin 25.0.5+8、Maven 3.9.12、Netty 4.2.19.Final;机器为 Apple M1 Pro、macOS 26.3。
- 工程是四模块的
linkd-v2,端口 9090/9091/9092/9093,上游libraryd在 8080。 - 四道门禁是编译、单测、冒烟、资源;前两道每节必过,后两道在涉及资源和关闭的章节必过。
- 代码块标语言,输出区分「本机」与「示意」,EventLoop 里不阻塞、不
sync()。 - 验证要留下命令、输出、机器口径和结论;没有记录就不算通过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。