关于 RPC 的追问往往是一条链:RPC 的完整流程是什么?链路上有哪些角色?注册中心选 AP 还是 CP,为什么 ZooKeeper 是 CP、Nacos 是 AP?再往深一层,一个工业级框架(比如 Dubbo 3)在调用的每个环节分别做了什么选择,又为什么这么选?
这四个问题其实是同一条线索:从”一次调用如何发生”,到”框架如何在每个环节上做工程取舍”。这篇文章沿着这条线走一遍,前半程讲通用原理,后半程用 Dubbo 3 当作案例,看理论落地时每个选择背后的账是怎么算的。
一、RPC 的完整流程与角色
1.1 一次调用的完整链路
以 Dubbo、gRPC 这类框架为例,一次典型的 RPC 调用经历下面几步:
sequenceDiagram
participant P as Provider
participant R as 注册中心
participant C as Consumer
P->>R: 启动时注册服务地址
C->>R: 订阅并缓存地址列表
C->>P: 本地代理发起调用(序列化请求)
P->>P: 反序列化后反射调用真实方法
P-->>C: 返回值序列化回传
C->>C: 代理反序列化,拿到结果
展开每一步:
- 服务注册:提供者启动时,把自己的地址(IP + 端口)和服务信息注册到注册中心。
- 服务发现:消费者启动时,从注册中心拉取(或订阅变更)提供者地址列表,缓存在本地。
- 本地调用:消费者调用的是一个本地接口代理(Stub / 动态代理),感觉不到网络的存在。
- 序列化:把方法名、参数类型、参数值打包成可传输的字节流(JSON、Protobuf、Hessian、Kryo 等)。
- 网络传输:字节流经协议(HTTP/2、TCP 自定义协议)发送,中间可能穿过负载均衡、熔断、重试等治理逻辑。
- 反序列化与反射调用:服务端解码出方法与参数,反射调用真实实现类。
- 结果返回:返回值同样经序列化、网络、反序列化,最终由消费者的代理交给业务代码。
RPC 的本质是让远程调用看起来像本地调用。代理 + 序列化 + 网络传输这三层,就是实现这个幻觉的全部秘密。
1.2 链路上的角色
| 角色 | 职责 |
|---|---|
| 服务提供者(Provider / Server) | 实现业务逻辑,启动时注册服务,接收并处理请求 |
| 服务消费者(Consumer / Client) | 发起调用的一方,通过本地代理调用远程服务 |
| 注册中心(Registry) | 服务的电话簿:提供者注册、消费者发现、地址变更推送 |
| 代理 / Stub(Client Stub 与 Server Stub) | 屏蔽远程调用细节:客户端代理做序列化和网络发起,服务端 Stub 做解码和反射调用 |
| 序列化组件 | 对象与字节流的互转(Protobuf、Hessian、Kryo、JSON 等) |
| 网络传输层(Protocol) | 承载请求的通信协议,如 TCP 自定义协议、HTTP/2 |
| 监控与治理组件 | 负载均衡、熔断限流、链路追踪、调用统计 |
前三个(Provider、Consumer、Registry)是最常说的三大角色,后面的组件都挂在它们之间。
二、反射调用:RPC 魔法的支点
2.1 为什么说反射是核心
RPC 要实现的幻觉是:调用者写的是本地接口调用,执行的却是远程方法。支撑这个幻觉的支点有两处,都靠反射:
- 客户端:接口根本没有实现类,
userService.getUser(1)能编译通过、能被调用,是因为框架在运行时用动态代理生成了一个”假实现”,把调用拦截下来转成网络请求; - 服务端:收到的是一堆字节(接口名 + 方法名 + 参数),JVM 里没有任何语法成分能”根据字符串调用方法”,只有反射的
Method.invoke能做到。
换句话说:动态代理负责”骗”调用方,反射负责让服务端”找得到”方法。两者合起来,远程调用才长得像本地调用。
2.2 客户端:动态代理拦截调用
一个最小化的客户端代理长这样:
// 代理类:把"方法调用"翻译成"请求对象"
public class RpcProxy implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
RpcRequest req = new RpcRequest();
req.setInterfaceName(method.getDeclaringClass().getName());
req.setMethodName(method.getName());
req.setParameterTypes(method.getParameterTypes()); // 关键
req.setArguments(args);
return client.send(req); // 序列化 → 网络发送 → 同步等待响应
}
}
// 消费者拿到的就是这个"假实现"
UserService userService = (UserService) Proxy.newProxyInstance(
loader, new Class[]{UserService.class}, new RpcProxy());
注意 parameterTypes 为什么必须传:方法是可以重载的,getUser(long id) 和 getUser(String name) 同名,服务端只靠方法名无法定位,必须靠参数类型列表唯一定位一个方法。
2.3 服务端:反射定位并调用真实方法
// 启动时注册:服务导出到本地缓存
Map<String, Object> serviceMap = Map.of(
"com.demo.UserService", new UserServiceImpl()
);
// 收到请求后的处理逻辑
public Object handle(RpcRequest req) throws Exception {
Object target = serviceMap.get(req.getInterfaceName()); // 1. 找到实现类实例
Method method = target.getClass().getMethod(
req.getMethodName(), req.getParameterTypes()); // 2. 反射定位方法
method.setAccessible(true); // 3. 跳过访问检查
return method.invoke(target, req.getArguments()); // 4. 真正执行
}
四步里第 2、4 步就是反射的核心:getMethod 用字符串还原出方法签名,invoke 把字节流里解出来的参数”打”到真实对象上。
2.4 反射的性能问题与框架的优化
裸反射是慢的:装箱拆箱、可变参数数组的组装、访问权限检查,而且 Method.invoke 对 JIT 不友好,无法内联,历史上还有反射调用计数超阈值后去优化的坑。主流框架的优化路线:
| 手段 | 原理 | 使用者 |
|---|---|---|
| 缓存 Method 对象 | getMethod 每次都遍历方法表,缓存后只查一次 | 几乎所有框架的基本功 |
setAccessible(true) | 跳过安全检查 | 同上 |
| 运行期字节码生成 | 用 Javassist、ASM 直接生成 target.getUser(id) 这样的强类型调用代码,把反射”编译”掉 | Dubbo 的 ProxyFactory、FastJSON2 的序列化器 |
| MethodHandle / invokedynamic | JVM 层面对反射的替代品,可被 JIT 内联 | 高版本 JDK 上的框架逐渐采用 |
另一个越来越重要的成本:GraalVM Native Image 里反射默认不可用,必须显式注册反射元数据。这是反射式 RPC(比如传统 Dubbo 应用)迁移 AOT 的主要开销,也是 Spring 6 推 AOT 元数据生成的背景之一。
2.5 反射不是唯一解
严格说,反射是 Java 等语言实现 RPC 的手段,不是 RPC 的必要组成。gRPC 的代码生成路线(protoc 生成强类型 Stub)就没有运行时反射这一环。两条路线的分工可以这样概括:
反射解决的是”不写模板代码”的问题;代码生成解决的是”调用快且类型安全”的问题。动态代理 + 反射是运行时生成调用逻辑,protobuf + Stub 是编译期生成调用逻辑——同一问题的两种解法。
三、注册中心:AP 还是 CP
3.1 判断标准
一句话:数据错了更痛,还是数据拿不到更痛?
服务发现的特殊性在于:注册中心只是”控制面”,真正的调用发生在”数据面”。消费者本地缓存了地址列表,注册中心短暂挂掉不影响存量调用;真正致命的是注册中心自己因为追求一致性而不可用,把控制面的故障扩散成数据面的全局雪崩。所以服务发现对可用性的要求,高于对一致性的要求——这也是当年 Eureka 那篇 “ZooKeeper is Incompatible with Service Discovery” 挑战 ZK 的核心论点。
3.2 ZooKeeper 为什么是 CP:ZAB 协议
ZAB(Zookeeper Atomic Broadcast)是一个崩溃恢复型共识协议,写路径是:
客户端写请求 → 任意 Follower 转发给 Leader
→ Leader 给 Proposal 分配全局递增的 zxid,广播给所有 Follower
→ 过半 Follower 持久化成功并 ACK
→ Leader 才提交(COMMIT),并响应客户端
两个关键推论:
推论一:分区时少数派主动”自杀”。 5 个节点被网络切成 2+3 两堆,只有拿到 3 票的那堆能继续工作;2 节点那堆发现自己没有 quorum,会停止对外服务,其上的客户端请求全部失败。宁可不可用,也不返回有分歧的数据。
推论二:Leader 宕机期间全集群不可写。 选主期间(通常几十秒级,极端情况更久),集群拒绝写请求,依赖它的注册与发现全部中断。
一个诚实的补充:ZK 的读请求默认由本节点直接返回,可能读到旧数据,所以它并非严格的线性一致;但在 CAP 分类里,它是分区时选择牺牲可用性的典型,通常归为 CP。对服务发现场景更麻烦的是会话模型的扰动放大:网络抖动或 GC 停顿导致会话超时,临时节点被批量删除,客户端先经历一波 Watch 风暴,再经历一波重新注册风暴。这在高变动、大规模的服务发现里是真实的生产事故来源。
3.3 Nacos 为什么是 AP:Distro 与数据分型
Nacos 做了一个 ZK 没做的设计:把数据分成两类,各用各的一致性协议。
| 数据类型 | 典型例子 | 协议 | 模式 |
|---|---|---|---|
| 临时数据(内存态、心跳维持) | 服务发现的实例地址 | Distro | AP |
| 持久数据(落盘、必须一致) | 配置内容、持久服务 | Raft | CP |
Distro 的工作方式:
- 集群每个节点按 key 哈希负责一部分数据的"主权"
- 写请求路由到主权节点,写入本地内存后立即返回成功
- 然后异步把增量数据通过 gRPC 同步给其他节点
- 每个节点都持有全量数据,任何节点都能独立响应读请求
- 实例靠客户端心跳续命,超时自动摘除;节点挂了,实例重新心跳即可重建数据
于是分区时:每个节点继续用自己手里的地址列表服务消费者——可用性保住了,代价是列表可能短暂过期(新上线的实例别人还没同步到)。教科书式的 AP:最终一致 + 分区容忍。
Nacos 敢这么做的前提,是它想清楚了那道选择题:服务实例地址是”可再生”的数据——哪怕全丢了,实例心跳一打就重建;配置是”不可再生”的数据——错一份就出生产事故。所以前者 AP,后者 CP。“按数据性质选一致性”这个思路,比”Nacos 是 AP”这句话本身更值得记住。
3.4 选型决策
| 维度 | 选 AP | 选 CP |
|---|---|---|
| 数据性质 | 临时的、可再生的(实例地址) | 持久的、不可再生的(配置、元数据) |
| 数据错了的后果 | 拿到失效地址,调用失败,客户端重试与熔断兜底 | 节点间不一致,部分实例行为分裂,排查困难 |
| 数据拿不到的后果 | 消费者无法发现服务,全局雪崩 | 短暂不可写,业务等一等 |
| 数据变更频率 | 高频(发布、扩容、抖动) | 低频 |
| 典型场景 | 服务发现、集群成员管理 | 配置中心、分布式锁、选主、命名服务 |
3.5 两个补充观点
客户端缓存可以把 CP 存储”用出 AP 效果”。 Kubernetes 是反例般的正面教材:etcd 明明是 CP(Raft),但 K8s 的服务发现工作得很好,因为 kube-proxy、CoreDNS 在节点本地有缓存和容错,数据面不直接依赖 etcd 的可用性。更精确的说法是:AP 不是唯一解,但”数据面绝不依赖控制面实时可用”是铁律;AP 注册中心只是让这条铁律更容易满足。
分区不是唯一要考虑的场景。 ZK 的问题不光是分区时 CP,还有前面说的”假下线”风暴。对高变动、大规模的服务发现,这种扰动放大的代价往往比分区本身更高——这也是 Nacos 用客户端心跳 + Distro 的重要原因。
四、Dubbo 3:工业级框架的答卷
Dubbo 3 的每一个选择,几乎都在回答同一个问题:如何在保留 Dubbo 2 易用性和治理能力的前提下,适配云原生和超大规模集群。先给总览,再逐环节展开。
| RPC 环节 | Dubbo 2 的做法 | Dubbo 3 的选择 | 改变的动机 |
|---|---|---|---|
| 代理层 | Javassist 字节码代理 | 保留(SPI 可换) | 已经够快,不动 |
| 调用模型 | 同步为主,异步是补丁 | 全链路 CompletableFuture | 异步不再有独立编程模型 |
| 序列化 | hessian2 默认 | fastjson2 自动协商(3.2+),Triple 下 Protobuf | 性能 + 安全 |
| 协议 | dubbo 私有 TCP 协议 | Triple(基于 HTTP/2,兼容 gRPC) | 云原生、网关、跨语言、流式 |
| 传输层 | Netty 长连接 | Netty + HTTP/2 多路复用,3.3 支持 HTTP/3 | 消除大报文下的队头阻塞 |
| 服务发现 | 接口级注册(URL 全量) | 应用级注册 + 元数据点对点拉取 | 注册量从”接口数 × 实例数”降为”实例数” |
| 元数据 | 塞在注册中心的 URL 参数里 | 独立元数据中心 + revision 聚合 | 数据量与治理能力解耦 |
| 部署形态 | SDK only | SDK / Sidecar mesh / Proxyless mesh | 治理能力可选、可下沉 |
4.1 代理层:Javassist,保留不动
Dubbo 从 2.x 起就用 Javassist 在运行期生成代理类,而不是 JDK 动态代理。回顾第二节讲的反射开销:JDK 的 Method.invoke 无法被 JIT 内联,而 Javassist 生成的是强类型的直接调用代码,等于把反射”编译”掉了。
Dubbo 3 没有动这一层——因为这不是瓶颈,也不是云原生化 的障碍。改架构时只改有明确收益的部分;代理层通过 SPI(ProxyFactory 扩展点)保持可替换,就够了。
4.2 调用模型:全链路异步统一
Dubbo 2 的同步和异步是两套 API:async=true 加 Future 的语义破碎,RpcContext 基于 ThreadLocal,异步后上下文串味。Dubbo 3 把整个调用链改造为以 CompletableFuture 为一等公民:同步调用只是在异步结果外面包了一层 future.get(),Filter 链、Cluster 层、协议层内部全部流转 CompletableFuture。
为什么必须这么改:Triple 协议天生是异步的(HTTP/2 stream),流式调用(客户端流、服务端流、双向流)无法用”同步阻塞 + ThreadLocal”的模型表达。协议和编程模型必须一起改,否则新协议就是空中楼阁。
4.3 序列化:hessian2 到 fastjson2 的自动协商
Dubbo 3.2 引入序列化协议自动协商:两端都是 Dubbo 3 且 classpath 里有 fastjson2,就自动用 fastjson2,否则回退 hessian2,过程对用户透明。
换的理由:性能上 fastjson2 基于 ASM 生成序列化器,路径接近手写代码;安全上 hessian2 是反序列化漏洞的重灾区,fastjson2 默认开启防护。为什么不强制换:序列化是最难升级的环节(两端必须同时换),所以选择协商而非一刀切——又一次平滑迁移优先的取舍。
Triple 协议下则统一用 Protobuf(Binary 或 JSON),因为要跨语言、要与 gRPC 生态互通,Java 私有的对象序列化格式在这里没有生存空间。
4.4 协议:Triple,最大的一笔押注
dubbo 协议是基于 TCP 的高性能私有协议,缺点官方说得很直白:通用性差。私有二进制协议意味着:
- Envoy、网关、K8s Ingress 这些通用基础设施读不懂你的流量,服务网格里 sidecar 无法基于内容路由,必须定制;
- 跨语言要为每种语言实现一遍协议栈;
- 没有流式语义;
- HTTP 生态(鉴权、压缩、可观测性、调试工具)全部享受不到。
Triple 的选择是:基于 HTTP/2、100% 兼容 gRPC 的 wire format,但保留 Dubbo 的编程模型。为什么不是直接用 gRPC?
| gRPC | Triple | |
|---|---|---|
| 通信格式 | Protobuf over HTTP/2 | 相同(兼容 gRPC) |
| 编程模型 | 必须先写 IDL,生成 Stub | Java 接口加注解即可,也支持 IDL |
| 服务发现与治理 | 官方不带,自己搭 | 内置 Dubbo 的注册发现、负载均衡、熔断 |
Triple = gRPC 的互通性 + Dubbo 的易用性和治理体系。wire format 兼容是单向受益的:所有 gRPC 客户端、网关、代理天然认识 Triple 流量。
一个值得注意的兼容性设计:协议默认值至今仍是 dubbo(官方文档明确说”考虑到对过往版本的兼容性,各发行版本均默认使用 dubbo 协议”),但强烈建议新用户显式配置 tri。这不是技术犹豫,而是升级策略:老集群里有海量 2.x 消费者,默认值一变就是断崖。配套的迁移路径是 3.3 的”单端口双协议 + preferred-protocol 首选协议”:Provider 在同一端口同时发布 dubbo 和 triple,注册地址里带上首选协议标记,消费者逐个升级后自动切换,全程无感。
3.3 之后 Triple 进一步吃下了 REST:基于同一套 HTTP 协议栈,加注解就能以 REST 风格发布 HTTP 服务(支持 Spring MVC、JAX-RS 方言),并且支持 HTTP/3(QUIC,通过 Alt-Svc 协商自动升级)。意图很清晰:让 Triple 同时覆盖东西向(RPC)和南北向(HTTP/REST)流量,用户不再需要”Web 框架管南北向、RPC 框架管东西向”两套栈。
4.5 传输层:HTTP/2 多路复用
dubbo 协议默认单条 TCP 长连接跑所有请求。单连接模型在小报文高并发下很高效(连接少、握手少),但大报文会阻塞同连接上的其他请求。Triple 换成 HTTP/2 后天然获得 stream 多路复用:一条连接上并发成百上千个 stream,互不阻塞;3.3 再进一步支持 HTTP/3 解决 TCP 层队头阻塞和弱网重连。
这笔账可以这样算:私有协议当年”省掉 HTTP 头”的性能收益,在 HTTP/2 头压缩(HPACK)加多路复用面前已经不剩多少,而互联互通的代价却越来越大——账算过来,就是 Triple。
4.6 服务发现:接口级到应用级,变更最剧烈的一环
Dubbo 2 的接口级模型:每个接口注册一条完整 URL(含全部配置参数),注册中心的数据量等于接口数 × 实例数。官方给出的实测量级:一个有 10 个 Service、部署 100 个实例的应用,产生 1000 条地址数据;阿里巴巴、工商银行这类规模的集群,注册中心容量触顶,消费端框架常驻内存超过 40%。数据被两个维度同时放大:100 个唯一地址被放大 10 倍,10 份唯一服务元数据被放大 100 倍。
flowchart TB
subgraph G2 ["Dubbo 2:接口级发现"]
A2["注册中心数据量 = 接口数 × 实例数"]
B2["每条 URL 携带全部配置元数据"]
end
subgraph G3 ["Dubbo 3:应用级发现"]
A3["注册中心只存应用名到实例地址"]
B3["富元数据走元数据中心与点对点拉取"]
end
G2 -->|"注册量与业务规模解耦"| G3
Dubbo 3 的解法分三步:
- 注册中心只存”应用名 → 实例地址”。数据量降为实例数,与接口数彻底解耦——业务 RPC 重构不再影响服务发现集群的容量稳定性,且与 K8s、Spring Cloud 的发现模型对齐。
- 元数据不再走注册中心,改为点对点拉取。Dubbo 3 内置了一个
MetadataService元数据服务,Consumer 拿到精简地址后,直接 RPC 调用 Provider 拉取”这个实例导出了哪些接口、什么配置”,在消费端聚合还原出类似 Dubbo 2 的运行态 URL 模型。 - revision 去重。内容相同(同一套接口和配置)的实例计算出相同 revision,共享一份元数据——100 个实例部署得完全一样,元数据实际只有 1 份。
应用级模型带来一个新问题:Consumer 只知道自己要消费哪些接口,怎么知道该订阅哪个应用?Dubbo 3 引入独立的元数据中心,Provider 启动时上报”接口名 → 应用名”映射(一对多)。Consumer 订阅前先查这个映射,转换成应用名再做精确订阅。
迁移路径同样贯彻平滑:Provider 双注册(register-mode=all),Consumer 双订阅(APPLICATION_FIRST 智能决策),且两份地址完全隔离、绝不混用(要么全用接口级、要么全用应用级,第一次地址通知时定死),确认全部迁移后再切单注册。
这一节可以提炼一个观点:Dubbo 3 的服务发现 = Spring Cloud 的数据模型 + Dubbo 的治理能力。注册中心只当”电话簿”(AP 足矣,呼应第三节的选型讨论),治理所需的富元数据走旁路。把”发现的大规模”和”治理的精细化”这两件冲突的事拆到两条链路上,不再让注册中心同时背两个 KPI。
4.7 集群容错与负载均衡:保留加增强
这一层 Dubbo 3 基本继承了 Dubbo 2 的设计,因为它恰恰是 Dubbo 的护城河而非负担:
- 容错策略:Failover(默认,失败自动换节点重试)、Failfast、Failsafe、Failback、Forking、Broadcast——把”重试”做成调用方的集群语义,而不是让每个业务自己写 try-catch;
- 负载均衡:Random(默认)、RoundRobin、LeastActive、ConsistencyHash,Dubbo 3 增加了自适应负载均衡(基于节点实时负载的 P2C 思路),把”最闲优先”从静态统计升级为动态探测;
- Filter 链 + SPI:整个 Invoker 链是责任链,每个环节都是 Adaptive SPI 扩展点——这是 Dubbo 能长出庞大生态(鉴权、tracing、限流全靠 Filter 插进来)的架构根基,3.x 严格保留了它。
4.8 部署形态:三态并存
Dubbo 3 的最终愿景是让治理能力可选、可下沉:
- 传统 SDK 模式:治理能力全在客户端,性能最好;
- Sidecar mesh:Triple 基于 HTTP/2,Envoy 等 sidecar 可以直接解析流量做路由——这正是私有 dubbo 协议做不到、必须发明 Triple 的原因之一。但 sidecar 每跳增加两段网络和两次代理,性能损耗大;
- Proxyless mesh:客户端直连,控制面(如 Istio)只下发 xDS 配置给 Dubbo SDK——保留 mesh 的统一控制面,又不付 sidecar 的性能税。
这也解释了为什么应用级服务发现如此重要:sidecar 和 xDS 的世界只认”应用、服务”粒度,不认识 Dubbo 的接口粒度。应用级发现是 Dubbo 融入 mesh 生态的入场券。
五、总结:Dubbo 3 的三条主线
回头看,Dubbo 3 的所有选择可以收拢成三条主线:
- 协议云原生化(Triple):用 HTTP/2 换掉私有协议,得到网关友好、跨语言、流式、mesh 兼容,代价用”wire format 兼容 gRPC”来抵消;
- 服务发现规模化(应用级):注册中心数据从 O(接口数 × 实例数) 降到 O(实例数),富元数据旁路化,规模与治理能力解耦;
- 迁移平滑化:序列化自动协商、双注册双订阅、单端口双协议——每一个破坏性变更都配了一条对用户无感的升级路径,这本身就是 Dubbo 3 最重要的架构选择。
原理层的结论同样可以收拢成一句话:RPC 的本质是”让远程调用看起来像本地调用”,代理和反射负责幻觉本身,注册中心的 AP/CP 取舍和框架的工程化设计,负责让这个幻觉在生产环境的规模、故障和升级中撑住不破。
Sources
- Dubbo 支持的 RPC 通信协议 — Apache Dubbo 官方文档
- 应用级服务发现 — Apache Dubbo 官方文档
- 应用级地址发现迁移指南 — Apache Dubbo 官方文档
- 应用级服务发现 vs 接口级服务发现 — Apache Dubbo 官方文档
- 元数据中心概述 — Apache Dubbo 官方文档
- 升级到 triple 协议 — Apache Dubbo 官方文档
- Apache Dubbo 3.3 全新发布:Triple X 领衔,开启微服务通信新时代 — Apache Dubbo 官方博客 (2024-09-11)