本节目标:把「联调通过了」变成一份别人能照着复跑、能签字的验收记录,失败时能定位到具体章节。
原则:每条检查项都有命令或断言、有预期、有证据;未执行的写SKIP并注明原因,不能算PASS。
承接的问题
15.1 定义了链路和边界,但边界写得再清楚,也需要证据证明它真的生效。常见的「验收」是这样的:
- 启动服务,
curl /healthz返回 200,宣布联调完成。 - 手工点几个页面,看到数据,宣布功能正常。
- 压测跑出 200 rps,没报错,宣布容量达标。
这三条都只覆盖了最短路径。真正会出问题的是:deadline 没有传递、限流没有接上、熔断打开后错误码不对、TLS 证书过期、指标维度缺失、回滚脚本跑不通。这些问题在「点几个页面」时都不会暴露。
本节按七组给出可执行清单。所有命令都是执行模板,不是本机输出;执行人填 PASS / FAIL / SKIP 和证据位置。
第一组:编译、单测与静态检查
| 检查项 | 命令 | 预期 | 证据 |
|---|---|---|---|
| JDK 版本 | java -version | 25.0.5,Temurin | 版本输出 |
| 编译 | mvn -q -DskipTests package | 退出码 0,四个模块产物齐全 | 构建日志 |
| 单测 | mvn -q test | 全部通过,无跳过 | surefire 报告 |
| 依赖检查 | mvn -q dependency:tree -Dincludes=io.grpc:grpc-netty | 只有 shaded 版本,无 grpc-netty 混入 | 依赖树 |
| 禁用 import | grep -rnE '^import +io[.]netty[.]' comm-*/src/main/java | 无匹配 | grep 输出 |
依赖检查这条经常被忽略。grpc-netty-shaded 和 grpc-netty 同时出现在 classpath 上,编译能过,运行期可能出现类冲突或 NoSuchMethodError。依赖树是唯一可靠的确认方式。
java -version 之前先 export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home,否则默认 java 可能指向 JDK 8,mvn 会用错误的编译器。
第二组:契约测试
契约测试验的是「服务端返回的东西,调用方能不能正确理解」,不是「方法有没有被调用」。至少要覆盖:正常返回、字段完整性、错误状态码、错误原因码、metadata 回显。
下面是一个可运行的 JUnit 5 测试骨架,启动真实 gRPC 服务并走网络:
package com.plume.linkd.v3.contract;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.server.BookRepository;
import com.plume.linkd.v3.server.LibraryService;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LibraryContractTest {
private Server server;
private ManagedChannel channel;
private LibraryGrpc.LibraryBlockingStub stub;
@BeforeEach
void setUp() throws IOException {
server = ServerBuilder.forPort(0)
.addService(new LibraryService(new FakeBookRepository()))
.build()
.start();
channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.getPort())
.usePlaintext()
.build();
stub = LibraryGrpc.newBlockingStub(channel);
}
@AfterEach
void tearDown() throws InterruptedException {
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
}
@Test
void getBookReturnsAllContractFields() {
Book book = stub.getBook(
GetBookRequest.newBuilder().setIsbn("978-7-111-00001-1").build());
assertEquals("978-7-111-00001-1", book.getIsbn());
assertEquals("通信工程", book.getTitle());
assertEquals("Leeting", book.getAuthor());
assertTrue(book.getStock() >= 0);
}
@Test
void expiredDeadlineFailsFast() {
StatusRuntimeException error = assertThrows(StatusRuntimeException.class, () ->
stub.withDeadlineAfter(1, TimeUnit.NANOSECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn("missing").build()));
assertEquals(Status.Code.DEADLINE_EXCEEDED, error.getStatus().getCode());
}
@Test
void unknownIsbnMapsToNotFound() {
StatusRuntimeException error = assertThrows(StatusRuntimeException.class, () ->
stub.getBook(GetBookRequest.newBuilder().setIsbn("unknown").build()));
assertEquals(Status.Code.NOT_FOUND, error.getStatus().getCode());
}
}
final class FakeBookRepository implements BookRepository {
@Override
public CompletableFuture<Book> find(String isbn, long budgetMs) {
if ("unknown".equals(isbn)) {
return CompletableFuture.failedFuture(
Status.NOT_FOUND.withDescription("unknown isbn").asRuntimeException());
}
return CompletableFuture.completedFuture(Book.newBuilder()
.setIsbn(isbn)
.setTitle("通信工程")
.setAuthor("Leeting")
.setStock(3)
.build());
}
}
FakeBookRepository 是测试替身,按 isbn 返回固定数据或 NOT_FOUND。测试走真实端口而不是进程内传输,是为了把序列化、HTTP/2 帧、deadline 传播都覆盖进去;代价是启动稍慢,适合放在集成测试阶段。
契约测试还要断言错误详情里的原因码。完整实现用 StatusProto.fromThrowable(error) 取 com.google.rpc.Status,再解析 ErrorInfo.reason,与 15.1 的映射表逐项比对。只断言 NOT_FOUND 不够,因为 NOT_FOUND 可能来自「书不存在」,也可能来自「路由写错」。
第三组:连通性、超时与错误映射
| 检查项 | 命令或断言 | 预期 | 证据 |
|---|---|---|---|
| 网关健康 | curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:9094/healthz | 200 | 状态码 |
| gRPC 端口 | lsof -nP -iTCP:9093 -sTCP:LISTEN | 有 LISTEN 行 | 输出 |
| 正常查询 | curl -sS http://127.0.0.1:9094/books/978-7-111-00001-1 | 200,字段完整 | 响应体 |
| 未知图书 | curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:9094/books/unknown | 404,原因码 BOOK_NOT_FOUND | 状态码、响应体 |
| 未知路由 | curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:9094/nope | 404,不是 500 | 状态码 |
| 总超时 | 注入 700 ms 延迟后请求 | 504,原因码 DEADLINE_EXCEEDED,耗时 < 1 s | 状态码、time |
| 单跳超时 | 停掉 libraryd 后请求 | 503,原因码 UPSTREAM_UNAVAILABLE | 状态码、响应体 |
| metadata 回显 | curl -H 'x-request-id: req-123' -D - ... | 响应头带 x-request-id: req-123 | 响应头 |
超时这条要用 time 或 curl -w '%{time_total}' 记录实际耗时。只看返回 504 不够:如果耗时 5 秒才返回,说明超时没有生效,可能是某一层用了固定 5 秒。
错误映射要连原因码一起验。第 9.3 节定义了映射表,15.1 给出了链路级规则;验收时逐条对照,不能只看 HTTP 状态码。
第四组:限流、熔断与隔离
| 检查项 | 命令 | 预期 | 证据 |
|---|---|---|---|
| 限流触发 | ab -k -n 3000 -c 100 http://127.0.0.1:9094/books/978-7-111-00001-1 | 出现 429,带 Retry-After | ab 报告、响应头 |
| 限流恢复 | 停止压测 5 秒后再请求 | 200,令牌桶回填 | 状态码序列 |
| 熔断打开 | 停 libraryd,连续请求 20 次以上 | 先 503,达到阈值后熔断打开,后续快速失败 | 状态码序列、熔断指标 |
| 熔断半开 | 等 5 秒后发 3 个请求 | 3 个探针,成功则关闭,失败则重新打开 | 熔断指标 |
| 并发隔离 | 注入慢请求占满 64 并发 | 新请求排队或 429,健康检查仍 200 | 队列指标、健康检查 |
| 队列上限 | 慢请求持续超过 128 队列 | 拒绝而不是无限堆积 | 拒绝计数 |
限流测试要注意租户维度。如果限流按租户配置,压测用的 tenant-loadtest 要有独立配额,否则测出来的是其他租户的余量。
熔断测试要记录状态迁移时间点,而不是只看最终状态。breaker_state 从 CLOSED 到 OPEN 到 HALF_OPEN 再到 CLOSED,每一步都要有指标或日志。只看到「后来恢复了」,无法判断半开探针是否按 3 个执行。
第五组:TLS 与证书
| 检查项 | 命令 | 预期 | 证据 |
|---|---|---|---|
| 服务端证书 | openssl s_client -connect 127.0.0.1:9093 -alpn h2 -servername localhost | 握手成功,ALPN 协商为 h2 | 输出 |
| 证书有效期 | openssl x509 -in server.crt -noout -enddate | 未过期,剩余 > 30 天 | 日期 |
| 证书链 | openssl verify -CAfile ca.crt server.crt | OK | 输出 |
| 私钥匹配 | 比较证书与私钥的公钥指纹 | 一致 | 指纹 |
| 客户端信任 | 用错误 CA 连接 | 握手失败,不是明文降级 | 异常 |
| 主机名校验 | 用错误 servername 连接 | 校验失败 | 异常 |
最后两条是负向测试,很多验收只做正向握手。没有负向测试,就无法证明「证书错误时连接会被拒绝」,而不是悄悄降级。
证书过期是典型的「配置故障」:代码没变、流量没变,到某个日期突然全线失败。15.3 的运行手册会给出到期检查和轮换流程;验收阶段至少要确认告警已经配置。
第六组:指标、日志与追踪
| 检查项 | 命令 | 预期 | 证据 |
|---|---|---|---|
| 指标端点 | curl -fsS http://127.0.0.1:9095/metrics | 返回 Prometheus 文本 | 抓取片段 |
| 请求计数 | 发 10 个请求后查 http_requests_total | 增加 10,维度含 route/status | 指标差值 |
| 延迟直方图 | 查 rpc_duration_seconds_bucket | 有 le 桶,能算 p95 | 指标片段 |
| 限流指标 | 触发 429 后查 ratelimit_rejected_total | 增加,scope 正确 | 指标差值 |
| 熔断指标 | 停 libraryd 后查 breaker_state | 从 CLOSED 变 OPEN | 指标序列 |
| 结构化日志 | grep 'req-123' app.log | 一行包含 requestId、route、status、duration | 日志行 |
| traceparent | 请求带 traceparent,查日志 | 同一 traceId 贯穿网关与服务端 | 两条日志 |
| 敏感信息 | grep -i 'authorization|cookie' app.log | 无明文令牌 | grep 输出 |
指标验收要看「维度是否足够定位」。只有 http_requests_total 一个总数,出问题时无法区分是哪条路由、哪个上游。至少要有 route、status、upstream 三个维度。
追踪验收不要求接入完整后端,但要求 traceparent 能解析、能透传、能写进日志。11.3 讲过采样与 exemplar;验收阶段先保证「同一条请求在两个服务里的 traceId 一致」。
第七组:灰度与回滚
| 检查项 | 命令 | 预期 | 证据 |
|---|---|---|---|
| 灰度路由 | curl -H 'x-canary: v2' -D - ... | 命中 v2,响应头带 x-served-by: v2 | 响应头 |
| 默认路由 | 不带 x-canary 请求 | 命中 v1 | 响应头 |
| 比例切流 | 发 100 个带随机 canary 的请求 | 比例接近配置值 | 计数 |
| 回滚 | 执行回滚脚本,再请求 | 全部命中 v1,无 5xx | 状态码序列 |
| 回滚耗时 | time ./rollback.sh | < 60 s | 命令输出 |
| 数据兼容 | v2 写入后由 v1 读取 | 字段兼容,不报错 | 响应体 |
回滚脚本必须真的执行一次,不能只检查文件存在。执行后要验证三件事:流量切回、旧版本健康、没有残留的新版本连接。第 12.3 节讲过契约测试与回滚,这里是链路级的复验。
故障注入:至少五条
验收不注入故障,只能证明「正常路径能跑」。下面五条覆盖依赖、延迟、过载、连接和证书:
| # | 注入 | 命令或开关 | 预期 |
|---|---|---|---|
| 1 | libraryd 退出 | kill -TERM "$(pgrep -f 'libraryd.*jar')" | 503 + UPSTREAM_UNAVAILABLE,9095 健康 |
| 2 | libraryd 变慢 | x-fault: delay:500,测试租户 100% | 504 + DEADLINE_EXCEEDED,耗时 < 1 s |
| 3 | 服务端过载 | x-fault: abort:RESOURCE_EXHAUSTED | 429 + RATE_LIMITED + Retry-After |
| 4 | 连接半开 | 13.2 的 HalfOpenProbe,shutdownOutput() | 连接在超时后被回收,fd 不增长 |
| 5 | 证书过期 | 测试环境换一张已过期证书 | 握手失败,进程不就绪,告警触发 |
第 2、3 条用 13.2 的 FaultInjectingServerInterceptor,只对 tenant-drill 生效,比例 100%。演练结束立即关闭 -Dlinkd.fault.enabled,并在审计日志里确认没有残留命中。
故障注入的测试代码可以复用 13.2 的拦截器:
package com.plume.linkd.v3.contract;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.chaos.FaultInjectingServerInterceptor;
import com.plume.linkd.v3.server.LibraryService;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class FaultAcceptanceTest {
@Test
void injectedUnavailableIsMappedTo503() throws Exception {
Server server = ServerBuilder.forPort(0)
.addService(new LibraryService(new FakeBookRepository()))
.intercept(new FaultInjectingServerInterceptor(true, 1L))
.build()
.start();
ManagedChannel channel = ManagedChannelBuilder
.forAddress("127.0.0.1", server.getPort())
.usePlaintext()
.build();
try {
LibraryGrpc.LibraryBlockingStub stub = LibraryGrpc.newBlockingStub(channel)
.withInterceptors(new DrillHeaders("abort:UNAVAILABLE"));
StatusRuntimeException error = assertThrows(StatusRuntimeException.class, () ->
stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn("x").build()));
assertEquals(Status.Code.UNAVAILABLE, error.getStatus().getCode());
} finally {
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
}
}
private static final class DrillHeaders implements ClientInterceptor {
private final String spec;
private DrillHeaders(String spec) {
this.spec = spec;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel next) {
Metadata.Key<String> tenant =
Metadata.Key.of("x-tenant-id", Metadata.ASCII_STRING_MARSHALLER);
Metadata.Key<String> fault =
Metadata.Key.of("x-fault", Metadata.ASCII_STRING_MARSHALLER);
Metadata.Key<String> ratio =
Metadata.Key.of("x-fault-ratio", Metadata.ASCII_STRING_MARSHALLER);
return new ForwardingClientCall.SimpleForwardingClientCall<>(
next.newCall(method, options)) {
@Override
public void start(Listener<RespT> listener, Metadata headers) {
headers.put(tenant, "tenant-drill");
headers.put(fault, spec);
headers.put(ratio, "100");
super.start(listener, headers);
}
};
}
}
}
这个测试只验证「注入生效且状态码正确」,不验证网关映射到 503 的那一步。完整验收要在网关层再发一次 HTTP 请求,断言 503 和 reason。单元测试和链路测试各管一段,不能互相替代。
常见错误与边界
错误一:把 SKIP 当 PASS。 没执行就写 SKIP,注明原因和补测日期;把未覆盖项算成通过,整份验收失去意义。
错误二:只测正向路径。 TLS 握手成功不代表错误证书会被拒绝;正常查询成功不代表未知图书返回 404。负向断言和正向断言一样重要。
错误三:超时只看状态码。 返回 504 但耗时 5 秒,说明超时没有在该生效的层生效。必须同时记录耗时。
错误四:限流和熔断混在一起测。 两者触发的条件不同,混测时无法判断 429 是限流还是熔断后的拒绝。分开注入、分开看指标。
错误五:回滚脚本没跑过。 文件存在不等于能执行。回滚要真的切一次流量,并确认旧版本健康、无残留连接。
错误六:故障注入忘记关。 验收结束检查 linkd.fault.enabled、审计日志和 x-fault 命中数,确认没有残留。
边界:验收通过不等于没有 bug。 它只证明「在给定命令、环境和阈值下,这些路径符合预期」。未覆盖的路径、未注入的故障、未压到的流量,仍然未知。
边界:libraryd 是外部服务。 它的版本、数据、超时行为不由本书控制;验收记录要写清 libraryd 的版本和部署形态,否则结果无法复现。
小结
- 验收分七组:编译单测、契约、连通与超时、限流熔断、TLS、指标日志追踪、灰度回滚。
- 每条检查项都要有命令或断言、预期和证据;未执行写
SKIP,不算通过。 - 契约测试走真实端口,覆盖正常返回、字段完整性、deadline 和错误状态码。
- 超时验收要记录耗时,不能只看 504;错误验收要连原因码一起看。
- 故障注入至少五条:依赖退出、延迟、过载、半开连接、证书过期。
- 回滚脚本必须真的执行一次,验证流量切回、旧版本健康和残留连接。
阅读导航:上一节 15.1 链路架构与边界 | 下一节 15.3 运行手册与复盘
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。