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 框架本身做了什么。
需求(假设场景):工作里用 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.84 | Dubbo 3.3 | OpenFeign + Spring Cloud | |
|---|---|---|---|
| Stub 怎么来 | 从 .proto 编译期生成 | Java 接口,运行时生成代理 | Java 接口 + 注解,JDK 动态代理 |
| 序列化 | protobuf | hessian2(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 都长成这个样子。
④-0 一次调用的全程
④-1 Stub:动态代理把方法调用变成数据
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 序列化:要不要把“结构”写进字节里
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 协议:帧头里每个字段都在防一件事
da bb、1 字节标志位(请求还是响应、要不要回复、是否心跳、5 位序列化编号)、1 字节状态、8 字节请求 id、4 字节体长度。gRPC 把这些交给 HTTP/2:请求 id 就是 stream 编号,消息前只加 5 字节(1 字节压缩标志 + 4 字节长度)。| 字段 | 没有它会怎样 |
|---|---|
| 魔数 | 端口连错、或者读错了位置,只会解出一堆乱码;有魔数,第一帧就能报“这不是本协议”并断开 |
| 版本、序列化编号 | 升级协议、换序列化时,新老节点没法共存,只能全部一起停机升级 |
| 体长度 | TCP 不管消息边界,不知道这条消息到哪结束(粘包);顺便要设上限,防止一个坏长度让对面分配几百 MB |
| 请求 id | 一条连接上同时只能有一个请求在途,要并发就得多开连接。有了它,响应可以乱序回来,各自对号入座 |
④-4 服务发现:名字换地址,而且地址是活的
④-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 的默认 |
为什么“在途请求数”能反映快慢:慢的实例每个请求要占 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: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 →
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();
}
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 字节才返回
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 一模一样。
⑥细节:超时不等于没做,最闲的那台可能是坏的
每个坑旁边的徽标指回 ④ 里推出它的那条原理。标“实测”的在 ⑤ 的输出里能找到,标“按源码”“按文档”的给了出处。
注意事项
- 现象
- ⑤ 第 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去重,同一订单扣过就返回上次的结果。读接口才放心重试。
- 现象
- ⑤ 第 5b 段:3 台实例,其中 1 台数据库连不上,每个请求立刻返回错误。轮询的错误率 33.3%;最少活跃 60.8%~67.4%,比不做均衡还糟。
- 根源
- 坏实例一收到请求就回错误,在途数几乎总是 0,在最少活跃看来它最闲。这个策略只数“还没回来的”,不管回来的是成功还是失败。
- 做法
- 失败也要算进去:按实例统计错误率或连续失败次数,超过阈值就暂时踢出,过一会儿放一点流量试探(离群剔除,也叫实例级熔断)。gRPC 有现成的
OutlierDetectionLoadBalancer(grpc-util 1.84,按成功率或失败比例剔除);Dubbo 一般配合 Sentinel 一类组件做。下面的实验台第三个预设可以亲手试。
- 现象
- 某台机器断电,或者进程被
kill -9,之后几十秒里一部分请求报“连接被拒”或超时;发布时也会有一小撮报错。 - 根源
- 进程来不及注销,注册中心只能等心跳超时。Dubbo 接 ZooKeeper 时会话超时默认 60 秒(3.3 源码
AbstractZookeeperClient.DEFAULT_SESSION_TIMEOUT_MS)。发布时先停进程、后注销,也会留出这样一个窗口。 - 做法
- 发布顺序:先从注册中心注销,等在途请求做完,再停进程;启动反过来,预热好再注册(全景第 3 站的冷启动)。客户端对“连不上”的实例自动换下一台:请求根本没发出去,这种重试是安全的。
- 现象
- 有人觉得
qty该放到后面,把它从 3 号改成 4 号。还没升级的订单服务继续按 3 号发,新库存服务不认识 3 号,直接跳过,qty读成默认值 0,扣了 0 件,返回成功。 - 根源
- 不认识的编号被跳过,正是向前兼容的机制,所以这里不会报错。自描述格式的坑在另一边:hessian2 和 JDK 序列化按类名找类,改了包名或类名,对面直接反序列化失败;JDK 序列化没写
serialVersionUID时,加一个字段也会报InvalidClassException。 - 做法
- 只加字段,不改已有字段的编号和类型;删掉的编号写进
reserved,以后谁也不能再用(protobuf 官方指南)。Java 接口包里的 DTO 一样:只加不改,固定serialVersionUID,不随手挪包。
- 现象
- 库存服务数据库锁住了,订单服务的线程一个个停在
future.get()上,线程池满,下单接口全部超时,故障从库存传到订单再传到网关。 - 根源
- gRPC 默认没有 deadline(
CallOptions的 deadline 默认是 null),不设就一直等。Dubbo 默认 1000 ms,但若网关只等 800 ms,库存还在白干。 - 做法
- 每个调用都配超时,越往下游越短。gRPC 的 deadline 会通过
grpc-timeout头传给下游(全景第 5 站抓到的299936u就是剩下的时间),下游再调别人时直接沿用剩余时间。
- 现象
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。
- 现象
- 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)
withDeadlineAfter下游一卡,上游线程池被拖满(坑 5)
超时后重试,库存扣两三次(坑 1)
默认 pick_first 全打一台;坏实例被最少活跃当成“最闲”(坑 2)
死实例还在列表里,发布期间一小撮报错(坑 3)
N+1 次往返;对面慢多久,本地行锁就多占多久(②)
4 MiB 上限报错,或一个大响应拖慢同一条连接(坑 6)
反序列化远程执行代码(坑 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.1 | hessian2 |
| 3.2.x | fastjson2(对端低于 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 字节。
这一页没展开的
- 注册中心本身:ZooKeeper(CP)和 Nacos / Eureka(AP)为什么选择不同(J10 服务治理)
- 一致性哈希和虚拟节点:加一台机器时,只有多少比例的 key 要搬家
- 用 Netty 重写 ⑤ 的迷你 RPC:读线程换成 EventLoop,帧解码换成
LengthFieldBasedFrameDecoder - gRPC 的流式调用和流量控制:HTTP/2 窗口怎么做到背压
- 泛化调用:网关不依赖接口包,怎么调任意服务(以及 CVE-2023-23638 为什么出在这里)
- 全链路 deadline 和链路追踪:trace id 怎么跟着元数据一路传下去
↺回到 ⑤:带着 ⑥ 改一遍
四道改造都在 ⑤ 的迷你 RPC 上做,study-labs 的 README 里有同样的题目。每道先猜结果,再运行。
在 InventoryImpl 里按 orderId 去重,扣过就返回上次的结果;然后在 RpcClient.call 里加“超时重试 2 次”,模仿 Dubbo 的默认值。分别在去重前后跑第 6 段,看 deducted 是几(坑 1)。
每个地址记连续失败次数,连续失败 5 次就踢出 1 秒,之后先放 1 个请求试探。再跑第 5b 段,目标是错误率降到 5% 以下(坑 2)。
在 Serializers.writeValue 里给 ReserveRequest 多写一个字段 4,读取端不改,看会不会报错;再把字段 3 的编号改成 5,看 qty 读成了几(坑 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)。