Stub · 动态代理 · 序列化 · IDL · protobuf · varint · Hessian2 · 协议帧 · 魔数 · 请求 id · 多路复用 · 注册中心 · 服务发现 · 负载均衡 · 轮询 · 最少活跃 · P2C · 超时 · 重试

RPC 原理:序列化、协议、服务发现、负载均衡。让远程方法用起来像本地方法,背后是四件事

inventory.reserve(req) 写起来就是一次方法调用,背后有四件事:序列化把对象变成字节;协议在 TCP 字节流上切出一帧一帧,并用请求 id 让一条连接同时跑很多个调用;服务发现把服务名换成现在还活着的地址列表;负载均衡每次从中挑一台。精华在这里:框架能让调用的写法和本地方法一样,却改变不了网络的三个事实:会慢、会失败、还会只做了一半。不懂这一点,就会把它当本地方法用:在循环里调、在事务里调、一超时就重试。

全景:订单服务调库存服务的一次 RPC · 第 1、2、4 站地图:J04 · RPC 原理:序列化、协议、服务发现、负载均衡前置:动态代理 · TCP 与粘包 · Netty

①为什么需要:一行代码背后,有四个问题要回答

全景里订单服务只写了一行 stub.reserve(req)。第 1 站它被包成一次调用,第 4 站对象变成 7 个字节,第 2 站从注册中心拿到两台库存的地址、轮着挑一台。第 3、5~10 站(连接、HTTP/2、Netty、TCP、线程池、超时)前几页已经讲过。这一页把 1、2、4 三站合起来,讲清 RPC 框架本身做了什么。

订单服务 ReserveReply r = inventory.reserve(req); Stub它是谁?一个代理,把“调方法”包成一条请求 序列化对象怎么变成字节?对面怎么变回来? 协议一条消息从哪到哪?回来的响应是谁的? 发现 + 均衡inventory 现在在哪几台?这次发给谁? 注册中心 inventory → 10.0.3.17:50051 10.0.3.18:50051 推送地址列表 库存实例 AInventoryServiceImpl 库存实例 BInventoryServiceImpl 这次挑 A 下次可能是 B 启动时登记
上面一行是业务代码看得见的部分;下面四层是 RPC 框架替你做的,这一页逐层拆开。

需求(假设场景):工作里用 gRPC 或 Dubbo 调服务,要能看懂超时、重试、分流不均这类问题出在哪一层;面试也常让人讲“自己写一个 RPC 要几步”。所以 ⑤ 用一个 400 行的纯 JDK 迷你 RPC 把四层亲眼跑一遍,⑥ 先讲事故最多的坑,最后附几道面试题。

②没有它会怎样:手写 HTTP + JSON,每个接口都把四件事重做一遍

不用 RPC 框架,最常见的写法是自己发 HTTP 请求。能用,但每多一个接口就要再抄一遍下面这些代码,而且有几件事它根本没做。

老办法:自己拼 URL、拼 JSON、发 HTTP

String url = "http://10.0.3.17:8080/inventory/reserve"; // 地址写死
String body = "{\"orderId\":" + orderId + ",\"skuId\":" + skuId
            + ",\"qty\":" + qty + "}";                  // 字段名手敲
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
    .timeout(Duration.ofMillis(300))
    .header("Content-Type", "application/json")
    .POST(BodyPublishers.ofString(body)).build();
HttpResponse<String> resp = http.send(req, BodyHandlers.ofString());
if (resp.statusCode() != 200) { /* 重试?换一台?*/ }
ReserveReply r = mapper.readValue(resp.body(), ReserveReply.class);
  • 没有契约:字段名靠文档对齐,对面把 qty 改成 count,编译照过,上线才发现库存一直扣 0 件
  • 地址写死:库存扩到 4 台、某台换了 IP,要改配置、重启订单服务;挂了一台也不知道
  • 没有负载均衡:要么全打一台,要么前面再架一个 Nginx,多一跳
  • JSON 偏大偏慢:同一个请求 JSON 35 字节、protobuf 7 字节(全景第 4 站实测),解析还要逐字符扫描
  • 连接多:HTTP/1.1 一条连接同一时刻只能有一个请求,全景实测 50 线程并发开了 27~46 条连接,gRPC 每台 1 条

RPC 框架:四件事抽出来,业务只剩一行

// 契约:双方共同依赖的接口(或 .proto 生成的类)
public interface InventoryService {
    ReserveReply reserve(ReserveRequest req);
}

// 调用方
ReserveReply r = inventory.reserve(new ReserveRequest(9527, 88, 2));
  • 接口就是契约,改了字段名,调用方编译就报错
  • 只写服务名,地址从注册中心来,实例增减自动生效
  • 每次调用自动挑一台;连不上的换下一台
  • 二进制序列化,一条长连接上并发跑很多请求
  • 代价:这行代码看起来太像本地调用了,下面会讲为什么这很危险

不懂精华时的典型误用:把远程调用当成本地方法用。在 for 循环里逐个调 getSku(id),100 个商品就是 100 次网络往返(N+1);在 @Transactional 方法里调远程,对面慢 300 ms,本地的数据库行锁就多占 300 ms;超时了就重试,可对面其实已经扣过库存(⑥ 坑 1 实测)。本地方法要么成功要么抛异常,远程调用还有第三种结果:不知道成没成功。

③相邻方案:四件事人人都做,做法不同

