《Java 服务端通信工程》序:从能通到可运营

《Java 服务端通信工程》开篇:从一条能跑通的 RPC 调用出发,说明上线后会遇到的超时、重试放大、错误码丢失、版本兼容与容量问题,交代 linkd-v3 的四模块、9093/9094/9095 端口,以及全书的最小可运行闭环和三卷本定位。

一条请求跑通,离可运营还有多远

卷一《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-servergRPC 服务实现、拦截器、健康检查、反射
comm-gatewayHTTP/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 调用,上面每一项缺失都会在某个故障场景里变成事故。

三个默认值背后的算术

全书统一一组默认值,目的是让「超时、重试、限流」在章节之间可比较,而不是每节各调一套:

参数默认值为什么是这个数
客户端总 deadline800 ms覆盖网关 + 一次下游调用 + 少量排队,不把用户拖过 1 s
调 libraryd 单跳 deadline300 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 上的差异会单独说明,不混在本机输出里。

版本口径

全书的版本在写作时锁定,正文不随发布漂移。版本不一致时,先看下表,不要用「最新版应该也行」来覆盖:

组件版本用途
JDKTemurin 25.0.5+8--release 25,示例按 25 的语言特性编写
Maven3.9.12构建、依赖与模块管理
gRPC-Java1.76.0grpc-netty-shaded、grpc-protobuf、grpc-stub、grpc-services
Protobuf4.33.2protobuf-java、protobuf-java-util
JUnit Jupiter5.14.4契约测试与单元测试
librarydHTTP 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 倍,这是重试治理要解决的核心问题。
  • 本卷只写可验证的内容:跑过的写「本机输出」,没跑过的写「预期输出」或明确标注假设。

下一站是前言:把读法、环境、版本和代码约定一次交代清楚,后面的章节不再重复。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收