RPC 高级工程实践与源码脉络:异步、安全、可观测、流量治理与协议演进
RPC 真正进入生产环境之后,问题很快就会超出“如何完成一次远程方法调用”的范围:吞吐量为什么上不去、内部服务如何鉴权、跨服务异常怎样定位、大量超时任务怎样低成本调度、架构升级如何利用真实流量验证、隔离集群怎样快速扩容、没有接口 Jar 如何调用服务,以及新旧 RPC 协议如何平滑共存。本文从这些工程问题出发,结合一套插件化、纯异步 RPC 的代码结构,把性能、安全、可观测性、流量治理、泛化调用、多协议兼容与 RPC 技术演进串成一套完整的设计脉络。
RPC 的高级阶段:从通信工具走向分布式基础设施
如果只看最基础的调用过程,RPC 的职责似乎很简单:
1 | |
但只要系统进入生产环境,问题马上会变得复杂。
一个成熟 RPC 框架至少需要面对下面几类问题:
| 问题 | RPC 需要提供的能力 |
|---|---|
| 请求大量等待下游,机器吞吐量上不去 | 异步 RPC |
| 谁都可以拿接口 Jar 调用服务 | 身份认证、接口/方法授权 |
| A → B → C → D 出错,不知道问题在哪 | 异常体系、分布式链路追踪 |
| 数万请求都需要超时检测 | 时钟轮 |
| 重构后如何证明行为没有改变 | 流量录制与回放 |
| 分组隔离后突发流量无法借用其它资源 | 动态分组 |
| 网关、测试平台不能依赖所有接口 Jar | 泛化调用 |
| 老 RPC 无法一次性全部迁移 | 多协议兼容 |
| 功能越来越多,框架难以维护 | 微内核与插件化 |
因此,RPC 更适合作为一种分布式通信与服务治理基础设施来理解,而不仅仅是一个“远程方法调用库”。
从这个视角出发,后面的很多设计就能够连起来了。
一个可治理 RPC 的整体结构
一套插件化 RPC 可以大致拆成四层:
flowchart TB
subgraph L1["入口层"]
Proxy["动态代理"]
Generic["泛化调用"]
Filter["Filter 调用链"]
Trace["链路追踪 / 鉴权 / 流量旁录"]
end
subgraph L2["集群层"]
Registry["Registry 服务注册与发现"]
Cluster["Cluster 集群管理"]
Route["路由"]
LB["负载均衡"]
Group["分组与动态分组"]
end
subgraph L3["协议层"]
Protocol["Protocol"]
Codec["编解码"]
Serialize["序列化"]
MultiProtocol["多协议识别"]
end
subgraph L4["传输层"]
Client["Client"]
Server["Server"]
Channel["Channel"]
Future["Future / FutureManager"]
Timer["超时 / 时钟轮"]
end
L1 --> L2
L2 --> L3
L3 --> L4
这个分层非常重要,因为后面几乎所有高级能力,都能找到一个自然的插入点。
例如:
- 异步调用最终落在
Invoker、Future和传输层; - 鉴权、链路追踪、流量录制适合进入 Filter 或请求处理链;
- 动态分组属于 Registry、Cluster 和路由体系;
- 泛化调用主要改变入口层和序列化逻辑;
- 多协议兼容主要发生在 Protocol 与 Codec 层;
- 超时检测则可以交给传输层的时钟轮。
一个好的 RPC 框架不是把所有功能塞进一个巨大调用函数,而是让这些能力围绕稳定的核心调用链进行扩展。
异步 RPC:吞吐量问题很多时候不是 CPU 不够
阻塞等待才是隐藏成本
一个很典型的现象是:
1 | |
这并不一定意味着 RPC 框架性能差。
如果一个请求内部还要同步调用多个耗时下游,那么线程的大量时间实际上处于等待状态。等待中的线程不会持续消耗 CPU,但它仍然占用了宝贵的并发资源。
假设业务需要访问四个彼此独立的服务:
1 | |
顺序调用的理想耗时约为:
1 | |
如果四次调用能够并行发出:
1 | |
整个等待时间理论上就可能接近 10ms。
这里真正提高的不是某一次 RPC 的网络性能,而是把原来的串行等待变成了并行等待。
需要注意,这只有在几个调用之间不存在数据依赖时才成立。如果:
1 | |
那么业务本身就是串行依赖,改成 Future 并不能凭空获得四倍吞吐量。
RPC 的同步调用,内部往往本来就是异步的
RPC 调用端发送请求和接收响应,本来就是两个不同时间发生的事件。
一个典型实现会在发送请求之前创建 Future:
1 | |
发送请求:
1 | |
服务端处理完成后返回:
1 | |
调用端收到响应后,根据 requestId 找回 Future:
1 | |
然后把结果写入 Future。
整个过程可以表示为:
sequenceDiagram
participant B as 业务代码
participant C as Consumer RPC
participant S as Provider RPC
participant W as 服务端业务执行
B->>C: invoke()
C->>C: 创建 Future
C->>C: 保存 requestId -> Future
C->>S: Request(requestId)
S->>W: 执行业务
W-->>S: result
S-->>C: Response(requestId, result)
C->>C: 根据 requestId 找到 Future
C->>C: complete(result)
C-->>B: 返回结果
因此所谓同步 RPC,很多时候只是:
1 | |
框架替业务代码主动等待了 Future。
而异步调用则是不立即执行这个等待动作,让调用者决定什么时候消费结果。
从实现角度看:
同步往往只是异步调用之上的一种阻塞式使用方式。
从 Future 进一步走向 CompletableFuture
传统 Future 最大的问题在于,它更适合“以后来取结果”,却不擅长组织后续异步逻辑。
CompletableFuture 则可以把“结果完成”本身变成事件。
RPC 接口可以直接返回:
1 | |
整个过程便可以变成:
sequenceDiagram
participant Caller as 调用方
participant CRPC as Consumer RPC
participant PRPC as Provider RPC
participant Biz as Provider Business
Caller->>CRPC: 调用接口
CRPC-->>Caller: CompletableFuture
CRPC->>PRPC: 发送请求
PRPC->>Biz: 异步执行业务
Biz-->>PRPC: complete(result)
PRPC-->>CRPC: 返回响应
CRPC->>CRPC: complete(result)
CRPC-->>Caller: 触发后续回调
这样调用端和服务端都可以围绕 CompletableFuture 构建真正的异步调用链。
服务端为什么也要异步
服务端收到网络数据后,通常至少经历:
1 | |
网络 IO 线程不应该承担慢业务,否则某一个慢请求就可能拖住大量网络事件。
因此常见结构是:
1 | |
但是业务线程池同样是有限资源。
如果业务里面大量存在数据库、远程服务等等待操作,简单地把线程池从 200 改成 500、1000,通常只是把问题推迟,并没有改变业务本身的资源模型。
一种方案是让服务方法返回异步结果:
1 | |
这里有一个很容易产生的误区:
异步不会让真正的计算和 IO 工作凭空消失。
如果最终执行慢业务的线程池也已经饱和,系统照样会达到容量上限。
异步的价值在于:
- 减少 RPC 工作线程长时间被等待操作占用;
- 允许请求之间更灵活地并发;
- 避免共享 RPC 线程池被少数慢接口长期占满;
- 更容易进行任务编排。
但它并不是“无限并发按钮”。
RPC 安全:内网不等于没有边界
第一个问题:谁有资格调用我
RPC 经常运行在内部网络里,因此它面对的安全问题和公网 Web 系统并不完全一样。
一个很现实的问题是:服务提供方把接口 Jar 发布到内部仓库后,理论上任何拿到依赖坐标的应用,都可以创建 RPC Proxy。
例如:
1 | |
如果服务提供方不知道调用来自哪个应用,就无法区分:
1 | |
和:
1 | |
这不仅是安全问题,也直接影响容量治理。
一个突然增加的调用方,很可能成为压垮服务的最后一部分流量。
身份认证应该进入 RPC 调用链
解决问题的第一步,是让调用方拥有稳定的应用身份。
可以设计一个授权控制面:
flowchart LR
Caller["调用方应用"] -->|申请接口权限| Auth["授权平台"]
ProviderOwner["服务负责人"] -->|审批| Auth
Auth -->|颁发凭证| Caller
Caller -->|RPC 请求 + 应用身份/凭证| Provider["服务提供方"]
Provider --> Verify["本地认证"]
Verify --> Authorize["接口/方法授权"]
Authorize --> Business["业务方法"]
如果让每一次 RPC 请求都先访问授权中心:
1 | |
会产生一个严重问题:
所有 RPC 流量都依赖同一个认证服务。
当公司内部 RPC 调用量足够大时,认证服务本身就会成为性能瓶颈和全局故障点。
更合理的思路是:
1 | |
这样授权平台不需要出现在每一条业务请求的同步链路中。
关于 HMAC,需要分清“认证”和“加密”
一种可以用于本地校验的技术是 HMAC。
严格来说:
HMAC 不是加密算法,也不是基于公私钥的非对称数字签名,而是使用共享密钥计算的消息认证码(Message Authentication Code)。
真正有价值的设计思想是:
1 | |
也就是说,把认证的高频判断从中心节点移动到服务提供方。
认证之后还需要授权
仅知道:
1 | |
还不够。
因为一个接口通常有多个方法:
1 | |
很可能调用方只应该拥有:
1 | |
而没有:
1 | |
因此权限模型至少应该能够描述:
1 | |
例如:
1 | |
授权关系可以由统一平台管理,再同步到服务本地缓存或配置体系,由 Provider Filter 在业务执行之前检查。
服务提供方本身也需要认证
安全问题并不只发生在 Consumer。
如果某个应用拿到了接口定义,也有可能尝试注册一个假的 Provider。
因此注册中心还应该具备类似下面的约束:
1 | |
注册过程就变成:
sequenceDiagram
participant P as Provider
participant R as Registry
participant M as 接口与应用绑定关系
P->>R: register(OrderService, appId)
R->>M: 查询允许发布该接口的应用
M-->>R: order-service
alt appId 匹配
R-->>P: 注册成功
else appId 不匹配
R-->>P: 拒绝注册
end
这样安全体系形成了两个方向:
1 | |
RPC 的安全边界才真正闭合起来。
分布式故障定位:不要让异常只剩一句 timeout
为什么分布式系统越来越难排查
单体应用发生异常时,可以直接从一个进程的日志往下看。
而分布式系统可能是:
1 | |
A 收到的异常,很可能真正来自 D。
如果只得到:
1 | |
维护 A 的开发者甚至不知道:
- 哪个 RPC 接口超时;
- 调的是哪个节点;
- 调用端 IP 是什么;
- Provider IP 是什么;
- 是连接超时还是响应超时;
- 是 RPC 框架异常还是业务异常;
- 中间到底经过了哪些服务。
因此,一款 RPC 框架的异常设计本身就是可观测能力的一部分。
异常必须携带可定位信息
RPC 框架的异常至少应该考虑:
1 | |
例如相比:
1 | |
下面的信息要有价值得多:
1 | |
异常码还应该形成稳定的分类体系:
1 | |
具体编码方式不是重点,重点是:
异常必须能够从“发生错误”进一步回答“错误发生在哪里、属于什么类型、应该从哪里开始查”。
Trace 与 Span
仅靠异常信息依然无法完整解决跨服务问题。
如果调用链是:
1 | |
维护 A 的人可能根本不知道 B 后面还有 C 和 D。
这就需要分布式链路跟踪。
一次完整调用可以表示成一个 Trace:
1 | |
其中每一段远程调用是一个 Span:
1 | |
并通过父子关系建立链路:
flowchart LR
A["Service A"]
B["Service B"]
C["Service C"]
D["Service D"]
A -->|"Span1"| B
B -->|"Span2 parent=Span1"| C
C -->|"Span3 parent=Span2"| D
一次 Trace 最终就可以回答:
1 | |
定位问题的方式从:
1 | |
变成:
1 | |
RPC 接入链路追踪的核心只有两件事
埋点
调用端:
1 | |
服务端:
1 | |
这些位置天然适合产生 Span 数据。
传递
上游必须把:
1 | |
传递给下游。
因此 RPC 协议通常需要保留类似附件或隐式参数的区域:
1 | |
这样下游才能创建正确的子 Span。
这里还有一个非常重要的工程问题:
1 | |
如果 TraceId 只存在 ThreadLocal 中,异步切换线程后上下文就可能丢失。
同样:
1 | |
也必须继续传播 Trace 上下文。
所以在异步 RPC 体系中:
上下文传播与异步模型必须同时设计。
否则业务是异步了,链路也“异步没了”。
时钟轮:高并发 RPC 为什么不应该扫描所有超时任务
Future 带来了另一个问题:超时
调用端每发送一个 RPC 请求,都会创建一个 Future:
1 | |
如果服务端一直不返回,这个 Future 不可能永久存在,因此还需要:
1 | |
问题随之而来:
几万甚至几十万个 Future,怎样高效判断谁已经超时?
最直接的方法:一个任务一个线程
例如:
1 | |
假设每秒发出 2 万请求,超时时间为 5 秒,那么理论上可能同时存在约:
1 | |
显然不能为每个任务创建一个线程。
第二种方法:一个线程扫描所有任务
于是可以改成:
1 | |
这解决了线程数量问题,却又制造了大量无意义遍历。
一个 5 秒后才超时的任务,在真正到期前可能被扫描几十次。
所以问题变成:
能不能只在任务快要到期的时候才看它?
这就是时钟轮解决的问题。
时钟轮的基本模型
假设:
1 | |
那么:
1 | |
第一层时间轮可以表示成:
1 | |
任务按照剩余时间落入槽位。
例如:
1 | |
可以粗略分配成:
1 | |
C 的执行过程是:
flowchart LR
C["Task C: 1610ms"] --> L2["第二层时间轮 slot1"]
L2 -->|"约 1s 后下沉"| L1["第一层时间轮 slot6"]
L1 -->|"再经过约 600ms"| E["执行 Task C"]
第一层时间轮完成一整圈后,第二层向前推进一个槽位,并把该槽中的任务重新分配到第一层。
它的核心不是提高时间精度,而是:
通过分桶减少无意义扫描。
时间精度与槽位数量的取舍
时间轮有两个很重要的参数。
tick 越小,精度越高
例如:
1 | |
理论调度粒度就在 10ms 级别。
如果:
1 | |
调度粒度就是 100ms 级别。
实际执行时间还会受到系统调度和任务执行器负载影响,因此时钟轮更适合高吞吐定时调度,而不是硬实时场景。
槽位越多,长任务越少进入高层时间轮
例如:
1 | |
那么 10 秒以内的任务都可以待在第一层。
如果只有:
1 | |
超过 100ms 的任务就需要进入更高层。
槽位越多通常意味着更多空间成本,但可以减少任务在多层之间迁移。
RPC 中哪些地方适合时钟轮
最典型的是:
- 请求超时;
- Client 启动超时;
- Server 启动超时;
- 心跳;
- 连接空闲检测;
- 延迟重试;
- 其它大量轻量级定时任务。
周期性任务也不一定需要特殊模型。
例如心跳任务执行完成后:
1 | |
即可继续循环。
流量回放:把线上真实请求变成回归测试资产
TestCase 为什么不够
应用重构时最麻烦的问题通常不是:
1 | |
而是:
1 | |
测试代码只能覆盖我们预想到的场景。
但线上流量包含的是用户实际产生过的场景。
因此可以把:
1 | |
转换成:
1 | |
这就是流量回放。
RPC 为什么天然适合录制流量
应用间调用都经过 RPC:
1 | |
所以 RPC 可以同时看到:
1 | |
那么只需要增加旁路:
flowchart LR
Caller["线上调用方"] --> RPC["RPC Provider"]
RPC --> Biz["原业务逻辑"]
RPC -. "异步旁录请求/响应" .-> Store["流量存储"]
Store --> Replay["回放应用"]
Replay --> New["改造后的 Provider"]
New --> Compare["结果比较"]
Store --> Compare
这里最重要的是:
录制过程不能显著影响真实业务链路。
因此旁录数据通常应该异步处理,而不是:
1 | |
否则测试能力反而会成为业务故障源。
回放的基本过程
可以抽象成四步:
1 | |
这样应用重构后就可以持续运行历史流量,而不是只依靠人工枚举 TestCase。
真正困难的地方不是“再请求一次”
简单比较:
1 | |
在很多业务中并不成立。
例如订单接口:
1 | |
原因在于响应不仅取决于入参:
1 | |
因此流量回放需要重点考虑:
- 底层数据是否一致;
- 方法是否有副作用;
- 是否依赖当前时间;
- 是否依赖缓存状态;
- 是否依赖其它 RPC 服务;
- 响应中是否存在随机值或动态字段;
- 什么才叫“结果一致”。
复杂场景中,只录制接口入参和出参甚至还不够。
如果应用依赖:
1 | |
为了得到稳定回放结果,还可能需要记录外部依赖的响应,并在回放时替代这些依赖。
因此工程上更合理的目标不是:
1 | |
而是:
1 | |
流量回放不只可以做回归
同一套数据还可以用于:
1 | |
或者:
1 | |
也可以用于验证:
- RPC 框架升级;
- 服务重构;
- 新旧集群切换;
- 性能优化;
- 协议升级。
换句话说,它把线上流量从一次性消耗品变成了可以反复利用的测试资产。
动态分组:隔离之后还要解决资源碎片
为什么需要分组
假设 Provider 集群同时服务:
1 | |
如果所有调用方共享同一组 Provider:
1 | |
一种常见治理方式是按调用方分组:
1 | |
这样可以形成硬隔离。
容量如何估算
可以先通过压测得到:
1 | |
统计分组调用量:
1 | |
那么基础机器数可以按下面的思路估算:
1 | |
再根据历史波动增加冗余。
例如不是让:
1 | |
而是:
1 | |
问题在于,每一个分组都保留很多冗余会产生大量闲置资源。
突发流量破坏了静态分组
假设:
1 | |
突然 B 流量暴涨。
与此同时:
1 | |
但 B 因为分组隔离看不到它们。
传统处理方式可能是:
1 | |
或者把现有节点修改分组后重启。
这些操作都太慢。
真正需要改变的是服务发现视图
调用方为什么只能访问某个分组?
因为服务发现告诉它:
1 | |
那么扩容时不一定真的需要重启 Provider。
只要动态把注册中心视图调整成:
1 | |
B 就获得了新的服务能力。
flowchart TB
Registry["注册中心"]
P1["P1\nGroup A"]
P2["P2\n真实 Group A"]
P3["P3\nGroup B"]
A["Consumer A"]
B["Consumer B"]
Registry --> A
Registry --> B
A --> P1
A --> P2
B --> P3
B -. "动态追加" .-> P2
这类动态分组通常有两种动作。
替换
1 | |
节点从 A 的服务集合中移出,加入 B。
追加
1 | |
节点同时服务两个逻辑分组。
从资源角度看,追加通常更灵活,因为节点原来的业务不需要立刻失去服务能力。
共享冗余池
进一步可以把:
1 | |
改成:
1 | |
这解决的是另一个典型问题:
隔离提高了稳定性,却容易制造资源碎片。
动态分组就是在“隔离”和“资源利用率”之间增加一个弹性层。
动态分组还有一个容易忽视的坑
注册中心中的逻辑分组被修改后,Provider 自己可能仍然认为:
1 | |
而 Consumer 按动态策略发送:
1 | |
如果 Provider 内部还做:
1 | |
请求就会被拒绝。
因此动态分组不能只改 Registry 数据,还需要设计:
1 | |
之间的关系。
一种思路是请求上下文中同时携带真实分组信息,让 Provider 的校验逻辑能够识别动态映射,而不是简单做字符串相等判断。
泛化调用:没有接口 Jar 也能完成 RPC
为什么正常 RPC 需要接口
通常调用端会写:
1 | |
动态代理是根据:
1 | |
生成的。
因此 Consumer 通常必须依赖 Provider 发布的 API Jar。
大部分业务系统这样做完全没问题。
但有两个特殊场景会马上遇到困难。
场景一:统一 RPC 测试平台
测试平台希望用户输入:
1 | |
然后直接调用任意 RPC 服务。
如果每增加一个 RPC 接口都要:
1 | |
这个平台就失去了“统一”意义。
场景二:HTTP -> RPC 网关
例如:
1 | |
网关不可能静态依赖公司所有 RPC API。
这就需要泛化调用(Generic Invocation)。
RPC 真正需要的不是 Java Interface,而是调用元数据
一次请求要完成方法定位,真正需要的是:
1 | |
因此完全可以定义统一入口:
1 | |
创建 GenericService Proxy 时,再额外指定真正目标:
1 | |
最终请求就可以构造成:
1 | |
服务端接收到请求后,仍然按照普通 RPC 的方式定位:
1 | |
因此泛化调用并不是另外发明了一种 RPC。
它只是:
不再依赖强类型接口作为调用入口,而是直接构造 RPC 所需要的元数据。
没有 Class,序列化怎么办
这才是泛化调用真正棘手的地方。
正常调用:
1 | |
Consumer 知道:
1 | |
于是可以按照正常方式序列化。
泛化调用可能只拿到:
1 | |
这时 Consumer 甚至没有目标 POJO Class。
因此可以为泛化调用设计单独的序列化逻辑,用通用数据结构承载 POJO:
1 | |
服务端知道真实接口定义,所以收到 Map 后,可以依据参数类型恢复真正的对象。
例如:
1 | |
返回值则反向转换成通用结构。
此外,协议中最好存在明确的泛化调用标记:
1 | |
而不是依靠某个特殊方法名“猜测”请求类型。
这样后续才能方便地为泛化请求配置独立序列化器和处理链。
多协议兼容:架构升级不应该要求全公司同一晚切换
多种 RPC 协议为什么会长期共存
企业系统通常不是一天建成的。
可能先后使用过:
1 | |
如果直接要求:
1 | |
对于大型系统几乎不可操作。
调用关系很可能已经是网状结构:
1 | |
很难找到一个绝对安全的升级顺序。
更合理的是协议兼容窗口
理想状态是:
1 | |
这样 Provider 只升级一次。
随后 Consumer 可以逐个迁移。
当最后一个旧 Consumer 下线之后,再移除旧协议。
用 magic number 识别协议
不同二进制协议通常都有自己的头部特征。
可以抽象成:
1 | |
RPC Server 收到数据后先识别:
1 | |
再匹配:
1 | |
流程变成:
flowchart TB
Network["二进制网络数据"]
Detect["ProtocolAdapter\n识别协议"]
PA["Protocol A Codec"]
PB["Protocol B Codec"]
Internal["协议无关 RPC Request"]
Invoke["RPC 内部调用逻辑"]
Result["协议无关 RPC Result"]
EncodeA["Protocol A Encode"]
EncodeB["Protocol B Encode"]
Network --> Detect
Detect --> PA
Detect --> PB
PA --> Internal
PB --> Internal
Internal --> Invoke
Invoke --> Result
Result --> EncodeA
Result --> EncodeB
这里最关键的设计并不是 magic number 本身。
而是:
协议相关对象不能继续污染 RPC 内部调用链。
协议对象必须转换成内部统一模型
假设 Protocol A 得到:
1 | |
Protocol B 得到:
1 | |
如果后面的 Filter、路由、鉴权、反射都分别支持:
1 | |
RPC 核心代码会迅速失控。
因此在协议层之后应统一转换为:
1 | |
类似这样的内部结构:
1 | |
另一种协议走:
1 | |
这样新增协议时,核心业务调用链不需要修改。
不要把协议支持写成巨型 if/else
如果代码最终变成:
1 | |
协议数量一多,维护会非常困难。
更合理的是插件模型:
1 | |
协议插件自己声明:
1 | |
ProtocolAdapter 只负责发现和匹配。
这也是插件化 RPC 架构的价值之一。
从 JoyRPC 的代码骨架看 RPC 如何真正跑起来
上面的能力如果只停留在概念层,仍然比较分散。
一套实际 RPC 代码可以通过三个核心流程串起来:
1 | |
入口层的核心抽象:Invoker
RPC 中可以抽象出一个统一调用接口:
1 | |
核心有两个实现方向:
1 | |
其中:
1 | |
1 | |
这两个对象可以理解为整个入口层最关键的两个支点。
Provider 启动:先 Export,再 Open
一个简化的 Provider 配置过程类似:
1 | |
exportAndOpen() 又被拆成:
1 | |
这不是单纯为了方法命名好看,而是在区分两个生命周期阶段。
Export 阶段
核心工作是:
1 | |
可以表示成:
sequenceDiagram
participant PC as ProviderConfig
participant R as Registry
participant IM as InvokerManager
participant E as Exporter
PC->>R: 创建并 open Registry
PC->>R: subscribe 配置
PC->>IM: export(serviceUrl)
IM->>E: 创建 Exporter
E-->>IM: Exporter
IM-->>PC: Exporter
Exporter 初始化过程中还可以完成:
1 | |
Open 阶段
Exporter 已经存在,但还不能对外服务。
Open 阶段负责:
1 | |
可以看到服务真正注册到注册中心之前,Server 应该已经具备提供服务的能力。
否则很容易出现:
1 | |
这种启动竞态。
Consumer 启动:核心是 Refer + Cluster
Consumer 的使用方式类似:
1 | |
启动过程可以抽象成:
1 | |
其中最复杂的对象之一是 Cluster。
它需要负责:
1 | |
因此:
1 | |
这是两个不同层面的职责。
Consumer 发起请求的完整链路
当业务代码:
1 | |
真正发生的调用链可以整理为:
1 | |
进一步展开:
flowchart LR
Proxy["Proxy"]
Handler["ConsumerInvokerHandler"]
Refer["Refer"]
Filter["FilterChain"]
Selector["NodeSelector"]
Route["Route"]
LB["LoadBalance"]
Client["Client"]
Proxy --> Handler
Handler --> Refer
Refer --> Filter
Filter --> Selector
Selector --> Route
Route --> LB
LB --> Client
这里每一个高级治理功能都有了明确位置。
比如:
1 | |
可以进入 Filter。
1 | |
可以进入 NodeSelector。
1 | |
由 Route 控制。
1 | |
则由 LoadBalance 负责。
这就是为什么插件化会比一个几千行的 invoke() 更容易演进。
Provider 收到请求后的处理链
服务端则大致经过:
1 | |
其中多协议能力就自然出现在:
1 | |
后面。
泛化调用可以在恢复调用参数时进入专属处理逻辑。
鉴权和业务级 Filter 可以位于:
1 | |
之后。
异步则贯穿:
1 | |
这些核心流程。
为什么“纯异步”与“插件化”是同一套设计思想
表面上看:
1 | |
解决性能问题;
1 | |
解决扩展问题。
实际上二者共同指向一个目标:
避免核心流程被某一个具体实现长期绑死。
纯异步意味着:
1 | |
插件化意味着:
1 | |
于是 RPC Core 更像一个协调器:
1 | |
这就是微内核架构真正有价值的地方。
把高级能力重新映射到源码位置
把前面的内容放回四层架构,可以得到一张非常有用的工程地图:
| 能力 | 主要落点 | 核心原因 |
|---|---|---|
| Future / CompletableFuture | Invoker、Transport | RPC 请求天然是异步消息模型 |
| 服务端异步 | Exporter | 业务结果不必与当前工作线程绑定 |
| 调用方认证 | Filter / Provider | 必须在业务执行之前拒绝非法调用 |
| 方法级授权 | Filter | 可以基于 service + method 做规则判断 |
| Provider 身份认证 | Registry | 防止非法应用发布服务 |
| Trace / Span | Consumer/Provider Filter | 调用前后天然是埋点位置 |
| Trace 上下文传播 | Request Attachment | 必须跨 RPC 边界传递 |
| 时钟轮 | FutureManager / Transport | 大量请求都需要低成本超时管理 |
| 流量录制 | Filter / 扩展点 | 能同时获得请求和响应 |
| 动态分组 | Registry + Cluster | 本质是动态改变服务发现视图 |
| 泛化调用 | Entry + Serializer | 不再依赖静态 Interface Class |
| 多协议 | ProtocolAdapter + Protocol | 协议差异应该终止于协议层 |
理解这张表以后,再阅读任意成熟 RPC 框架源码,思路会清晰很多。
不要一上来就追着类名跑。
先问:
1 | |
这些问题往往比记住某个类名更重要。
从 ICE、Hessian、Dubbo 到基础设施下沉
RPC 的演进历史本身也能解释这些设计为什么会出现。
ICE:跨语言通信问题
早期系统从 .NET 向 Java 迁移时,首先面对的是:
1 | |
这时候 RPC 解决的核心矛盾仍然是:
如何让两个不同技术栈的应用完成远程调用。
ICE 这类框架在这一阶段能够很好地解决跨语言通信问题。
Hessian:和 Java/Spring 更自然地结合
当系统逐渐以 Java 为主后,开发体验变得更加重要。
Hessian 可以更自然地和 Spring 应用结合,因此 RPC 接口开发成本下降。
但是随着规模增大,另一个问题出现:
只有 RPC 通信还不够,还需要服务发现。
如果所有 Provider 都通过 VIP 暴露:
1 | |
流量会集中经过中间节点,同时每一个服务都需要维护相应 VIP。
RPC 开始从:
1 | |
走向:
1 | |
Dubbo + ZooKeeper:服务发现进入 RPC
Dubbo 一类框架把:
1 | |
更紧密地纳入 RPC 体系。
调用关系变为:
1 | |
这比所有流量经过中心 VIP 更适合大规模服务化。
但随着:
1 | |
注册中心本身也会面对更大压力。
这又推动了新的架构选择和自研需求。
从 SDK 治理走向基础设施治理
传统 RPC 经常把大量能力封装进应用 Jar:
1 | |
应用需要:
1 | |
随着 Kubernetes 等基础设施的发展,越来越多非业务能力开始尝试从应用进程中剥离。
Service Mesh 所代表的一种方向就是:
1 | |
这并不意味着 RPC 的服务治理消失了。
恰恰相反:
治理能力一直存在,只是它可能从 SDK 移到了更低的基础设施层。
所以理解 RPC 原理并不会因为云原生出现而失去价值。
服务发现、路由、负载均衡、超时、重试、链路传播、协议转换这些问题依旧存在,只是实现位置可能发生变化。
几个很容易被误解的工程结论
异步不等于一定更快
如果四个请求互相依赖:
1 | |
就不能通过简单并行变成 1/4 时间。
如果异步任务最终又全部堆积在另一个饱和线程池里,同样不会获得无限吞吐量。
异步优化的是等待模型和线程使用方式,不是消灭真实工作量。
线程多不等于吞吐量高
当瓶颈来自:
1 | |
继续增加线程可能只会导致:
1 | |
真正应该先搞清楚:
1 | |
内网不等于可信网络
“只有内部系统使用”并不能代替:
1 | |
流程制度可以减少误调用,但技术系统应该具备真正的强制边界。
TraceId 不是加个日志字段就结束了
真正困难的是:
1 | |
都必须保证上下文连续。
否则最需要链路信息的地方,往往恰好最容易丢 TraceId。
时间轮不是高精度定时器
它优先解决的是:
1 | |
而不是微秒级、严格实时触发。
流量回放也不是简单复制请求
真正的业务结果还依赖:
1 | |
如果忽略这些条件,很容易把“环境不同”误判成“代码有 Bug”。
动态分组不是改一个字符串那么简单
逻辑分组发生变化后:
1 | |
都必须理解这种临时拓扑变化。
否则注册中心认为调用合法,Provider 自己却把请求拒绝了。
多协议兼容的重点是“内部模型统一”
magic number 只是入口。
真正决定架构能不能长期维护的是:
1 | |
否则支持三个协议之后,整个 Core 都会充满协议判断。
一套完整 RPC 的工程闭环
如果把所有能力放在一起,一次生产级 RPC 调用其实经历的是:
flowchart TB
Biz["业务方法调用"]
Proxy["Proxy / GenericService"]
CFilter["Consumer Filter\nTrace / Auth / Replay"]
Cluster["Cluster"]
Route["Route + LoadBalance"]
Client["Client"]
Future["Future + Timeout Wheel"]
Network["Network"]
Protocol["ProtocolAdapter + Codec"]
PFilter["Provider Filter\nAuth / Trace"]
Exporter["Exporter"]
Method["真实业务方法"]
Biz --> Proxy
Proxy --> CFilter
CFilter --> Cluster
Cluster --> Route
Route --> Client
Client --> Future
Future --> Network
Network --> Protocol
Protocol --> PFilter
PFilter --> Exporter
Exporter --> Method
而框架外围还有:
1 | |
这时再来看 RPC,会发现真正复杂的部分早已不是:
1 | |
而是:
1 | |
总结
RPC 最基础的能力,是把一次远程网络通信包装成本地方法调用体验。但系统规模扩大之后,真正决定 RPC 是否适合生产环境的,往往是通信之外的能力。
异步 RPC 通过 Future 和 CompletableFuture 改变请求等待模型,让独立调用可以并行,也让服务端不必长期占用 RPC 工作线程;但异步并不会消灭真实资源消耗,线程池、数据库和下游服务仍然存在容量边界。
安全体系需要同时解决 Consumer 和 Provider 两个方向的问题。调用方必须具备可识别的应用身份,并进一步做到接口乃至方法级授权;服务注册时也应该验证应用是否有资格发布对应接口。授权平台更适合作为控制面,而高频认证判断应尽量在数据面本地完成。
分布式故障定位不能只依赖一句 timeout。异常码、接口、方法、Consumer/Provider 地址以及明确的错误分类,是 RPC 的基础可观测能力;Trace 和 Span 则进一步还原完整调用链,而真正困难的工程问题在于跨线程、异步回调和消息边界持续传播上下文。
当每个请求都拥有自己的超时任务时,简单轮询会产生大量无效扫描。时钟轮通过时间槽和分层调度,把任务提前分桶,只处理当前即将到期的一部分任务,因此特别适合 RPC 请求超时、心跳、启动超时等海量轻量级定时场景。
RPC 还能利用自身处于所有服务调用必经链路这一特点完成流量录制。真实流量经过异步旁录、存储、回放与结果比较后,可以成为应用重构、框架升级和压力测试的重要资产,不过数据状态、时间、幂等性以及外部依赖决定了流量回放远比“重新发一次请求”复杂。
分组治理能够隔离核心和非核心业务,但静态隔离也会产生资源碎片。动态分组通过修改服务发现视图,把其它分组或共享池中的富余实例临时加入高压分组,使逻辑扩缩容不必依赖重启 Provider。
泛化调用则进一步揭示了 RPC 的本质:Java Interface 并不是远程调用不可缺少的东西。只要拥有接口名、分组、方法、参数类型和参数值,就可以直接构造 RPC 请求,因此统一测试平台和 RPC 网关不必依赖所有业务接口 Jar。
面对长期存在的新旧系统,多协议兼容比要求一次性迁移更现实。RPC 可以通过协议特征识别不同协议,再将协议相关对象转换为统一内部请求,让 Filter、路由、负载均衡和业务调用逻辑保持协议无关,从而获得平滑升级能力。
而把这些设计放回真实代码,会发现它们最终都围绕几个稳定抽象展开:Consumer 侧的 Refer、Provider 侧的 Exporter、负责服务发现和连接管理的 Registry 与 Cluster、处理协议的 Protocol,以及创建 Client/Server 的传输层工厂。核心 Invoker 以 CompletableFuture 表达异步结果,外围能力再通过插件不断扩展。
所以学习 RPC 最有价值的部分,并不是记住某个框架的一组配置,而是理解这些设计问题为什么会出现,以及它们应该位于调用链的哪一个位置。
当这些问题真正想通之后,再去阅读 Dubbo、gRPC、自研 RPC,甚至 Service Mesh 中的通信与治理逻辑,会发现底层面对的仍然是同一批问题:
1 | |
RPC 的网络调用只是起点。
围绕远程调用建立稳定、可治理、可观测、可扩展的分布式通信体系,才是完整的 RPC 工程。