Java 里常见的三套都把四件事做全了,区别在每一件选了哪种做法。表里是各自的默认值,后面讲原理时会反复回到这张表。

gRPC Java 1.84Dubbo 3.3OpenFeign + Spring Cloud
Stub 怎么来从 .proto 编译期生成Java 接口,运行时生成代理Java 接口 + 注解,JDK 动态代理
序列化protobufhessian2(3.2 曾改成 fastjson2,3.3 又改回)JSON(Jackson)
协议HTTP/2,消息前加 5 字节前缀默认 dubbo 协议(16 字节头,TCP 长连接);可选 Triple(兼容 gRPC)HTTP/1.1
服务发现NameResolver,默认 DNS,可换成注册中心注册中心(Nacos、ZooKeeper 等)注册中心(Nacos、Eureka 等)
负载均衡默认pick_first:全发第一台random:按权重随机轮询
超时 / 重试默认没有 deadline;不自动重试1000 ms;失败再试 2 次看配置;Feign 默认不重试
跨语言强(十几种语言官方实现)以 Java 为主,Triple 可跨语言任何能说 HTTP 的

选择规则:新系统、要跨语言或对外暴露,选 gRPC;Java 为主、用 Nacos 一类国内生态和服务治理,选 Dubbo;已经是 Spring Cloud HTTP 体系、调用量不大,用 OpenFeign。三者的四件事原理相同,换框架只是换默认值,所以先把四件事本身学透。

本页学 RPC 四件事的原理:用一个纯 JDK 写的迷你 RPC 把每一件拆开看,再对照 gRPC 1.84 和 Dubbo 3.3 的真实做法。

④原理:一次调用走过的八步,分属四件事

先看一次调用的全程,再一件一件拆开:Stub、序列化、协议、发现和均衡。最后讲为什么主流 RPC 都长成这个样子。

先知道这些
  • JDK 动态代理:Proxy.newProxyInstance 运行时生成一个实现了接口的类,所有方法调用都转给你写的 InvocationHandler.invoke(proxy, method, args)。反射与动态代理讲过。
  • TCP 是字节流:没有“一条消息”的概念,两次写可能被一次读到,所以上层要自己标边界。TCP 与粘包讲过。
  • 长连接上谁在读写:gRPC 和 Dubbo 底下是 Netty,一条连接归一个 EventLoop 线程,业务在另一个线程池里跑。Netty 那页讲过;本页的迷你 RPC 为了短,用每连接一个读线程代替。

④-0 一次调用的全程

订单服务(调用方) 库存服务(提供方) 1 Stub代理拦下 reserve(req) Request{InventoryService, "reserve", [req]} 2 序列化对象 → 86 字节 3 发现 + 均衡本地缓存的地址列表里挑一台 [A, B] → leastActive → A 4 协议加 17 字节头:请求 id 42、长度 86 pending.put(42, future) → 写进长连接 8 配对读线程收到 id 42 的响应 pending.remove(42).complete(resp) 5 切帧先读满 17 字节头 len=86 → readFully(86 字节) 6 反序列化 + 分派 services.get("InventoryService") .getMethod("reserve") → 业务线程池 InventoryServiceImpl.reserve(req) 7 响应用同一个请求 id 42 写回 一条长连接 (同时还有别的 请求在途) 调用线程 future.get(超时) 等着
颜色对应四件事:蓝 Stub,紫 序列化,粉 发现与均衡,橙 协议。注意第 3 步:调用时不访问注册中心,只读本地缓存的列表。⑤ 的迷你 RPC 就是照这张图写的,每一步都能在代码里找到。

④-1 Stub:动态代理把方法调用变成数据

RpcClient.proxy(⑤ 的源码,省略了 Object 方法的处理)JDK 8+
return (T) Proxy.newProxyInstance(api.getClassLoader(), new Class<?>[]{api},
    (proxy, method, args) -> call(api.getName(), method.getName(), args));
// inventory.reserve(req) → call("…InventoryService", "reserve", [req])

方法调用本来是“跳到一段代码去执行”,到这里变成了一份数据:服务名、方法名、参数。数据可以序列化、可以发到任何地方。gRPC 不用运行时代理,而是从 .proto 编译期生成 InventoryServiceGrpc,做的事一样。Stub 也是最危险的一层:它让远程调用和本地调用长得一模一样(④-6)。

④-2 序列化:要不要把“结构”写进字节里

JDK 序列化127 字节 · 实测
ac ed 00 05 …类名 labs.network…Api$ReserveRequestserialVersionUID字段 orderId J字段 qty I字段 skuId J值 …
JSON35 字节
{"orderId":9527,"skuId":88,"qty":2}
protobuf7 字节 · 全景实测
08b74a10581802
拆开看字段号 + 值
08 = 字段 1,变长整数b7 4a = 952710 = 字段 258 = 8818 = 字段 302 = 2
同一个 ReserveRequest(9527, 88, 2)。JDK 序列化把类名、字段名、字段类型全写进去,对面不用事先知道结构(自描述);protobuf 只写“第几号字段 + 值”,结构写在双方都有的 .proto 里(约定结构,也叫 IDL)。

两个关键编码

  • tag = 字段号 << 3 | 类型:08 = 1<<3 | 0(0 表示变长整数),10 = 2<<3,18 = 3<<3。
  • varint:每字节用低 7 位存数,最高位 1 表示“后面还有”。9527 = 74 × 128 + 55,先写 55 | 0x80 = b7,再写 74 = 4a。小数字只占 1 个字节。

