本节目标:把「功能都写完了」变成一份可执行、可复现、能签字的 v1 验收记录,并知道出问题后怎么复盘到具体章节。
适用版本:JDK 25(Temurin 25.0.5+8);java.net.http.HttpClient、java.net.Socket。
承接的问题
前 14 章分别交付了 Socket、Buffer、Selector、HTTP/1.1、HTTP/2 教学帧、TLS、WebSocket、Reactor、零拷贝、背压与优雅关闭。到 v1 收口时,最容易出现三种「假验收」:
- 只跑
GET /healthz,返回 200 就签字。未知路径、错误方法、超大头部、半包请求全没测。 - 只在自己的笔记本上跑,换一台机器、换一个 JDK 就复现不了;没有命令、没有版本、没有时长。
- 把「没测」写成「通过」。TLS 没配证书、WebSocket 没写客户端,清单上却全是勾。
验收的目标不是证明「没有 bug」,而是给出边界清晰的可信声明:哪些能力通过了什么判据,哪些没覆盖、为什么、谁在什么时候补。复盘则是反向过程:从现象回到章节,找到哪条契约被违反。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。下面的自动检查对 15.1 的替身服务执行,不是 linkd 本体;替身只实现了 /healthz、/books/{id} 和 404,所以 HTTP/2、WebSocket、TLS 三项是 SKIP,这正好用来演示「未覆盖」和「通过」的区别。
验收判据:命令、阈值、证据
一条合格的验收项必须能写成三元组:命令(别人能原样执行)、阈值(通过/失败的分界)、证据(输出留在哪里)。缺任何一项,它就只是意见。
| 验收项 | 命令 | 通过判据 | 证据 |
|---|---|---|---|
| 健康检查 | curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9090/healthz | 200,正文 ok | 终端输出存档 |
| 未知路径 | curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9090/nope | 404,不是 500 或挂起 | 状态码 |
| keep-alive | 同一 socket 连发两次 GET /healthz | 两次都 200,连接未断 | 客户端日志 |
| h2c preface | 发送 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n | 首帧为 SETTINGS(type 0x4) | 前 9 字节十六进制 |
| WebSocket | GET /ws/echo 带 Upgrade: websocket | 101 且 Sec-WebSocket-Accept 正确 | 响应头 |
| 优雅关闭 | kill -INT <pid> | 停止 accept,在途请求完成,10 秒内退出 | 退出码与日志 |
| 吞吐 | ab -k -n 10000 -c 50 .../healthz | ≥ 8000 req/s,p99 ≤ 50ms,失败 0 | ab 报告 |
| 连接诊断 | netstat -anv -p tcp 过滤端口 | CLOSE_WAIT 不随请求数增长 | 5 分钟计数 |
阈值不是拍脑袋:8000 req/s 和 50ms 是 15.2 在本机三次运行里都能达到的值,留了约 10% 余量。换机器必须重测,不能把 M1 的数字抄到 CI 容器里。
清单:逐条对应前 14 章
下面这张表是 v1 的验收主表。每一行都指回一章,避免「验收清单」和「代码实现」各说各话。
| 章 | 能力 | 验收项 | 方式 | 判据 |
|---|---|---|---|---|
| 1 | 请求路径 | 一次请求经过 accept、read、parse、write | 手动 | tcpdump 看到 SYN、SYN-ACK、PSH、FIN |
| 2 | TCP 语义 | 半关闭与 RST | 故障注入 | RST 后连接进 CLOSED,无残留 CLOSE_WAIT |
| 3 | Socket 超时 | libraryd 卡住时请求在预算内失败 | 故障注入 | 2 秒内返回 504,不无限等待 |
| 4 | 编解码 | 非法长度域、超大帧 | 手动 | 返回 400 并关闭,堆内存不持续上涨 |
| 5 | Buffer | 大响应后 DirectBuffer 释放 | 手动 | NMT committed 回落到基线附近 |
| 6 | Selector | 1000 条空闲连接 | 压测 | CPU 空闲,无 select 空转 |
| 7 | 非阻塞与半包 | 请求按 1 字节切分发送 | 手动 | 仍能拼出完整请求并返回 200 |
| 8 | HTTP/1.1 | 健康检查、404、keep-alive | 自动 | H1–H3 全 PASS |
| 9 | HTTP/2 | h2c preface 与 SETTINGS | 自动 | 首帧 type 0x4,长度大于 0 |
| 10 | TLS | 握手、证书校验、ALPN | 手动 | TLS 1.3 握手成功,错误证书被拒 |
| 11 | WebSocket | Upgrade 与文本回显 | 自动/手动 | 101 且 accept 正确,回显一致 |
| 12 | Reactor | 业务阻塞不占用事件循环 | 故障注入 | 慢 handler 期间其他连接仍可读 |
| 13 | 零拷贝 | transferTo 传大文件 | 手动 | 下载字节一致,NMT 稳定 |
| 14 | 背压与关闭 | 不读的客户端、SIGINT 排空 | 故障注入 | 不 OOM;退出前写完在途响应 |
14 行里只有 3 行能完全自动化(8、9、11 的部分),其余需要故障注入或人工观察。这不是清单的缺陷,而是网络服务的常态:能自动化的自动化,不能自动化的写清楚谁在什么时候手动做。
自动化:AcceptanceChecklist
自动部分做成一个可执行类,失败时以非 0 退出,方便接进 CI。核心是三项 HTTP 检查,加上把未覆盖项显式标成 SKIP:
package com.plume.linkd.app;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class AcceptanceChecklist {
public enum Status { PASS, FAIL, SKIP }
public record Item(String id, String chapter, String name, Status status, String detail) { }
public static void main(String[] args) throws Exception {
List<Item> items = run(args.length > 0 ? args[0] : "http://127.0.0.1:9090");
System.out.printf("%-4s %-8s %-30s %-6s %s%n",
"ID", "CHAPTER", "CHECK", "STATUS", "DETAIL");
for (Item item : items) {
System.out.printf("%-4s %-8s %-30s %-6s %s%n",
item.id(), item.chapter(), item.name(), item.status(), item.detail());
}
long failed = items.stream().filter(i -> i.status() == Status.FAIL).count();
System.out.printf("total=%d pass=%d fail=%d skip=%d%n", items.size(),
items.stream().filter(i -> i.status() == Status.PASS).count(), failed,
items.stream().filter(i -> i.status() == Status.SKIP).count());
if (failed > 0) System.exit(1);
}
public static List<Item> run(String base) throws Exception {
List<Item> items = new ArrayList<>();
URI uri = URI.create(base);
int port = uri.getPort() > 0 ? uri.getPort() : 80;
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build();
items.add(expect(client, base + "/healthz", 200, "ok", "8.1", "H1"));
items.add(expect(client, base + "/no-such-path", 404, null, "8.3", "H2"));
items.add(keepAlive(uri.getHost(), port));
items.add(new Item("M1", "14.3", "SIGINT graceful drain", Status.SKIP,
"needs process control; see manual checklist"));
items.add(new Item("M2", "10.1", "TLS handshake and ALPN", Status.SKIP,
"needs keystore; manual keytool flow"));
return items;
}
private static Item expect(HttpClient client, String url, int status,
String body, String chapter, String id) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(3)).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
boolean ok = response.statusCode() == status
&& (body == null || body.equals(response.body()));
return new Item(id, chapter, "GET " + URI.create(url).getPath(),
ok ? Status.PASS : Status.FAIL,
"status=" + response.statusCode() + " body=" + response.body());
}
private static Item keepAlive(String host, int port) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 2000);
socket.setSoTimeout(3000);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream();
int first = exchange(out, in, host, port);
int second = exchange(out, in, host, port);
boolean ok = first == 200 && second == 200;
return new Item("H3", "8.2", "two requests, one connection",
ok ? Status.PASS : Status.FAIL, "first=" + first + " second=" + second);
} catch (IOException e) {
return new Item("H3", "8.2", "two requests, one connection",
Status.FAIL, e.toString());
}
}
private static int exchange(OutputStream out, InputStream in, String host, int port)
throws IOException {
String request = "GET /healthz HTTP/1.1\r\nHost: " + host + ":" + port
+ "\r\nConnection: keep-alive\r\n\r\n";
out.write(request.getBytes(StandardCharsets.US_ASCII));
out.flush();
ByteArrayOutputStream head = new ByteArrayOutputStream();
int last4 = 0;
while (true) {
int b = in.read();
if (b == -1) throw new IOException("EOF before headers");
head.write(b);
last4 = (last4 << 8) | b;
if (last4 == 0x0D0A0D0A) break;
}
String[] lines = head.toString(StandardCharsets.ISO_8859_1).split("\r\n");
int status = Integer.parseInt(lines[0].split(" ")[1]);
Map<String, String> headers = new LinkedHashMap<>();
for (int i = 1; i < lines.length; i++) {
int colon = lines[i].indexOf(':');
if (colon > 0) {
headers.put(lines[i].substring(0, colon).toLowerCase(),
lines[i].substring(colon + 1).trim());
}
}
in.readNBytes(Integer.parseInt(headers.getOrDefault("content-length", "0")));
return status;
}
}
对替身服务运行:
java -cp out com.plume.linkd.app.AcceptanceChecklist http://127.0.0.1:9091
本机输出:
ID CHAPTER CHECK STATUS DETAIL
H1 8.1 GET /healthz PASS status=200 body=ok
H2 8.3 GET /no-such-path PASS status=404 body=not found
H3 8.2 two requests, one connection PASS first=200 second=200
M1 14.3 SIGINT graceful drain SKIP needs process control; see manual checklist
M2 10.1 TLS handshake and ALPN SKIP needs keystore; manual keytool flow
total=5 pass=3 fail=0 skip=2
四点说明:
SKIP不是PASS。 退出码只看FAIL,但发布记录必须把SKIP单列,并写负责人和补测日期。把SKIP算进通过率,就是假验收。- keep-alive 必须用裸
Socket验。HttpClient会自己管理连接池,两次send未必落在同一条连接;只有自己发两次、读两次,才能证明服务端没有在第一次响应后关闭。 readResponse只处理Content-Length。 遇到 chunked 响应会读错;v1 的/healthz和 404 都有长度,够用。验收 chunked 要另写一项,对应 8.2。- 上面的骨架只含前三项。 配套代码里的完整类另外包含 h2c preface 与 WebSocket 握手:h2c 看首帧 type 是否为
0x4,WebSocket 看101与Sec-WebSocket-Accept的 SHA-1 结果;替身不实现这两个协议,完整类对它运行会多出两行SKIP。
故障注入:主动把服务弄坏
只测正常路径的验收没有价值。下面六项按「注入 → 预期 → 证据」写,前四项能在本机用 JDK 和 shell 完成,后两项需要证书与 HTTP/2 客户端。命令是给 linkd 本体用的,本机没有可运行的 linkd 发行包,因此没有执行输出。
| 注入 | 命令 | 预期 | 证据 |
|---|---|---|---|
| 下游卡死 | kill -STOP <libraryd-pid>,30 秒后 kill -CONT | /books/{id} 在 2 秒内返回 504,线程不永久阻塞 | 响应时间、jcmd Thread.print |
| 客户端 RST | socket.setSoLinger(true, 0); socket.close(); | 服务端 onClosed 恰好一次,无 CLOSE_WAIT 残留 | 应用日志、netstat |
| 不读的客户端 | 连上后写 200KB 不再读 | 写水位触发背压,Send-Q 增长但进程不 OOM | netstat -anv、堆监控 |
| 优雅关闭 | 在途请求未完成时 kill -INT <linkd-pid> | 停止 accept,排空写队列,10 秒内退出 | 退出码、客户端是否收全响应 |
| 错误证书 | 用 keytool 生成 CN 不匹配的证书 | SSLHandshakeException,不回退明文 | 客户端异常栈 |
| 流控归零 | h2c 客户端把窗口设为 0 | 服务端停止发送,不空转、不丢帧 | 抓包、CPU |
第一项最容易做,也最容易暴露问题:kill -STOP 让 libraryd 完全停止响应,但 TCP 连接还在,linkd 的读会一直等。如果 14.2 的预算没有贯穿到代理调用,请求会挂几十秒;如果预算生效,2 秒内应该看到 504,并且 jcmd Thread.print 里没有长期停在 read0 的线程。
第二项验证 3.3 与 7.3 的关闭契约。setSoLinger(true, 0) 让 close() 直接发 RST,服务端 read 会抛 SocketException: Connection reset。正确的 Handler.onClosed 只应被调用一次,并且状态机走到 CLOSED;如果日志里出现两次 onClosed,或者 netstat 里留下 CLOSE_WAIT,说明关闭路径不幂等或漏了 close()。
第三项验证 14.1 的水位。客户端不读,服务端的 Send-Q 会持续增长;水位机制应该在超过高水位时停止入队或降低读取速度,而不是无限堆积 ByteBuffer。证据是 netstat 的 Send-Q 和应用日志里的水位事件同时出现。
复盘模板:从现象回到章节
复盘不是写检讨,是把「哪条契约被违反」定位出来。模板固定六段,缺一段就不算完成:
# <一句话标题>
## 影响
受影响接口、时间段、错误率、用户可见现象。
## 时间线
UTC+8 时间 | 事件 | 证据来源
## 根因
直接原因、触发条件、为什么现有测试没发现。
## 处置
止血动作、恢复时间、是否回滚。
## 改进项
动作 | 负责人 | 截止日期 | 对应章节
一个填好的示例(虚构场景,仅演示格式,不是本机事故):
# linkd /books 延迟升高与 fd 耗尽
## 影响
21:40–22:05,/books/{id} p99 从 30ms 升到 8s,错误率 12%;
fd 从 800 涨到 6100,22:03 起新连接被拒。
## 时间线
21:40 | 监控告警 p99 > 1s | 指标面板
21:44 | netstat 显示 4200 条 CLOSE_WAIT | 现场快照
21:52 | jcmd 显示 8 个线程停在 read0 | 线程转储
22:01 | 确认 libraryd 在 21:38 重启过 | 发布记录
22:05 | 回滚 linkd 到上一版,fd 回落 | 部署系统
## 根因
onReadable 收到 -1 后只 return,没有调用 Connection.close();
对端重启时大量连接同时半关闭,socket 停在 CLOSE_WAIT。
现有验收只测 /healthz,没有注入对端重启。
## 处置
22:05 回滚;22:20 补丁在 EOF 分支调用 close();22:40 灰度 10%。
## 改进项
EOF 分支补 close() | 张 | 10-30 | 7.3、14.3
CLOSE_WAIT > 100 持续 1 分钟告警 | 李 | 10-31 | 15.1
验收加入对端重启注入 | 王 | 11-02 | 15.3
这个例子里,现象(p99 升高、fd 耗尽)和根因(-1 分支没关闭)之间隔着三层:netstat 的 CLOSE_WAIT、线程栈的 read0、发布记录的时间点。复盘的价值就是把这三层证据按时间排好,而不是停在「对端重启导致」这种无法行动的结论上。
v1 验收记录与发布判据
把上面的结果写成一张可签字的表:
| 项 | 结果 | 备注 |
|---|---|---|
| 自动检查 H1–H3 | PASS | 替身服务上验证;linkd 本体需重跑 |
| 自动检查 H4(h2c)、H5(WebSocket) | SKIP | 替身不实现;linkd 上必须 PASS |
| 手动 TLS | SKIP | 需要 keytool 证书,v1 用自签 |
| 故障注入六项 | 待执行 | 每项留命令输出与时间 |
| 30 分钟稳定性 | 待执行 | 观察 fd、CLOSE_WAIT、DirectBuffer |
| 压测门槛 | PASS(替身) | 8000 req/s、p99 ≤ 50ms;linkd 重测 |
发布判据建议写成四条,全部满足才允许 v1 打 tag:
- 自动检查 0
FAIL;SKIP项有负责人和补测日期。 - 六项故障注入全部执行,且结果与预期一致;不一致的项要么修代码,要么改预期并在记录里说明。
- 30 分钟稳定压测后,
CLOSE_WAIT数量不随请求数单调增长,DirectBuffercommitted回落到基线附近。 - 已知限制写进发布说明:HTTP/2 是教学实现、只支持 h2c prior knowledge;TLS 不把 ALPN 与 h2c 合并;没有生产级证书管理。
常见错误与边界
错误一:把 SKIP 算成 PASS。 五项检查里两项没跑,通过率写成 100%。正确写法是 pass=3 fail=0 skip=2,并单列补测计划。
错误二:只验健康检查。 /healthz 返回 200 只证明 accept 和最简单的一条路由活着;keep-alive、404、半包、WebSocket 都可能坏着。清单要覆盖每条协议路径。
错误三:没有稳定性测试。 一次 3000 请求的压测跑 0.3 秒,发现不了 fd 泄漏、DirectBuffer 增长、TIME_WAIT 堆积。至少跑 30 分钟,观察趋势而不是单点。
错误四:验收环境与发布环境不一致。 在 M1 笔记本上测 8000 req/s,直接写进容器发布的容量承诺。CPU 核数、内核参数、TLS、网络 RTT 都不同,必须在目标环境重测。
错误五:复盘没有时间线。 只有「根因:对端重启」的复盘无法验证,也无法判断影响范围。时间线要带证据来源,每个时间点能追到一条命令输出或一条日志。
错误六:改进项没有负责人和日期。 「加强监控」「补充测试」不是改进项,是愿望。每条改进项要能在一个迭代内验收,并指回具体章节。
边界:验收通过不等于没有 bug。 它只证明「在给定命令、给定环境、给定阈值下,这些路径符合预期」。清单之外的行为仍然未知,发布说明要把未知写出来。
边界:教学实现的能力边界要明说。 第 9 章的 HTTP/2 只做 h2c prior knowledge,不做 ALPN;第 10 章的 TLS 用自签证书。验收记录里必须把这两条列为已知限制,不能写成「支持 HTTP/2 与 TLS」。
小结
- 验收项必须写成「命令 + 阈值 + 证据」三元组,否则只是意见。
- 14 章对应 14 行验收项,只有 3 行能完全自动化,其余要故障注入或人工观察。
AcceptanceChecklist用PASS/FAIL/SKIP三态,SKIP不计入通过,FAIL以非 0 退出。- 故障注入至少覆盖下游卡死、RST、不读客户端、SIGINT、错误证书、流控归零六项。
- 复盘固定六段:影响、时间线、根因、处置、改进项,每条改进项带负责人、日期和章节。
- 发布判据是四条硬门槛;HTTP/2 教学实现与自签 TLS 必须写进已知限制。
到这里,第一卷的 50 篇收口。linkd v1 的能力边界、证据链和失败处理都已落地;卷二会用 Netty 4.2 重写同一套协议,把今天手写的 Selector、帧解析和关闭流程换成成熟框架的实现,再对照这里的验收表看哪些契约被保留、哪些被抽象掉了。
阅读导航:上一节:15.2 压测工具与指标口径 · 下一节:目录与学习路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。