《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘

验收不是再跑一遍健康检查。本节把 linkd-v2 的验收拆成功能与协议一致性清单、五类故障注入、优雅关闭、指标与日志四块,每项给出可复制命令、预期与证据;再对比卷一手写 linkd v1 与 Netty 版 linkd-v2 的差异和代价,并给出带「验收缺口」的复盘模板。

本节目标:把「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/healthz200,正文 ok状态码与正文
图书代理curl -sS -D - http://127.0.0.1:9090/books/7200,正文来自 libraryd,带 Via: 1.1 linkd-v2响应头与正文
未知路由curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/nope404,不是 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-alivecurl -v --http1.1 http://127.0.0.1:9090/healthz http://127.0.0.1:9090/healthz第二条复用连接,两条都 200curl -v 的 Re-using existing connection
h2ccurl --http2-prior-knowledge -sS -o /dev/null -w '%{http_version} %{http_code}\n' http://127.0.0.1:9091/healthz2 200,不是 HTTP/1.1 降级版本与状态码
WebSocketcurl -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/echo101 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-Afterab 报告、响应头
优雅关闭见下一节命令在途请求完成,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 引用计数漏释放是静默的
故障定位直接看 syscallhandler、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 限流、超时与降级 · 下一节:目录与学习路径 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.2 限流、超时与降级
  2. 《Netty 高性能网络编程》15.1 网关路由与转发
  3. 《Netty 高性能网络编程》14.3 调优案例与回归