为什么用字段号而不是字段名

  • 省字节:名字每条消息都要带一遍,编号 1 个字节就够
  • 好兼容:新版本加了字段 4,老版本读到不认识的编号就跳过;字段改名也不影响,只要编号不变
  • 代价:编号一旦用过就不能改含义。改了编号或类型,对面会按老规则解出一个错的值,而且不报错(⑥ 坑 4)

Dubbo 默认的 hessian2 和 JSON 一样是自描述的,不用 IDL,Java 对象直接传,所以大小在两者之间。选哪种是一组取舍:约定结构小、快、跨语言,但要先写 IDL;自描述拿来就用、可读,但更大,而且反序列化时要按字节流里写的类名去创建对象,这正是反序列化漏洞的来源(⑥ 坑 7)。

④-3 协议:帧头里每个字段都在防一件事

ca fe魔数
01版本
00请求
02序列化
00 00 00 00 00 00 00 2a请求 id = 42
00 00 00 56体长度 86
…体:86 字节
⑤ 迷你 RPC 的 17 字节帧头(实测输出里的那一帧,请求 id 换成了 42)。Dubbo 的头是 16 字节,字段几乎一样:魔数 da bb、1 字节标志位(请求还是响应、要不要回复、是否心跳、5 位序列化编号)、1 字节状态、8 字节请求 id、4 字节体长度。gRPC 把这些交给 HTTP/2:请求 id 就是 stream 编号,消息前只加 5 字节(1 字节压缩标志 + 4 字节长度)。
字段没有它会怎样
魔数端口连错、或者读错了位置,只会解出一堆乱码;有魔数,第一帧就能报“这不是本协议”并断开
版本、序列化编号升级协议、换序列化时,新老节点没法共存,只能全部一起停机升级
体长度TCP 不管消息边界,不知道这条消息到哪结束(粘包);顺便要设上限,防止一个坏长度让对面分配几百 MB
请求 id一条连接上同时只能有一个请求在途,要并发就得多开连接。有了它,响应可以乱序回来,各自对号入座
一条连接上的多路复用 pending 表 41 → 线程 T1 的 future 42 → 线程 T2 的 future 43 → 线程 T3 的 future 超时的 id 会被删掉 TCP 连接(字节按顺序,但谁先做完谁先回) 请求 41 请求 42 请求 43 响应 43 响应 41 响应 42 库存业务线程池 41:等行锁 8 ms 42:12 ms 43:1 ms,先做完
没有请求 id,只能“发一个、等一个”,或者每个调用单独占一条连接。HTTP/1.1 就是前者,所以它要连接池;全景第 5 站的 HTTP/2 stream 编号、Dubbo 头里的 8 字节 id,都是这里的请求 id。

④-4 服务发现:名字换地址,而且地址是活的

注册中心 inventory: A 10.0.3.17:50051 ♥ B 10.0.3.18:50051 ♥ 订单服务 本地缓存:[A, B] 每次调用:负载均衡挑一台 注册中心挂了,用缓存照样能调 库存 A 库存 B ① 订阅 ② 推送新列表 登记 + 心跳 ③ 调用直连,不经过注册中心
注册中心只管“谁在、在哪”,不在调用路径上。实例下线有两种:正常停机会主动注销,列表几乎立刻更新;进程被杀、机器断电只能等心跳超时。Dubbo 用 ZooKeeper 时靠临时节点,会话超时默认 60 秒(Dubbo 3.3 源码),这段时间里死实例还在列表上(⑥ 坑 3)。

④-5 负载均衡:每次调用挑一台,看不看对方忙不忙

策略怎么挑看对方状态吗谁的默认
轮询A、B、A、B……不看Spring Cloud LoadBalancer;gRPC 要显式配 round_robin
加权随机按权重掷骰子不看Dubbo(random)
最少活跃挑“发出去还没回来”的请求最少的那台看在途请求数Dubbo 可选 leastactive
P2C随机抽两台,取在途少的那台看,但只比两台Dubbo 3 的 adaptive;gRPC xds 模块的 least_request_experimental
一致性哈希同一个参数(如用户 id)总到同一台不看Dubbo consistenthash;gRPC ring_hash_experimental
pick_first全发给列表第一台,断了才换只看连没连上gRPC 的默认
轮询 · fast
1200 次
轮询 · slow
1200 次
最少活跃 · fast
2214 次
最少活跃 · slow
186 次
实测(⑤ 第 5 段):两台实例,fast 每次 2 ms,slow 每次 30 ms,16 个线程一共调 2400 次。轮询不看快慢,一半请求落到慢的那台,平均 16.3 ms;最少活跃只给慢的那台 186 次,平均 4.4 ms。

为什么“在途请求数”能反映快慢:慢的实例每个请求要占 30 ms,发过去的请求还没回来就在那攒着,它的在途数总是偏高,于是少分。它不需要知道对方的 CPU、队列长度,只看自己这边的计数,这是它便宜又有效的原因。同一个原因也是它的盲区:一台坏掉、立刻报错的实例,在途数永远是 0,看起来最闲(⑥ 坑 2)。

④-6 为什么这样设计

