本节目标:把「15.1 和 15.2 都写完了」变成一份别人能照着重跑、能签字的验收记录,并知道失败后怎么定位到具体章节。
适用版本:JDK 25 + Netty 4.2.19.Final;libraryd在 8080,linkd-v2的 HTTP 在 9090。
承接的问题
卷一 15.3 给过 linkd v1 的验收表:健康检查、keep-alive、h2c preface、WebSocket、TLS、优雅关闭,逐条给命令与判据。那张表不能直接搬到 linkd-v2:
- v1 的 HTTP/1.1 是手写状态机,v2 换成
HttpServerCodec加HttpObjectAggregator,同样的请求可能在新 pipeline 的不同位置失败。 - v1 没有引用计数,v2 的
ByteBuf漏了不会立刻报错,只会在压测后表现为堆外内存上涨。 - v1 的线程模型是自己写的 Selector 循环,v2 是
EventLoopGroup,阻塞代码的后果从「一个循环变慢」变成「一条 EventLoop 上的所有连接一起停摆」。 - v1 的代理是同步的,v2 是
HttpClientCodec加连接池,超时、池满、迟到响应是新的失败模式。
所以 v2 的验收要重新采集证据,不能复制 v1 的 PASS。下面所有表格都是执行模板:写的是检查项、预期和证据,不是本次运行的结果;结果由执行人填,未执行就写 SKIP 并注明原因。
功能与协议一致性清单
先验「协议说得对」,再验「功能跑得通」。两条 /books/7 请求都返回 200,但一条带 Transfer-Encoding: chunked、一条带错误的 Content-Length,后者才是更危险的那条。
| 检查项 | 命令 | 预期 | 证据 |
|---|---|---|---|
| 健康检查 | curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/healthz | 200,正文 ok | 状态码与正文 |
| 图书代理 | curl -sS -D - http://127.0.0.1:9090/books/7 | 200,正文来自 libraryd,带 Via: 1.1 linkd-v2 | 响应头与正文 |
| 未知路由 | curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/nope | 404,不是 500 或挂起 | 状态码 |
| 路径穿越 | curl --path-as-is -sS -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:9090/books/../../etc/passwd' | 400,网关拒绝,不转发 | 状态码 |
| 查询串 | curl -sS -D - 'http://127.0.0.1:9090/books/7?fields=title' | 上游收到原样查询串,响应 200 | 响应头、上游日志 |
| keep-alive | curl -v --http1.1 http://127.0.0.1:9090/healthz http://127.0.0.1:9090/healthz | 第二条复用连接,两条都 200 | curl -v 的 Re-using existing connection |
| h2c | curl --http2-prior-knowledge -sS -o /dev/null -w '%{http_version} %{http_code}\n' http://127.0.0.1:9091/healthz | 2 200,不是 HTTP/1.1 降级 | 版本与状态码 |
| WebSocket | curl -i -N --max-time 2 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' http://127.0.0.1:9090/ws/echo | 101 Switching Protocols,Sec-WebSocket-Accept 正确 | 响应头 |
| 自定义协议 | printf '\x4c\x4b\x44\x46\x01\x03\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00' | nc -w 2 127.0.0.1 9093 | od -An -tx1 | 回帧 offset 5 的 type 为 04(PONG),requestId 为 00 00 00 01 | 十六进制输出 |
| 上游不可达 | 停止 libraryd 后 curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/books/7 | 先 502,熔断打开后 503;/healthz 仍 200 | 状态码序列 |
| 限流 | ab -k -n 600 -c 30 http://127.0.0.1:9090/books/7 后 curl -sS -D - -o /dev/null http://127.0.0.1:9090/books/7 | 出现 429,响应带整数秒 Retry-After | ab 报告、响应头 |
| 优雅关闭 | 见下一节命令 | 在途请求完成,10 秒内退出,端口释放 | 退出码、lsof |
--path-as-is 不能省:curl 默认会在客户端把 .. 归一化掉,不加这个参数,请求根本没到网关,测出来的是 curl 的行为,不是网关的。
9093 的十六进制帧按 7.1 的 16 字节头拼:4c 4b 44 46 是 LKDF,01 是 version,03 是 PING,00 00 是 flags,00 00 00 01 是 requestId,00 00 00 00 是长度。回帧把 type 改成 04,requestId 原样带回。用 od 而不是 xxd,是因为 macOS 和 Linux 都自带 od。
协议一致性还有三条不在表里、但要在抓包里看:
- 不能同时出现
Content-Length和Transfer-Encoding: chunked。 15.1 的网关重算长度、删逐跳头,就是为了保证这一条。 - 状态行版本与请求版本匹配。 HTTP/1.0 客户端发
Connection: close,响应应该关闭连接,不能假装 keep-alive。 - 错误响应不能带内部地址和堆栈。 502 的正文只写
proxy failed,不要把libraryd的 host:port、异常类名回给客户端。
故障注入:主动把服务弄坏
正常路径只能证明「代码跑起来了」,故障注入才能证明「保护机制真的接上了」。五项按「注入 → 预期 → 证据」执行,命令都在本机可复制。
杀掉 libraryd
kill -TERM "$(pgrep -f 'libraryd.*jar')"
for i in $(seq 1 12); do
curl -sS -o /dev/null -w '%{http_code} ' http://127.0.0.1:9090/books/7
done
echo
lsof -nP -iTCP:8080 | wc -l
预期:前几个请求 502(连接被拒),连续失败达到阈值后变 503(熔断打开);/healthz 全程 200;熔断打开后 lsof 不再出现新的 8080 连接。证据是 12 个状态码的序列和 lsof 计数。
pgrep -f 'libraryd.*jar' 比 pgrep -f libraryd 稳,后者可能匹配到正在执行的 pgrep 本身或 shell 命令行。kill -TERM 让 libraryd 走自己的关闭流程;如果想测「端口还在但进程不响应」,用 kill -STOP,这时网关会先建连再读超时,返回 504。
慢客户端
nc 127.0.0.1 9090
# 手动输入下面这行,不要补最后的空行
GET /books/7 HTTP/1.1
Host: 127.0.0.1
预期:服务端在 ReadTimeoutHandler(建议 5 秒)触发后关闭连接;重复 100 次后,lsof -nP -iTCP:9090 | wc -l 不随次数增长。证据是连接被关闭的时间点和 fd 计数。
9090 的 pipeline 要显式加 ReadTimeoutHandler,否则慢客户端会把连接一直挂着,worker 线程和 fd 都被占住。这个超时和 15.2 的后端读超时是两回事:一个限制客户端,一个限制上游,不能共用一个实例。
半关闭
try (Socket socket = new Socket("127.0.0.1", 9090)) {
socket.getOutputStream().write((
"GET /healthz HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.getBytes(StandardCharsets.US_ASCII));
socket.getOutputStream().flush();
socket.shutdownOutput();
String response = new String(socket.getInputStream().readAllBytes(),
StandardCharsets.ISO_8859_1);
System.out.println(response.lines().findFirst().orElse("no response"));
}
预期打印 HTTP/1.1 200 OK。客户端只关了写方向,读方向还在;服务端不能把「读到 EOF」当成「连接已经没了」,必须把响应写完再关闭。如果打印 no response 或连接被 RST,说明 8.3 的半关闭处理在 HttpServerCodec 这条链上没有接对。
注意用 shutdownOutput(),不要用 close():close() 会同时关掉读方向,客户端自己读不到响应,测出来的是客户端的 bug。
ByteBuf 泄漏
java -Dio.netty.leakDetection.level=PARANOID \
-Dio.netty.allocator.type=pooled \
-Xmx256m -jar linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar
for i in $(seq 1 2000); do
curl -sS -o /dev/null http://127.0.0.1:9090/books/7
done
grep -c 'LEAK:' linkd.log
预期:grep 输出 0,进程不出现 IllegalReferenceCountException,DirectBuffer 的 committed 在请求停止后回落到基线附近。证据是 LEAK: 计数、日志里的异常、jcmd <pid> VM.native_memory summary(启动时加 -XX:NativeMemoryTracking=summary)。
PARANOID 只适合短时间排查,它会给每个 ByteBuf 记录调用栈,吞吐会明显下降;生产默认是 SIMPLE,SIMPLE 抓不到大多数泄漏。用 2000 次请求而不是 20 次,是因为泄漏往往发生在「池借出后超时关闭」这种低频路径。
EventLoop 阻塞
static void watchLag(Channel channel) {
EventLoop loop = channel.eventLoop();
AtomicLong last = new AtomicLong(System.nanoTime());
loop.scheduleAtFixedRate(() -> {
long now = System.nanoTime();
long lagMs = (now - last.getAndSet(now)) / 1_000_000L - 100L;
if (lagMs > 50) {
System.getLogger("linkd.lag").log(System.Logger.Level.WARNING,
"event loop lag {0} ms", lagMs);
}
}, 0, 100, TimeUnit.MILLISECONDS);
}
预期:在 14 章的压测流量下跑 60 秒,日志里没有 event loop lag 警告。这个探针每 100 毫秒在同一个 EventLoop 上排一个任务,实际执行晚于预期 50 毫秒就说明线程被占住了。证据是告警计数和同一时段的 jcmd <pid> Thread.print:如果 event loop 线程停在 read0、JDBC 或文件 IO,就是 3.3 的阻塞隔离没做到位。
优雅关闭
SIGINT 是发布和重启的常规路径,必须验证「停止接收新连接、排空在途请求、限时退出」三件事:
java -jar linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar &
pid=$!
sleep 2
curl -sS --max-time 5 http://127.0.0.1:9090/books/7 > /tmp/inflight.out &
curl_pid=$!
kill -INT "$pid"
wait "$curl_pid"; echo "curl=$?"
wait "$pid"; echo "exit=$?"
lsof -nP -iTCP:9090 -sTCP:LISTEN || echo "port released"
预期:curl=0 且 /tmp/inflight.out 是完整正文;exit=0;进程在 10 秒内退出;lsof 显示端口已释放。证据是三个退出码和 lsof 输出。
关闭顺序必须是:先关 server channel(停止 accept),再关在途连接和连接池,最后 shutdownGracefully() 停 EventLoopGroup。顺序反了,EventLoopGroup 先停,在途响应写不出去,客户端收到的是连接重置而不是完整正文。wait "$pid" 要放在同一个 shell 里,跨 shell 拿不到子进程退出码。
如果 10 秒内没退出,先看 jcmd <pid> Thread.print:卡在 shutdownGracefully 的默认静默期是正常等待,卡在业务线程池的 awaitTermination 说明还有任务没结束。不要用 kill -9 收尾,那会把「关闭慢」这个 bug 一起掩盖掉。
指标与日志
验收不只看看状态码,还要看「出问题时能不能定位」。指标口径固定成下面几个,名字可以换,语义不能少:
| 指标 | 类型 | 在哪里加 | 用途 |
|---|---|---|---|
http_requests_total{route,status} | 计数 | GatewayHandler 写响应处 | 按路由看 2xx/4xx/5xx 比例 |
proxy_duration_seconds | 直方图 | GatewayHandler.forward 完成回调 | 区分池等待、上游耗时、总耗时 |
ratelimit_rejected_total{scope} | 计数 | RateLimitHandler.reject | 判断 429 是全局还是单 IP |
breaker_state{upstream} | 枚举 | CircuitBreaker 状态迁移 | 熔断是否打开、打开了多久 |
pool_leased{upstream} | 瞬时值 | HttpBackendPools 借还处 | 池是否被打满 |
日志用 System.Logger,一行包含固定字段,方便 grep 和后续结构化解析:
2026-10-28T18:19:00.123+08:00 INFO linkd.gateway route=/books/ upstream=127.0.0.1:8080 status=200 durationMs=18 poolWaitMs=2 breaker=CLOSED client=127.0.0.1
这是示意行,不是本机输出。字段里不要放 Authorization、Cookie、查询串中的 token;client 只记 IP,不记端口。502/504 的日志要带 error 的类名和一句原因,但响应体不回显。
验收时要检查两条:同一条请求的接入日志和上游日志能用 requestId 对上;429/503 也有日志,不能只在成功路径打点。没有这两条,故障复盘只能靠猜。
v1 与 v2 的差异与代价
把卷一的 linkd v1 和这一卷的 linkd-v2 放在一起看,差异不只是「用了框架」:
| 维度 | linkd v1(手写) | linkd-v2(Netty) | 代价 |
|---|---|---|---|
| 事件循环 | 自己写 Selector 循环 | EventLoopGroup | 线程归属和阻塞后果要重新学 |
| HTTP 解析 | 手写状态机 | HttpServerCodec + 聚合器 | pipeline 顺序错一位就难查 |
| 连接管理 | 自己维护连接表 | ChannelGroup、FixedChannelPool | 池的借还和超时要配对 |
| 背压 | 自己判断可写 | isWritable()、水位线 | 默认参数不一定适合业务 |
| 内存 | GC 管理 | ByteBuf 引用计数 | 漏释放是静默的 |
| 故障定位 | 直接看 syscall | handler、EventLoop、引用计数三层 | 需要更多工具和约定 |
| 依赖 | JDK 自带 | Netty 4.2.19.Final | 版本升级、CVE、native 构件 |
| 可控性 | 每一行都能改 | 受框架抽象约束 | 遇到框架边界要绕过或等版本 |
最容易被低估的代价是可观测性变差。v1 的 read() 返回什么、write() 写了多少字节,都在自己的代码里;v2 的字节流经过 HttpServerCodec、聚合器、可能还有 Http2StreamFrameToHttpObjectCodec,出问题时先要判断「消息在哪一层变成了什么类型」。13 章的泄漏检测、JFR 和连接诊断就是为这个代价准备的。
另一个代价是验收成本变高。v1 的验收表 14 行,v2 多了连接池、引用计数、pipeline 顺序、原生传输四类失败模式,同样的功能要更多证据。这不是 Netty 的缺陷,是抽象换来的能力必须用新的方式验证。
复盘模板:从现象回到章节
复盘固定七段,最后一段「验收缺口」是 v2 新增的:不只问「怎么修」,还要问「哪条验收项本该拦住它」。
# <一句话标题>
## 影响
受影响接口、时间段、错误率、用户可见现象。
## 时间线
UTC+8 时间 | 事件 | 证据来源(日志/指标/命令输出)
## 根因
直接原因、触发条件、为什么现有测试没发现。
## 处置
止血动作、恢复时间、是否回滚。
## 改进项
动作 | 负责人 | 截止日期 | 对应章节
## 验收缺口
本应覆盖的检查项 | 为什么没覆盖 | 补进哪张表
## 回归证据
修复后重跑的命令 | 结果 | 证据存放位置
填「验收缺口」时不要写「测试不足」这种结论,要指到具体一行。例如「慢客户端注入只测了 10 秒,没测 100 次循环后的 fd 增长,补进 15.3 故障注入表」,这样的改进项才能在一个迭代内验收。
常见错误与边界
错误一:把 v1 的验收结果直接复制到 v2。 协议名一样,实现和失败模式全变了;v2 的每条证据都要在 Netty 版本上重新采集。
错误二:只跑 /healthz。 健康检查只证明 accept 和一条最短路径活着;代理、WebSocket、9093、429、熔断都可能坏着。清单要覆盖每条协议入口。
错误三:泄漏测试用默认级别。 SIMPLE 抓不到大多数泄漏,必须用 PARANOID 跑短时间、固定请求数,再回到默认级别。长期跑 PARANOID 会让压测数字失真。
错误四:用 close() 测半关闭。 close() 同时关读写,客户端读不到响应;要用 shutdownOutput() 只关写方向。
错误五:把 429 算成可用性失败。 限流命中是保护生效,监控要单列 ratelimit_rejected_total;把 429 混进 5xx 会让人误判下游故障。
边界:验收通过不等于没有 bug。 它只证明「在给定命令、环境和阈值下,这些路径符合预期」;清单之外的行为仍然未知,发布说明要把未覆盖项写出来,比如 h2c 只做 prior knowledge、不做 ALPN。
边界:本节的表是模板。 没有执行的项写 SKIP,并注明原因和补测日期;把 SKIP 算成 PASS,整套验收就失去意义。
小结
- v2 的验收要重新采集证据,不能复制 v1 的 PASS;每项写成「检查项 + 预期 + 证据」。
- 功能清单覆盖健康检查、代理、404、路径穿越、查询串、keep-alive、h2c、WebSocket、9093、502/503、429。
- 故障注入至少五项:杀
libraryd、慢客户端、半关闭、ByteBuf泄漏、EventLoop 阻塞。 - 优雅关闭验证三件事:停止 accept、排空在途请求、10 秒内退出并释放端口。
- 指标和日志要能回答「哪条路由、哪个上游、哪一层拒绝、耗时多少」,且不泄漏敏感信息。
- 复盘的「验收缺口」段要指到具体检查项,改进项才有可验收的落点。
阅读导航:上一节:15.2 限流、超时与降级 · 下一节:目录与学习路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。