一条请求跑通,离可运营还有多远
卷一《Java 网络编程基础》用 JDK 原生 API 把一条连接从字节流写到 HTTP;卷二《Netty 高性能网络编程》把这套能力搬进 linkd-v2,处理了 Pipeline、引用计数、背压、原生传输和网关。到卷二结束,linkd-v2 已经能在 9093 上完成自定义帧的收发,也能把 /books/{id} 代理到上游。
这条链路是「能通」的。它离「可运营」还有一段距离,而且这段距离不是再加一个 handler 就能填上的:
- 客户端不知道一次调用最多等多久,服务端也不知道自己还剩多少时间,超时层层叠加,最后全部压在数据库连接池上。
- 上游返回一个
500,调用方看不出是「书不存在」「库存冲突」还是「下游挂了」,只能把整段响应体打进日志再人工判断。 - 超时之后自动重试,三层调用各重试两次,一次用户请求在链路上放大成二十七次下游请求。
- 同一个下单请求因为网络抖动被重发两次,库存扣了两次;补一条唯一索引才发现问题,但脏数据已经写进去了。
- 新版本给消息加了一个字段,旧客户端解析失败,发布不得不回滚;没有人能说清哪些改动是兼容的。
- 出故障时只有应用日志,没有请求 ID、没有耗时分布、没有下游状态,排查靠猜。
这些问题的共同点是:它们都发生在「服务与服务之间」,而不是「一条连接内部」。卷一和卷二解决的是后者,本卷解决前者。
把「能通」和「可运营」放在一起对比,差距会更具体:
| 维度 | 能通 | 可运营 |
|---|---|---|
| 接口 | 能序列化、能反序列化 | 字段有编号、有保留位、有兼容规则 |
| 错误 | 抛一个异常,带一段字符串 | 状态码 + 原因码 + 可重试标记 + 定位信息 |
| 时间 | 调用方自己 sleep 或无限等待 | 总预算、单跳预算、取消传播、剩余时间可见 |
| 失败 | 失败就重试,重试就成功 | 只重试可重试的错误,有退避、有抖动、有预算 |
| 重复 | 假定一次请求只到一次 | 幂等键 + 服务端去重 + 结果可重放 |
| 观测 | 一行文本日志 | 请求 ID、租户、耗时直方图、下游状态、追踪 |
| 变更 | 改完一起发 | 先兼容、再灰度、可回滚、有契约测试 |
| 容量 | 压测过一次 | 有容量模型、有瓶颈定位、有成本口径 |
这张表不是要求一次做完,而是给出顺序:第 1–3 章解决前三行,第 4–9 章解决中间三行,第 10–15 章解决最后两行。跳过前面的行直接做后面的,通常会在故障复盘时补回来。
卷三要回答的六个问题
本卷不重复网络原理,也不写框架入门。它围绕服务端通信工程里反复出现的六类决策展开:
| 问题 | 对应章节 | 交付物 |
|---|---|---|
| 接口应该长什么样,错误怎么表达 | 第 1 章 | .proto 契约、错误码表、兼容规则 |
| 数据在线上的字节是什么 | 第 2 章 | Protobuf 编码说明、JSON 取舍、演进矩阵 |
| 一次调用等多久,什么时候取消 | 第 3 章 | Deadline 预算、幂等键、重试策略 |
| 客户端怎么建、怎么复用、怎么发现 | 第 4–6 章 | Stub 生命周期、NameResolver、连接池 |
| 故障怎么被隔离和吸收 | 第 7–9 章 | 重试预算、熔断、Bulkhead、限流、网关 |
| 上线之后怎么看见、怎么演进 | 第 10–15 章 | TLS、指标、追踪、灰度、混沌、容量与验收 |
第 1–3 章是地基。它们看起来最「不工程」,但后面每一章都要引用这里的类型和默认值:没有统一的错误模型,熔断只能按 HTTP 状态码猜;没有 Deadline 预算,重试会把尾延迟放大;没有幂等键,重试就是数据风险。
主线:linkd-v3 调 libraryd
全书只有一条主线:linkd-v3 作为调用方,通过 gRPC 调 libraryd;对外再由 comm-gateway 暴露 HTTP。libraryd 是已有服务(HTTP 8080 + PostgreSQL 17),本卷不重写它,只在网关侧做协议转换。
linkd-v3 的 groupId 是 com.plume.linkd.v3,四个模块职责固定:
| 模块 | 职责 |
|---|---|
comm-api | .proto、生成的 stub、错误码常量 |
comm-core | 服务发现、负载均衡、重试、熔断、限流、指标、追踪 |
comm-server | gRPC 服务实现、拦截器、健康检查、反射 |
comm-gateway | HTTP/BFF 网关、协议转换、下游 libraryd 调用 |
端口也固定:libraryd 8080,linkd-v3 gRPC 9093,HTTP 网关 9094,指标与管理 9095。端口不是随便挑的,它决定了排障时先看哪条链路:9094 出问题先查网关,9093 出问题先查 gRPC 服务,8080 出问题才轮到既有服务。
共享类型在各章反复出现,签名在这里先定死,后文不再改:
RequestContext(String requestId, String tenantId, TraceContext trace, Deadline deadline)TraceContext(String traceId, String spanId, boolean sampled),含toTraceparent()与parse(String)RpcError(Status.Code code, String reason, String message, boolean retryable)RetryPolicy(int maxAttempts, Duration baseDelay, Duration maxDelay, double jitter)CircuitBreaker(int window, double failureRateThreshold, Duration openDuration)TokenBucket(double ratePerSecond, double burst)Bulkhead(int maxConcurrent, int queueCapacity)MetricsRegistry:counter(String, Map<String,String>)、histogram(...)、renderPrometheus()StaticRegistry:register(String service, String host, int port)、resolve(String service)LibraryClient:封装ManagedChannel与阻塞/异步 stub
一个最小可运行闭环
先不引入生成代码之外的东西,用 MethodDescriptor 手工描述一个方法,服务端用 ServerCalls.asyncUnaryCall 挂上实现,客户端用 ClientCalls.blockingUnaryCall 调用。这段代码的作用不是展示 gRPC 的全部能力,而是证明「契约、序列化、调用、状态」四个环节在一屏之内闭合。
package com.plume.linkd.v3.demo;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.MethodDescriptor;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.ServerServiceDefinition;
import io.grpc.protobuf.ProtoUtils;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.ServerCalls;
public final class MinimalRpc {
private static final MethodDescriptor<GetBookRequest, Book> GET_BOOK =
MethodDescriptor.<GetBookRequest, Book>newBuilder()
.setType(MethodDescriptor.MethodType.UNARY)
.setFullMethodName("plume.linkd.v3.Library/GetBook")
.setRequestMarshaller(ProtoUtils.marshaller(GetBookRequest.getDefaultInstance()))
.setResponseMarshaller(ProtoUtils.marshaller(Book.getDefaultInstance()))
.build();
public static void main(String[] args) throws Exception {
ServerServiceDefinition service = ServerServiceDefinition.builder("plume.linkd.v3.Library")
.addMethod(GET_BOOK, ServerCalls.asyncUnaryCall((request, observer) -> {
observer.onNext(Book.newBuilder()
.setIsbn(request.getIsbn())
.setTitle("book:" + request.getIsbn())
.setStock(1)
.build());
observer.onCompleted();
}))
.build();
Server server = ServerBuilder.forPort(0).addService(service).build().start();
ManagedChannel channel = ManagedChannelBuilder
.forAddress("127.0.0.1", server.getPort())
.usePlaintext()
.build();
try {
Book book = ClientCalls.blockingUnaryCall(
channel, GET_BOOK, io.grpc.CallOptions.DEFAULT,
GetBookRequest.newBuilder().setIsbn("42").build());
System.out.println("unary=" + book.getTitle());
} finally {
channel.shutdownNow();
server.shutdownNow();
}
}
}
ServerBuilder.forPort 与 ManagedChannelBuilder.forAddress 会按 classpath 选择传输实现。本机只引入 grpc-netty-shaded,实际选中的就是它内部打包的 Netty provider;换成其它传输实现时,业务代码不需要改。
本机验证时直接用了 shaded 的 NettyServerBuilder.forPort(0);上面的代码用 ServerBuilder,在只引入 shaded 的 classpath 上会选中同一个 provider,结果一致。本机(Temurin 25.0.5+8,grpc-netty-shaded 1.76.0)输出:
unary=book:42
同一进程还会打印一条 sun.misc.Unsafe::allocateMemory 的终结弃用警告。这不是示例写错了,而是 shaded Netty 依赖 JDK 内部内存访问方式;它会在未来某个 JDK 版本失效,属于依赖升级要盯的风险,不是业务代码能修的问题。
这条闭环能跑通,但它没有超时、没有重试、没有错误信息、没有观测。把 GetBook 换成真实的 libraryd 调用,上面每一项缺失都会在某个故障场景里变成事故。
三个默认值背后的算术
全书统一一组默认值,目的是让「超时、重试、限流」在章节之间可比较,而不是每节各调一套:
| 参数 | 默认值 | 为什么是这个数 |
|---|---|---|
| 客户端总 deadline | 800 ms | 覆盖网关 + 一次下游调用 + 少量排队,不把用户拖过 1 s |
调 libraryd 单跳 deadline | 300 ms | 留出重试与网关自身处理时间 |
| 重试 | 最多 2 次(共 3 次尝试),基础 20 ms,指数 2,抖动 ±20% | 20 → 40 ms 的退避对瞬时抖动够用,又不把尾延迟拉长 |
| 熔断 | 窗口 20 次,失败率 50%,打开 5 s,半开 3 个探针 | 小窗口对小流量更敏感,半开探针避免二次雪崩 |
| 限流 | 200 rps,burst 400 | 按单实例容量的一半设定,留出突发余量 |
| 服务端并发 | 64,等待队列 128 | 超过并发上限的请求排队而不是无限建线程 |
| 最大入站消息 | 4 MiB | 单条消息的最坏内存可控,大对象走对象存储 |
| 压缩 | gzip | 文本型 payload 收益明显,已压缩内容不再压 |
这组数字有一个必须提前算清的矛盾:总 deadline 800 ms,单跳 300 ms,重试 3 次就是 900 ms,已经超过总预算。正确的做法不是把总 deadline 调大,而是让每次尝试只使用「剩余时间除以剩余尝试次数」的预算,并且重试只在还有足够余量时发生。这个计算在第 3.2 节和第 7.1 节各写一遍。
另一个常见错误是只看单层重试。三层调用、每层重试两次,最坏情况是 3×3×3 = 27 次下游请求:
package com.plume.linkd.v3.demo;
public final class RetryAmplification {
public static long worstCase(long attemptsPerLayer, int layers) {
long total = 1;
for (int i = 0; i < layers; i++) {
total *= attemptsPerLayer;
}
return total;
}
public static void main(String[] args) {
System.out.println("amplification=" + worstCase(3, 3));
}
}
预期输出:
amplification=27
这个数字不需要真实压测就能算出来,但它是很多「重试导致雪崩」事故的起点。第 7 章会用重试预算和熔断把它压回可控范围。
三卷本的位置
| 卷 | 书名 | 回答的问题 | 主要产出 |
|---|---|---|---|
| 一 | 《Java 网络编程基础》 | 不用框架,一条连接从字节到协议要经过什么 | linkd v1 |
| 二 | 《Netty 高性能网络编程》 | 用 Netty 重写连接、编解码、背压与网关 | linkd-v2 |
| 三 | 《Java 服务端通信工程》 | 多服务之间怎么契约、调用、治理、观测和发布 | linkd-v3 |
三卷可以按顺序读,也可以按需跳。只读本卷也能跑通示例,但遇到「为什么不能在事件循环里阻塞」「为什么消息边界由 codec 负责」时,会缺少卷二那层经验。本卷假设你已经写过至少一个能收发请求的服务,知道 TCP 连接、HTTP 请求和线程池大概是什么。
本卷不写通用网络原理,不写 Netty 组件入门,不写 Web 框架全家桶,也不写分布式事务。它只写一件事:把一次跨服务调用做成可运营的工程件。
写给谁
这本书写给已经能写出「一条请求一条响应」的服务端开发者,尤其是正在把单体拆成多服务、或者把内部 HTTP 调用换成 gRPC 的人。它假设你会写 Java、用过 Maven、知道线程池和连接池的区别,但不要求你用过服务网格或注册中心。
如果你现在的系统只有一个服务,本卷第 1–3 章仍然值得读:错误模型、Deadline 和幂等性不是分布式系统独有的,单服务内部的模块边界同样需要它们。第 4 章之后的内容可以等真正出现第二个服务时再补。
如果你只关心某个具体问题,目录页给出了按目标划分的路径。每条路径都标了前置章节和可以后置的内容,不需要从头读到尾。
本书不写什么
有三件事需要提前说明,避免预期错位:
第一,不伪造数字。本机没有真实跑过的命令、没有测过的性能,不会写成「实测」。需要给量级时,会标明「估算」或「假设」,并写清口径。
第二,不假设工具已安装。grpcurl 在本机没有安装,涉及反射服务的验证会用 Java 代码完成;需要 grpcurl 的地方会写明「若已安装」。
第三,不把平台差异藏起来。macOS 没有 ss,也没有 epoll,诊断命令用 netstat、lsof -i 和 tcpdump -i lo0;Linux 上的差异会单独说明,不混在本机输出里。
版本口径
全书的版本在写作时锁定,正文不随发布漂移。版本不一致时,先看下表,不要用「最新版应该也行」来覆盖:
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | Temurin 25.0.5+8 | --release 25,示例按 25 的语言特性编写 |
| Maven | 3.9.12 | 构建、依赖与模块管理 |
| gRPC-Java | 1.76.0 | grpc-netty-shaded、grpc-protobuf、grpc-stub、grpc-services |
| Protobuf | 4.33.2 | protobuf-java、protobuf-java-util |
| JUnit Jupiter | 5.14.4 | 契约测试与单元测试 |
| libraryd | HTTP 8080 + PostgreSQL 17 | 已有服务,不重写 |
gRPC 只引入 grpc-netty-shaded,不额外引入非 shaded 的传输实现,也不直接依赖 Netty 4.2。shaded 包内部自带一套 Netty,两套混用会在运行时出现类冲突,而且冲突往往在第一条连接建立时才暴露。
小结
- 卷一解决「一条连接怎么读写」,卷二解决「用框架怎么组织连接」,本卷解决「服务之间怎么可靠调用」。
- 本卷的主线是
linkd-v3通过 gRPC 调libraryd,四模块、三端口和共享类型在全书中固定。 - 最小闭环只需要
MethodDescriptor、ServerCalls、ClientCalls和一个ManagedChannel,但它不含任何运营能力。 - 800 ms 总 deadline、300 ms 单跳、2 次重试是全书统一口径;三者之间存在预算矛盾,必须按剩余时间分配。
- 三层、每层 3 次尝试的最坏放大是 27 倍,这是重试治理要解决的核心问题。
- 本卷只写可验证的内容:跑过的写「本机输出」,没跑过的写「预期输出」或明确标注假设。
下一站是前言:把读法、环境、版本和代码约定一次交代清楚,后面的章节不再重复。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。