选中:一条长连接 + 请求 id 多路复用

  • 建连接贵:一次 TCP 握手一个往返,加 TLS 再多一两个,冷连接还要慢启动;长连接只付一次
  • RPC 的包很小(几十字节),调用量又大;一条连接上并发跑,连接数不随并发涨(全景实测:每台 1 条)
  • 响应谁先做完谁先回,一个慢请求不挡后面的
  • 代价:要有一张 pending 表,超时要清理;所有请求共用一条 TCP,丢包重传时一起等(TCP 层的队头阻塞)

放弃:一次调用一条连接,或 HTTP/1.1 一问一答

  • 每次调用都握手:内网一次往返 0.1~0.5 ms,比 RPC 本身还贵;主动关闭的一方堆满 TIME_WAIT
  • 一问一答:要并发就要连接池,50 个线程就是几十条连接,每条都占两端的内存和文件描述符
  • 好处是简单,每条连接上不会有两个请求混在一起,不需要请求 id

决定胜负的约束:服务间调用是海量的小请求,在两个固定的端点之间反复发生。连接是贵的、请求是便宜的,所以连接要复用到极致,就必须有请求 id。

选中:客户端发现 + 客户端负载均衡

  • 调用直连目标实例,不多一跳,也没有一个“中间的”单点
  • 均衡粒度是每次调用,能看到自己发出的在途请求数,做最少活跃这类策略
  • 注册中心挂了,本地缓存照样能调
  • 代价:每种语言都要有一份客户端 SDK;注册中心推送慢一步,客户端就会打到死实例

放弃:服务端发现(前面架一个负载均衡器)

  • 客户端最简单,只知道一个地址,比如 K8s 的 Service IP 或一个 Nginx
  • 四层负载均衡按连接分:RPC 是一条长连接跑所有请求,连接一建好就钉在某台后端上,扩容了新实例也分不到(全景第 5 站的坑)
  • 换成七层代理能按请求分,但多一跳、多一个要扩容和保高可用的组件。服务网格(Istio 的 sidecar)就是这条路,代价是每个 Pod 多一个代理

决定胜负的约束:④-6 上面那个选择:长连接 + 多路复用。请求都在少数几条长连接里,只有发请求的那一方能按“每次调用”来分,所以均衡必须放在客户端(或贴着客户端的 sidecar)。

还有一个更老的教训:上世纪 90 年代的 CORBA、RMI 想做到“远程对象和本地对象完全一样”。1994 年 Sun 实验室的《A Note on Distributed Computing》指出这条路走不通:延迟、部分失败、没有共享内存,这些差别藏不住,藏起来只会让人写出错的程序。今天的 gRPC 因此把差别摆在明面上:每次调用都可以带 deadline,失败返回明确的状态码(DEADLINE_EXCEEDED、UNAVAILABLE……)。Stub 只负责让写法像本地调用,超时、失败和重试仍要在代码里明确处理。

④-7 最少的 API:真实框架里这四件事写在哪

gRPC Java 1.84 与 Dubbo 3.3参数细节放到 ⑥
// gRPC:Stub 由 .proto 生成;发现写在 target 里,均衡和超时显式配
ManagedChannel ch = ManagedChannelBuilder.forTarget("dns:///inventory:50051") // 服务发现
    .defaultLoadBalancingPolicy("round_robin")                             // 不配就是 pick_first
    .usePlaintext().build();                                               // 内网明文 HTTP/2
ReserveReply r = InventoryServiceGrpc.newBlockingStub(ch)                   // Stub
    .withDeadlineAfter(300, TimeUnit.MILLISECONDS)                         // 不配就无限等
    .reserve(ReserveRequest.newBuilder().setOrderId(9527).setSkuId(88).setQty(2).build());

// Dubbo:Java 接口就是契约;注册中心写在配置里,策略写在注解上
@DubboReference(timeout = 300, retries = 0, loadbalance = "leastactive")
private InventoryService inventory;          // 注入的是代理
// application.yml: dubbo.registry.address: nacos://127.0.0.1:8848

⑤亲眼看到:400 行纯 JDK 写一个迷你 RPC

不依赖任何框架,四件事各一个类:Serializers、Protocol、Registry、LoadBalancers,再用 RpcClient(动态代理 + 长连接 + pending 表)和 RpcServer(读帧 + 业务线程池 + 反射)串起来。下面只贴最关键的两段,完整代码在 study-labs →

RpcClient.call:④-0 的第 3、4、8 步JDK 8 写法
String addr = lb.pick(addresses.get(service), active);        // 3 本地列表 + 负载均衡
active.get(addr).incrementAndGet();                             // 在途 +1,最少活跃就看它
try {
    Connection c = connections.computeIfAbsent(addr, Connection::open);  // 每个地址一条长连接
    long id = nextId.incrementAndGet();
    byte[] body = serializer.write(new RpcMessage.Request(service, method, args));  // 2
    CompletableFuture<Frame> f = c.send(new Frame(REQUEST, serializer.id(), id, body)); // 4 登记 pending 再写
    try {
        Frame resp = f.get(timeoutMs, TimeUnit.MILLISECONDS);   // 8 读线程按 id 唤醒这里
        ...
    } catch (TimeoutException e) {
        c.pending.remove(id);         // 不等了;但对面可能已经在做,甚至做完了
        throw ...;
    }
} finally {
    active.get(addr).decrementAndGet();
}
Protocol.read:④-3 的切帧17 字节头 + 变长体
short magic = in.readShort();
if (magic != MAGIC) throw new IOException("魔数不对,不是本协议的连接");
in.readByte();                                   // 版本
byte type = in.readByte(), ser = in.readByte();
long id = in.readLong();
int len = in.readInt();
if (len < 0 || len > MAX_BODY) throw new IOException("体长度异常:" + len);
byte[] body = new byte[len];
in.readFully(body);                              // TCP 分几段送来都没关系,凑够 len 字节才返回
怎么跑JDK 8+,无依赖
git clone https://github.com/archeu/study-labs.git && cd study-labs/java
mvn -q -pl network/rpc-serialization-discovery-lb compile exec:java \
  -Dexec.mainClass=labs.network.rpc_serialization_discovery_lb.MiniRpcDemo
JDK 17.0.20.1

== 1. 序列化:同一个 ReserveRequest(9527, 88, 2) 变成多少字节 ==
JDK 序列化 127 字节 | JSON 35 字节 | Compact 9 字节:51 08 b7 4a 10 58 18 02 00
JDK 字节流里能读到的字符串:>labs.network.rpc_serialization_discovery_lb.Api$ReserveRequest | orderIdI | qtyJ | skuIdxp |
整条 RPC 请求(服务名 + 方法名 + 参数):JDK 397 字节,Compact 86 字节
一帧 = 17 字节头 + 86 字节体 = 103 字节
头:ca fe 01 00 02 00 00 00 00 00 00 00 01 00 00 00 56  ← 魔数 cafe | 版本 01 | 类型 00=请求 | 序列化 02 | 请求 id 8 字节 | 体长度 4 字节

== 2. 一次调用,看起来像本地方法 ==
inventory 的真实类型:jdk.proxy1.$Proxy0
reserve(9527, 88, 2) → ReserveReply{ok=true, instance=A}

== 3. 轮询:1000 次顺序调用 ==
{A=500, B=500}

== 4. 服务发现:C 上线,A 下线 ==
C 注册后 300 次:{A=100, B=100, C=100}
A 摘除并关闭后 300 次:{B=150, C=150}

== 5. 一台变慢:fast 2 ms,slow 30 ms,16 线程共 2400 次 ==
轮询   fast 1200 次 / slow 1200 次 | 平均  16.3 ms | p90  30.2 ms | p99  31.5 ms
随机   fast 1214 次 / slow 1186 次 | 平均  16.1 ms | p90  30.2 ms | p99  31.1 ms
最少活跃 fast 2214 次 / slow  186 次 | 平均   4.4 ms | p90   2.8 ms | p99  30.3 ms

== 5b. 一台坏了但还在注册中心里:它立刻报错,16 线程共 2400 次 ==
轮询   a  800 次 / b  800 次 / bad  800 次 | 错误率 33.3%
最少活跃 a  465 次 / b  476 次 / bad 1459 次 | 错误率 60.8%

== 6. 超时:客户端等 100 ms,库存要 300 ms ==
101 ms 后客户端收到:java.util.concurrent.TimeoutException: reserve 超过 100 ms 没回复,地址 127.0.0.1:33339
此刻库存已扣次数:0
再等 400 ms,库存已扣次数:1  ← 超时不等于没做

本机 2 核 ARM,JDK 17.0.20,跑了 3 次:第 1~4、6 段每次相同(端口除外);第 5 段每次差几个百分点;5b 的最少活跃错误率三次分别是 67.4%、61.3%、60.8%。Compact 比 protobuf 多 2 个字节:开头 1 字节类型标记 51('Q')和结尾的 00,中间 7 个字节和 protobuf 一模一样。

到这里四件事都亲眼看到了

代理把调用变成数据(第 2 段),9 字节对 127 字节(第 1 段),17 字节帧头(第 1 段),注册中心增删实例后流量立刻跟着变(第 4 段),最少活跃避开慢实例(第 5 段)。面试被问“手写一个 RPC 要哪几块”,照着这五个类讲就够了。

还没处理的细节

超时以后要不要重试(坑 1);坏实例反而被多分流量(坑 2);实例死了注册中心还没摘(坑 3);字段怎么改才兼容(坑 4);反序列化的安全(坑 7)。读线程每连接一个,真实框架换成 Netty 的 EventLoop。

⑥细节:超时不等于没做,最闲的那台可能是坏的

每个坑旁边的徽标指回 ④ 里推出它的那条原理。标“实测”的在 ⑤ 的输出里能找到,标“按源码”“按文档”的给了出处。

第一圈

注意事项

坑 1超时后重试,库存扣了两次← ④-3 超时只是删了 pending实测 + 按源码
现象
⑤ 第 6 段:客户端 101 ms 收到超时,库存那边 300 ms 后照样扣了 1 次。Dubbo 默认 retries = 2、集群容错 failover(3.3 源码 CommonConstants.DEFAULT_RETRIES、Cluster.DEFAULT):一次超时最多变成 3 次调用,可能扣 3 次。
根源
请求早已发出,超时只是调用方不再等、把 pending 里的登记删了,对面并不知道。gRPC 会给对面发一个取消(RST_STREAM),但业务代码不检查 Context.isCancelled() 就照做不误(全景第 10 站实测)。
做法
写操作 retries = 0(或 cluster = "failfast");确实要重试,先让接口幂等:库存按 order_id 去重,同一订单扣过就返回上次的结果。读接口才放心重试。
坑 2一台坏了,最少活跃反而把六成流量送给它← ④-5 只看在途数实测
现象
⑤ 第 5b 段:3 台实例,其中 1 台数据库连不上,每个请求立刻返回错误。轮询的错误率 33.3%;最少活跃 60.8%~67.4%,比不做均衡还糟。
根源
坏实例一收到请求就回错误,在途数几乎总是 0,在最少活跃看来它最闲。这个策略只数“还没回来的”,不管回来的是成功还是失败。
做法
失败也要算进去:按实例统计错误率或连续失败次数,超过阈值就暂时踢出,过一会儿放一点流量试探(离群剔除,也叫实例级熔断)。gRPC 有现成的 OutlierDetectionLoadBalancer(grpc-util 1.84,按成功率或失败比例剔除);Dubbo 一般配合 Sentinel 一类组件做。下面的实验台第三个预设可以亲手试。
坑 3实例已经死了,还在被调用← ④-4 只能等心跳超时按源码
现象
某台机器断电,或者进程被 kill -9,之后几十秒里一部分请求报“连接被拒”或超时;发布时也会有一小撮报错。
根源
进程来不及注销,注册中心只能等心跳超时。Dubbo 接 ZooKeeper 时会话超时默认 60 秒(3.3 源码 AbstractZookeeperClient.DEFAULT_SESSION_TIMEOUT_MS)。发布时先停进程、后注销,也会留出这样一个窗口。
做法
发布顺序:先从注册中心注销,等在途请求做完,再停进程;启动反过来,预热好再注册(全景第 3 站的冷启动)。客户端对“连不上”的实例自动换下一台:请求根本没发出去,这种重试是安全的。
坑 4改了一个字段,对面读出错值,还不报错← ④-2 编号就是身份按文档
现象
有人觉得 qty 该放到后面,把它从 3 号改成 4 号。还没升级的订单服务继续按 3 号发,新库存服务不认识 3 号,直接跳过,qty 读成默认值 0,扣了 0 件,返回成功。
根源
不认识的编号被跳过,正是向前兼容的机制,所以这里不会报错。自描述格式的坑在另一边:hessian2 和 JDK 序列化按类名找类,改了包名或类名,对面直接反序列化失败;JDK 序列化没写 serialVersionUID 时,加一个字段也会报 InvalidClassException。
做法
只加字段,不改已有字段的编号和类型;删掉的编号写进 reserved,以后谁也不能再用(protobuf 官方指南)。Java 接口包里的 DTO 一样:只加不改,固定 serialVersionUID,不随手挪包。
坑 5不配超时,下游一卡,上游跟着挂← ④-3 pending 靠超时清理按源码
现象
库存服务数据库锁住了,订单服务的线程一个个停在 future.get() 上,线程池满,下单接口全部超时,故障从库存传到订单再传到网关。
根源
gRPC 默认没有 deadline(CallOptions 的 deadline 默认是 null),不设就一直等。Dubbo 默认 1000 ms,但若网关只等 800 ms,库存还在白干。
做法
每个调用都配超时,越往下游越短。gRPC 的 deadline 会通过 grpc-timeout 头传给下游(全景第 5 站抓到的 299936u 就是剩下的时间),下游再调别人时直接沿用剩余时间。
坑 6一次查 5 万条,报 RESOURCE_EXHAUSTED← ④-3 体长度要有上限按源码
现象
RESOURCE_EXHAUSTED: gRPC message exceeds maximum size 4194304。不报错的框架里,大响应会让同一条连接上的其他调用一起变慢:一条连接上的字节是排着队发的。
根源
帧长度上限防止一个坏长度或一个超大消息撑爆内存;gRPC 默认入站上限 4 MiB(GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE)。⑤ 的 Protocol.MAX_BODY 也是 4 MiB。
做法
分页,或改成流式调用(gRPC 的 server streaming)。别第一反应就把 maxInboundMessageSize 调到几百 MB。
坑 7反序列化成了远程执行代码的入口← ④-2 自描述按类名建对象按文档
现象
CVE-2021-43297:hessian-lite 3.2.11 及以前的反序列化漏洞,影响 Dubbo 2.6.12、2.7.15、3.0.5 之前的版本,可远程执行代码。CVE-2023-23638 是 Dubbo 泛化调用里的同类问题。
根源
自描述格式按字节流里写的类名去创建对象、填字段。攻击者往字节里写一个精心挑选的类名,类路径上恰好有能被利用的类(gadget),反序列化时就会执行它的代码。protobuf 只按 IDL 生成的类解码,不按字节里的类名建对象,没有这一类问题。
做法
及时升级;开类白名单(JDK 用 jdk.serialFilter / ObjectInputFilter,JEP 290,JDK 9 加入、8u121 回移);Dubbo 3.3 起默认不再带 JDK 序列化(升级指南);RPC 端口不对公网开放。

实验台:哪种负载均衡最扛事

模型:T 个调用线程,每个调完立刻发下一次,一共 2400 次。每台实例 W 个业务线程,忙不过来就排队;耗时在设定值的 ±10% 内波动,“坏”实例 0.2 ms 就回错误;另加 0.3 ms 网络。四种策略用同一组参数各跑一遍。预设“实测复现”和 ⑤ 第 5 段同参数:模拟出的分配是 2227 / 173、平均 4.3 ms,实测 2214 / 186、4.4 ms。

先猜:3 台实例,其中 1 台坏了,每个请求立刻返回错误,注册中心还没摘掉它。用最少活跃,错误率大约是多少?

最后一台的状态

先选一个答案再运行,也可以直接改参数。

第二圈

使用规范

接口包(.proto 或 api jar)单独发版;字段只加不改,删掉的编号写 reserved

字段错位、读出错值不报错(坑 4)

每个方法显式配超时,越往下游越短;gRPC 每次调用都带 withDeadlineAfter

下游一卡,上游线程池被拖满(坑 5)

写操作不自动重试;要重试先做幂等(业务唯一键去重)

超时后重试,库存扣两三次(坑 1)

gRPC 显式配负载均衡策略,并开离群剔除

默认 pick_first 全打一台;坏实例被最少活跃当成“最闲”(坑 2)

发布时先注销、等在途请求做完、再停进程;启动时先预热、再注册

死实例还在列表里,发布期间一小撮报错(坑 3)

不在循环里调远程,改成批量接口;不在本地事务里调远程

N+1 次往返;对面慢多久,本地行锁就多占多久(②)

列表类接口必须分页,大结果用流式

4 MiB 上限报错,或一个大响应拖慢同一条连接(坑 6)

RPC 端口只在内网开放,序列化开类白名单,框架跟着安全公告升级

反序列化远程执行代码(坑 7)

第三圈

优化和深处

Dubbo 协议头 16 字节,逐位看

偏移内容
0–1魔数 0xdabb
2标志位:0x80 请求 / 响应,0x40 要不要回复(单向调用不回),0x20 心跳事件,低 5 位序列化编号
3状态(只在响应里有意义,如 20 = OK)
4–11请求 id(long)
12–15体长度(int)

依据:Dubbo 3.3 源码 ExchangeCodec(HEADER_LENGTH = 16、MAGIC = 0xdabb、FLAG_REQUEST / FLAG_TWOWAY / FLAG_EVENT、SERIALIZATION_MASK = 0x1f)。和 ⑤ 的 17 字节头比:它把“类型”“序列化”“是否单向”压进 1 个字节,还多了心跳。

protobuf 编码里的两个细节

  • 负数:int32 / int64 的负数按 64 位补码做 varint,最高位全是 1,固定占 10 字节。经常出现负数的字段用 sint32 / sint64,先做 ZigZag 映射(0→0、-1→1、1→2、-2→3……),小的负数也只占 1 字节。
  • 未知字段:proto3 从 3.5 起默认保留不认识的字段,解析后再序列化时原样带上。所以“老服务转发新消息”不会丢字段(3.5 之前会丢)。

依据:protobuf.dev 的 Encoding 指南、protobuf v3.5.0 发行说明。

Dubbo 默认序列化的来回

版本dubbo 协议默认序列化
2.x~3.1hessian2
3.2.xfastjson2(对端低于 3.2 时降级为 hessian2)
3.3.0 起改回 hessian2(hessian-lite 4.0,支持 JDK 17 / 21);JDK 序列化默认不再自带

依据:Dubbo 官方 3.1→3.2、3.2→3.3 升级指南;3.3 源码 DefaultSerializationSelector。混合版本的集群里,两端靠帧头里的序列化编号协商,这就是 ④-3 里“序列化编号”字段的用处。网上的资料常只写一半,看到“Dubbo 默认 fastjson2”要先问是哪个版本。

gRPC Java 1.84 的负载均衡清单

  • pick_first:默认(GrpcUtil.DEFAULT_LB_POLICY)。
  • round_robin:grpc-util 内置,defaultLoadBalancingPolicy("round_robin") 直接用。
  • weighted_round_robin、least_request_experimental、ring_hash_experimental:在 grpc-xds 模块里,classpath 上有这个 jar 就能按名字配,不需要真的连 xDS 控制面。名字里带 _experimental 的还不是稳定 API。
  • least_request 就是 P2C:随机抽两台比在途数。比“全量最少活跃”省事,又避免所有客户端同时扑向同一台“最闲”的实例。
面试

面试官会怎么问

Dubbo 和 gRPC 有什么区别?怎么选?

四件事逐项比:Stub(Java 接口运行时代理 vs .proto 编译期生成)、序列化(hessian2 自描述 vs protobuf 约定结构)、协议(16 字节头的私有 TCP 协议 vs HTTP/2 + 5 字节前缀)、发现和均衡(注册中心 + 默认加权随机 vs NameResolver + 默认 pick_first)。选法:跨语言、对外选 gRPC;Java 内部、要 Nacos 生态和服务治理选 Dubbo,Dubbo 3 的 Triple 协议也兼容 gRPC。

注册中心挂了,服务还能调用吗?

能。注册中心不在调用路径上(④-4),客户端用本地缓存的列表直连实例。挂掉期间的问题是列表不再更新:新上线的实例分不到流量,死掉的实例摘不掉,要靠客户端自己的失败剔除兜底。所以注册中心更看重可用性,宁可数据短暂不一致,也不能拒绝读。

RPC 调用超时了,你怎么处理?能直接重试吗?

先分清“没发出去”和“发出去了没等到”。连不上可以换一台重试;后者对面可能已经做了(⑤ 第 6 段实测),写接口直接重试会重复执行。要重试就先做幂等:业务唯一键去重、或者带一个请求号让服务端记住结果。同时检查超时是否逐级缩短,重试次数是否会把下游打垮(重试风暴)。

protobuf 为什么比 JSON 小、快?

不写字段名,只写 1 字节的“字段号 + 类型”;整数用 varint,小数字只占 1 字节;解码是按类型读固定格式,不用逐字符扫描、找引号和冒号。代价是要先写 IDL,字节不可读。同一个请求实测 7 字节对 35 字节。

下一圈

这一页没展开的

↺回到 ⑤:带着 ⑥ 改一遍

四道改造都在 ⑤ 的迷你 RPC 上做,study-labs 的 README 里有同样的题目。每道先猜结果,再运行。

1. 加幂等,再加重试

在 InventoryImpl 里按 orderId 去重,扣过就返回上次的结果;然后在 RpcClient.call 里加“超时重试 2 次”,模仿 Dubbo 的默认值。分别在去重前后跑第 6 段,看 deducted 是几(坑 1)。

2. 给最少活跃加上失败剔除

每个地址记连续失败次数,连续失败 5 次就踢出 1 秒,之后先放 1 个请求试探。再跑第 5b 段,目标是错误率降到 5% 以下(坑 2)。

3. 字段兼容

在 Serializers.writeValue 里给 ReserveRequest 多写一个字段 4,读取端不改,看会不会报错;再把字段 3 的编号改成 5,看 qty 读成了几(坑 4)。

4. 注册中心晚了一步

第 4 段里先关掉 A,200 ms 后才注销,看这段时间的调用会怎样;再给 RpcClient 加“连不上就换下一台”(坑 3)。

1. 用一句话说出 RPC 的精华。

把远程调用拆成四件事:Stub 把方法调用变成数据,序列化把数据变成字节,协议用长度切帧、用请求 id 让一条长连接并发跑很多调用,服务发现和负载均衡决定每次发给哪台活着的实例。它能让调用写起来像本地方法,网络的慢、失败和“做了一半”却还在,超时、重试、幂等都要在代码里明确处理。

2. 手写 HTTP + JSON 调接口,痛点在哪?

每个接口重复写拼 URL、拼 JSON、解析;没有契约,字段改名运行时才发现;地址写死,扩缩容和宕机都要人工改;没有负载均衡;JSON 大、解析慢;HTTP/1.1 一问一答,并发要靠连接池,连接数随并发涨(②)。

3. “超时后重试,库存扣了两次”,根源是哪条原理?

④-3:超时只是调用方删掉了 pending 表里的登记、不再等了,请求早已在连接上发出去,对面照样会执行。远程调用除了成功和失败,还有“不知道”这第三种结果,重试会把“不知道”变成“做了两次”。

4. 预测:把 RpcClient 的 pending 表换成“只记最后一个 Future”,16 个线程并发调,会怎样?

请求 id 失去作用(④-3)。线程 T1 刚登记,T2 就把它覆盖了;T1 的响应先回来时会被交给 T2,T2 拿到别人的结果;T1 永远等不到,直到超时。表现是偶发的“结果串了”加大量超时,单线程测试却完全正常。要么恢复按 id 配对,要么退回“一条连接同时只跑一个请求”,用连接池换并发。

5. 预测:两台健康的库存实例,B 在另一个机房,每次多 10 ms 往返。最少活跃会怎么分?轮询呢?

最少活跃会少给 B:B 的每个请求多在途 10 ms,在途数总是偏高(④-5),所以大部分请求自动留在近的 A,不用任何“机房”配置。轮询不看这些,一半请求白白多付 10 ms。这也是同城多机房里常把“同机房优先”做成单独路由规则的原因:最少活跃只能间接反映距离,而且 A 忙起来时还是会溢出到 B。

6. 预测:K8s 里 gRPC 客户端连 inventory 这个 Service(一个 ClusterIP),后面 3 个 Pod,配了 round_robin。流量怎么分?

几乎全打到一个 Pod。DNS 只解析出一个 ClusterIP,客户端眼里只有一个地址,round_robin 无从轮起,只建一条长连接;kube-proxy 是四层转发,按连接分,这条连接被钉在某个 Pod 上(④-6 服务端发现那一栏)。改法:用 headless Service,让 DNS 直接返回 3 个 Pod 的 IP,客户端自己均衡;或者上服务网格,让 sidecar 按请求分。

7. 预测:实验台选“都一样”预设,轮询和随机的平均延迟差不多,p99 谁更高?为什么?

随机更高(模拟里轮询约 5.9 ms,随机约 9.9 ms)。随机会短时间内连续掷到同一台,那台的 8 个业务线程瞬间不够用,请求开始排队;轮询严格错开,几乎不排队。均值被大量正常请求摊平了,尾延迟才看得出“分得不匀”。

8. 放回全景:第 1、2、4 站前后各发生了什么?这一页的东西出问题,整条链路会怎样?

第 1 站:下单线程调 stub.reserve(req),代理把它包成一次调用;第 2 站:名字解析器从注册中心拿到两台的地址,负载均衡挑一台;第 3 站拿到(或新建)这台的长连接;第 4 站 protobuf 把请求编成 7 字节;之后第 5 站装进 HTTP/2 帧,经 Netty、TCP 送到库存服务。这一层出事:不配 deadline,库存一卡订单线程全挂(坑 5);超时就重试,库存多扣(坑 1);坏实例没剔除,最少活跃把大半请求送去报错(坑 2);改错 .proto 编号,扣 0 件还返回成功(坑 4)。