RPC 高级工程实践与源码脉络:异步、安全、可观测、流量治理与协议演进

RPC 真正进入生产环境之后,问题很快就会超出“如何完成一次远程方法调用”的范围:吞吐量为什么上不去、内部服务如何鉴权、跨服务异常怎样定位、大量超时任务怎样低成本调度、架构升级如何利用真实流量验证、隔离集群怎样快速扩容、没有接口 Jar 如何调用服务,以及新旧 RPC 协议如何平滑共存。本文从这些工程问题出发,结合一套插件化、纯异步 RPC 的代码结构,把性能、安全、可观测性、流量治理、泛化调用、多协议兼容与 RPC 技术演进串成一套完整的设计脉络。

RPC 的高级阶段:从通信工具走向分布式基础设施

如果只看最基础的调用过程,RPC 的职责似乎很简单:

1
2
3
4
5
6
7
8
9
10
11
调用本地代理

序列化请求

网络发送

服务端反序列化

调用真实方法

返回响应

但只要系统进入生产环境,问题马上会变得复杂。

一个成熟 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

这个分层非常重要,因为后面几乎所有高级能力,都能找到一个自然的插入点。

例如:

  • 异步调用最终落在 InvokerFuture 和传输层;
  • 鉴权、链路追踪、流量录制适合进入 Filter 或请求处理链;
  • 动态分组属于 Registry、Cluster 和路由体系;
  • 泛化调用主要改变入口层和序列化逻辑;
  • 多协议兼容主要发生在 Protocol 与 Codec 层;
  • 超时检测则可以交给传输层的时钟轮。

一个好的 RPC 框架不是把所有功能塞进一个巨大调用函数,而是让这些能力围绕稳定的核心调用链进行扩展。

异步 RPC:吞吐量问题很多时候不是 CPU 不够

阻塞等待才是隐藏成本

一个很典型的现象是:

1
2
3
TPS 上不去
CPU 只有 40%~50%
继续加流量,CPU 和 TPS 都没有明显提升

这并不一定意味着 RPC 框架性能差。

如果一个请求内部还要同步调用多个耗时下游,那么线程的大量时间实际上处于等待状态。等待中的线程不会持续消耗 CPU,但它仍然占用了宝贵的并发资源。

假设业务需要访问四个彼此独立的服务:

1
2
3
4
Service1 10ms
Service2 10ms
Service3 10ms
Service4 10ms

顺序调用的理想耗时约为:

1
10 + 10 + 10 + 10 = 40ms

如果四次调用能够并行发出:

1
2
t=0ms   同时发出 Service1/2/3/4
t=10ms 四个请求全部完成

整个等待时间理论上就可能接近 10ms。

这里真正提高的不是某一次 RPC 的网络性能,而是把原来的串行等待变成了并行等待

需要注意,这只有在几个调用之间不存在数据依赖时才成立。如果:

1
2
3
4
5
6
7
A 的结果

生成 B 的参数

B 的结果

才能调用 C

那么业务本身就是串行依赖,改成 Future 并不能凭空获得四倍吞吐量。

RPC 的同步调用,内部往往本来就是异步的

RPC 调用端发送请求和接收响应,本来就是两个不同时间发生的事件。

一个典型实现会在发送请求之前创建 Future:

1
requestId -> Future

发送请求:

1
2
3
Request {
requestId = 10001
}

服务端处理完成后返回:

1
2
3
4
Response {
requestId = 10001
result = ...
}

调用端收到响应后,根据 requestId 找回 Future:

1
10001 -> Future

然后把结果写入 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.get();

框架替业务代码主动等待了 Future。

而异步调用则是不立即执行这个等待动作,让调用者决定什么时候消费结果。

从实现角度看:

同步往往只是异步调用之上的一种阻塞式使用方式。

从 Future 进一步走向 CompletableFuture

传统 Future 最大的问题在于,它更适合“以后来取结果”,却不擅长组织后续异步逻辑。

CompletableFuture 则可以把“结果完成”本身变成事件。

RPC 接口可以直接返回:

1
CompletableFuture<Result>

整个过程便可以变成:

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
2
3
4
5
6
7
8
网络 IO
→ 拆包
→ 解码
→ 反序列化
→ 业务执行
→ 序列化
→ 编码
→ 网络响应

网络 IO 线程不应该承担慢业务,否则某一个慢请求就可能拖住大量网络事件。

因此常见结构是:

1
2
3
4
5
6
7
8
9
10
Netty IO Thread

├─ 解码
├─ 反序列化

└─ 投递

Business Thread Pool

业务逻辑

但是业务线程池同样是有限资源。

如果业务里面大量存在数据库、远程服务等等待操作,简单地把线程池从 200 改成 500、1000,通常只是把问题推迟,并没有改变业务本身的资源模型。

一种方案是让服务方法返回异步结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
RPC 业务线程

触发异步任务

立即释放 RPC 处理线程

其它执行器

真正完成任务

complete(result)

RPC 继续序列化并响应

这里有一个很容易产生的误区:

异步不会让真正的计算和 IO 工作凭空消失。

如果最终执行慢业务的线程池也已经饱和,系统照样会达到容量上限。

异步的价值在于:

  • 减少 RPC 工作线程长时间被等待操作占用;
  • 允许请求之间更灵活地并发;
  • 避免共享 RPC 线程池被少数慢接口长期占满;
  • 更容易进行任务编排。

但它并不是“无限并发按钮”。

RPC 安全:内网不等于没有边界

第一个问题:谁有资格调用我

RPC 经常运行在内部网络里,因此它面对的安全问题和公网 Web 系统并不完全一样。

一个很现实的问题是:服务提供方把接口 Jar 发布到内部仓库后,理论上任何拿到依赖坐标的应用,都可以创建 RPC Proxy。

例如:

1
2
3
4
5
6
7
OrderService API

发布到 Maven 私服

应用 A 引入并调用
应用 B 引入并调用
应用 C 也可以自己引入

如果服务提供方不知道调用来自哪个应用,就无法区分:

1
已申请调用方

和:

1
未经允许的新调用方

这不仅是安全问题,也直接影响容量治理。

一个突然增加的调用方,很可能成为压垮服务的最后一部分流量。

身份认证应该进入 RPC 调用链

解决问题的第一步,是让调用方拥有稳定的应用身份。

可以设计一个授权控制面:

flowchart LR
    Caller["调用方应用"] -->|申请接口权限| Auth["授权平台"]
    ProviderOwner["服务负责人"] -->|审批| Auth
    Auth -->|颁发凭证| Caller

    Caller -->|RPC 请求 + 应用身份/凭证| Provider["服务提供方"]
    Provider --> Verify["本地认证"]
    Verify --> Authorize["接口/方法授权"]
    Authorize --> Business["业务方法"]

如果让每一次 RPC 请求都先访问授权中心:

1
2
3
4
5
Caller

Auth Server

Provider

会产生一个严重问题:

所有 RPC 流量都依赖同一个认证服务。

当公司内部 RPC 调用量足够大时,认证服务本身就会成为性能瓶颈和全局故障点。

更合理的思路是:

1
2
3
4
5
6
7
8
9
控制面:
申请
审批
凭证签发
权限配置

数据面:
Provider 本地认证
Provider 本地授权

这样授权平台不需要出现在每一条业务请求的同步链路中。

关于 HMAC,需要分清“认证”和“加密”

一种可以用于本地校验的技术是 HMAC。

严格来说:

HMAC 不是加密算法,也不是基于公私钥的非对称数字签名,而是使用共享密钥计算的消息认证码(Message Authentication Code)。

真正有价值的设计思想是:

1
2
3
4
5
6
7
授权平台

使用预先约定的安全机制生成凭证

调用方携带凭证

Provider 使用本地信息完成校验

也就是说,把认证的高频判断从中心节点移动到服务提供方。

认证之后还需要授权

仅知道:

1
Application A 可以调用 OrderService

还不够。

因为一个接口通常有多个方法:

1
2
3
4
5
6
7
8
interface OrderService {

Order queryOrder(Long id);

void cancelOrder(Long id);

void forceCloseOrder(Long id);
}

很可能调用方只应该拥有:

1
queryOrder

而没有:

1
forceCloseOrder

因此权限模型至少应该能够描述:

1
2
3
4
5
callerApplication
+
service
+
method

例如:

1
2
3
4
5
6
7
finance-service
-> OrderService.queryOrder
-> allowed

report-service
-> OrderService.forceCloseOrder
-> denied

授权关系可以由统一平台管理,再同步到服务本地缓存或配置体系,由 Provider Filter 在业务执行之前检查。

服务提供方本身也需要认证

安全问题并不只发生在 Consumer。

如果某个应用拿到了接口定义,也有可能尝试注册一个假的 Provider。

因此注册中心还应该具备类似下面的约束:

1
2
3
OrderService

只允许 order-service 应用注册

注册过程就变成:

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
2
3
4
5
Consumer -> Provider
认证谁可以调用

Provider -> Registry
认证谁可以提供服务

RPC 的安全边界才真正闭合起来。

分布式故障定位:不要让异常只剩一句 timeout

为什么分布式系统越来越难排查

单体应用发生异常时,可以直接从一个进程的日志往下看。

而分布式系统可能是:

1
2
3
4
5
6
7
A

B

C

D

A 收到的异常,很可能真正来自 D。

如果只得到:

1
TimeoutException

维护 A 的开发者甚至不知道:

  • 哪个 RPC 接口超时;
  • 调的是哪个节点;
  • 调用端 IP 是什么;
  • Provider IP 是什么;
  • 是连接超时还是响应超时;
  • 是 RPC 框架异常还是业务异常;
  • 中间到底经过了哪些服务。

因此,一款 RPC 框架的异常设计本身就是可观测能力的一部分。

异常必须携带可定位信息

RPC 框架的异常至少应该考虑:

1
2
3
4
5
6
7
8
9
10
errorCode
errorType
serviceName
methodName
alias / group
consumerIp
providerIp
timeout
cost
cause

例如相比:

1
Request timeout

下面的信息要有价值得多:

1
2
3
4
5
6
RPC-22210
type = BUSINESS_THREAD_POOL_EXHAUSTED
service = com.demo.CService
method = query
consumer = 192.168.1.2
provider = 192.168.1.3

异常码还应该形成稳定的分类体系:

1
2
3
4
5
6
1xxxx  网络连接
2xxxx 线程池
3xxxx 序列化
4xxxx 服务发现
5xxxx 路由
...

具体编码方式不是重点,重点是:

异常必须能够从“发生错误”进一步回答“错误发生在哪里、属于什么类型、应该从哪里开始查”。

Trace 与 Span

仅靠异常信息依然无法完整解决跨服务问题。

如果调用链是:

1
A → B → C → D

维护 A 的人可能根本不知道 B 后面还有 C 和 D。

这就需要分布式链路跟踪。

一次完整调用可以表示成一个 Trace:

1
TraceId = T10001

其中每一段远程调用是一个 Span:

1
2
3
Span1: A -> B
Span2: B -> C
Span3: C -> D

并通过父子关系建立链路:

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
2
3
A -> B    5ms
B -> C 12ms
C -> D 803ms ← 异常

定位问题的方式从:

1
2
3
4
5
6
7
找 A

问 B

再问 C

最后找到 D

变成:

1
2
3
查询 TraceId

直接看到 D 异常

RPC 接入链路追踪的核心只有两件事

埋点

调用端:

1
2
发送请求前
收到响应后

服务端:

1
2
收到请求
业务执行完成

这些位置天然适合产生 Span 数据。

传递

上游必须把:

1
2
TraceId
ParentSpanId

传递给下游。

因此 RPC 协议通常需要保留类似附件或隐式参数的区域:

1
2
3
4
5
attachments:
traceId
spanId
callerApp
...

这样下游才能创建正确的子 Span。

这里还有一个非常重要的工程问题:

1
2
3
4
5
Thread A

线程池

Thread B

如果 TraceId 只存在 ThreadLocal 中,异步切换线程后上下文就可能丢失。

同样:

1
2
3
4
5
RPC

MQ

另一个服务

也必须继续传播 Trace 上下文。

所以在异步 RPC 体系中:

上下文传播与异步模型必须同时设计。

否则业务是异步了,链路也“异步没了”。

时钟轮:高并发 RPC 为什么不应该扫描所有超时任务

Future 带来了另一个问题:超时

调用端每发送一个 RPC 请求,都会创建一个 Future:

1
requestId -> Future

如果服务端一直不返回,这个 Future 不可能永久存在,因此还需要:

1
timeout = 5000ms

问题随之而来:

几万甚至几十万个 Future,怎样高效判断谁已经超时?

最直接的方法:一个任务一个线程

例如:

1
2
3
4
5
6
7
创建 Future

创建线程

sleep(5000)

判断是否超时

假设每秒发出 2 万请求,超时时间为 5 秒,那么理论上可能同时存在约:

1
2万 × 5 = 10万个等待中的超时任务

显然不能为每个任务创建一个线程。

第二种方法:一个线程扫描所有任务

于是可以改成:

1
2
3
4
5
每 100ms

扫描所有 Future

currentTime >= deadline ?

这解决了线程数量问题,却又制造了大量无意义遍历。

一个 5 秒后才超时的任务,在真正到期前可能被扫描几十次。

所以问题变成:

能不能只在任务快要到期的时候才看它?

这就是时钟轮解决的问题。

时钟轮的基本模型

假设:

1
2
槽位数量 = 10
一轮周期 = 1s

那么:

1
tick = 100ms

第一层时间轮可以表示成:

1
2
3
4
5
slot0
slot1
slot2
...
slot9

任务按照剩余时间落入槽位。

例如:

1
2
3
任务 A:90ms 后执行
任务 B:610ms 后执行
任务 C:1610ms 后执行

可以粗略分配成:

1
2
3
4
5
6
第一层:
slot0 -> A
slot6 -> B

第二层:
slot1 -> C

C 的执行过程是:

flowchart LR
    C["Task C: 1610ms"] --> L2["第二层时间轮 slot1"]
    L2 -->|"约 1s 后下沉"| L1["第一层时间轮 slot6"]
    L1 -->|"再经过约 600ms"| E["执行 Task C"]

第一层时间轮完成一整圈后,第二层向前推进一个槽位,并把该槽中的任务重新分配到第一层。

它的核心不是提高时间精度,而是:

通过分桶减少无意义扫描。

时间精度与槽位数量的取舍

时间轮有两个很重要的参数。

tick 越小,精度越高

例如:

1
tick = 10ms

理论调度粒度就在 10ms 级别。

如果:

1
tick = 100ms

调度粒度就是 100ms 级别。

实际执行时间还会受到系统调度和任务执行器负载影响,因此时钟轮更适合高吞吐定时调度,而不是硬实时场景。

槽位越多,长任务越少进入高层时间轮

例如:

1
2
1000 slots × 10ms
= 10s

那么 10 秒以内的任务都可以待在第一层。

如果只有:

1
2
10 slots × 10ms
= 100ms

超过 100ms 的任务就需要进入更高层。

槽位越多通常意味着更多空间成本,但可以减少任务在多层之间迁移。

RPC 中哪些地方适合时钟轮

最典型的是:

  • 请求超时;
  • Client 启动超时;
  • Server 启动超时;
  • 心跳;
  • 连接空闲检测;
  • 延迟重试;
  • 其它大量轻量级定时任务。

周期性任务也不一定需要特殊模型。

例如心跳任务执行完成后:

1
2
3
4
5
执行 heartbeat

计算下一次执行时间

重新加入时间轮

即可继续循环。

流量回放:把线上真实请求变成回归测试资产

TestCase 为什么不够

应用重构时最麻烦的问题通常不是:

1
新功能能不能工作

而是:

1
原来几千种线上行为有没有被意外改坏

测试代码只能覆盖我们预想到的场景

但线上流量包含的是用户实际产生过的场景。

因此可以把:

1
线上真实请求

转换成:

1
可重复执行的测试数据

这就是流量回放。

RPC 为什么天然适合录制流量

应用间调用都经过 RPC:

1
2
3
4
5
Caller

RPC

Provider

所以 RPC 可以同时看到:

1
2
3
4
5
6
7
接口
方法
参数
请求上下文
响应
异常
耗时

那么只需要增加旁路:

flowchart LR
    Caller["线上调用方"] --> RPC["RPC Provider"]
    RPC --> Biz["原业务逻辑"]

    RPC -. "异步旁录请求/响应" .-> Store["流量存储"]

    Store --> Replay["回放应用"]
    Replay --> New["改造后的 Provider"]
    New --> Compare["结果比较"]
    Store --> Compare

这里最重要的是:

录制过程不能显著影响真实业务链路。

因此旁录数据通常应该异步处理,而不是:

1
2
3
先把录制数据写成功

再处理真实请求

否则测试能力反而会成为业务故障源。

回放的基本过程

可以抽象成四步:

1
2
3
4
5
6
7
8
9
10
11
1. Record
记录接口、方法、请求和结果

2. Store
异步存储

3. Replay
使用相同参数请求新应用

4. Compare
比较旧结果与新结果

这样应用重构后就可以持续运行历史流量,而不是只依靠人工枚举 TestCase。

真正困难的地方不是“再请求一次”

简单比较:

1
oldResponse == newResponse

在很多业务中并不成立。

例如订单接口:

1
2
3
4
5
6
7
线上第一次:
createOrder(10001)
→ success

回放:
createOrder(10001)
→ order already exists

原因在于响应不仅取决于入参:

1
2
3
4
5
6
7
Response =
Request
+ Database State
+ Cache State
+ Downstream State
+ Current Time
+ ...

因此流量回放需要重点考虑:

  • 底层数据是否一致;
  • 方法是否有副作用;
  • 是否依赖当前时间;
  • 是否依赖缓存状态;
  • 是否依赖其它 RPC 服务;
  • 响应中是否存在随机值或动态字段;
  • 什么才叫“结果一致”。

复杂场景中,只录制接口入参和出参甚至还不够。

如果应用依赖:

1
2
3
DB
Redis
下游 RPC

为了得到稳定回放结果,还可能需要记录外部依赖的响应,并在回放时替代这些依赖。

因此工程上更合理的目标不是:

1
字节级完全一致

而是:

1
业务语义一致

流量回放不只可以做回归

同一套数据还可以用于:

1
2
历史流量 × 1
→ 回归测试

或者:

1
2
历史流量 × N
→ 压力测试

也可以用于验证:

  • RPC 框架升级;
  • 服务重构;
  • 新旧集群切换;
  • 性能优化;
  • 协议升级。

换句话说,它把线上流量从一次性消耗品变成了可以反复利用的测试资产。

动态分组:隔离之后还要解决资源碎片

为什么需要分组

假设 Provider 集群同时服务:

1
2
3
4
核心交易
营销活动
内部报表
批处理任务

如果所有调用方共享同一组 Provider:

1
2
3
4
5
非核心业务突然暴涨

整个 Provider 集群过载

核心业务一起失败

一种常见治理方式是按调用方分组:

1
2
3
Core Group
Marketing Group
Report Group

这样可以形成硬隔离。

容量如何估算

可以先通过压测得到:

1
Q_single = 单实例安全 QPS

统计分组调用量:

1
Q_group = 分组内所有调用方总 QPS

那么基础机器数可以按下面的思路估算:

1
N_base = ceil(Q_group / Q_single)

再根据历史波动增加冗余。

例如不是让:

1
Capacity == Current Traffic

而是:

1
Capacity > Current Traffic

问题在于,每一个分组都保留很多冗余会产生大量闲置资源。

突发流量破坏了静态分组

假设:

1
2
3
4
5
6
Group A
P1
P2

Group B
P3

突然 B 流量暴涨。

与此同时:

1
P1、P2 还有余量

但 B 因为分组隔离看不到它们。

传统处理方式可能是:

1
2
3
4
5
6
7
8
9
找机器

修改配置

部署

启动

注册

或者把现有节点修改分组后重启。

这些操作都太慢。

真正需要改变的是服务发现视图

调用方为什么只能访问某个分组?

因为服务发现告诉它:

1
Group B = [P3]

那么扩容时不一定真的需要重启 Provider。

只要动态把注册中心视图调整成:

1
Group B = [P2, P3]

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
2
P2:
A -> B

节点从 A 的服务集合中移出,加入 B。

追加

1
2
P2:
A -> [A, B]

节点同时服务两个逻辑分组。

从资源角度看,追加通常更灵活,因为节点原来的业务不需要立刻失去服务能力。

共享冗余池

进一步可以把:

1
2
3
A 预留 2 台
B 预留 2 台
C 预留 2 台

改成:

1
2
3
共享 Reserve Pool

根据实时压力动态分配

这解决的是另一个典型问题:

隔离提高了稳定性,却容易制造资源碎片。

动态分组就是在“隔离”和“资源利用率”之间增加一个弹性层。

动态分组还有一个容易忽视的坑

注册中心中的逻辑分组被修改后,Provider 自己可能仍然认为:

1
我属于 Group A

而 Consumer 按动态策略发送:

1
Group B

如果 Provider 内部还做:

1
request.group.equals(provider.group)

请求就会被拒绝。

因此动态分组不能只改 Registry 数据,还需要设计:

1
2
3
真实分组
逻辑分组
临时分组

之间的关系。

一种思路是请求上下文中同时携带真实分组信息,让 Provider 的校验逻辑能够识别动态映射,而不是简单做字符串相等判断。

泛化调用:没有接口 Jar 也能完成 RPC

为什么正常 RPC 需要接口

通常调用端会写:

1
2
OrderService orderService = ...
orderService.query(...);

动态代理是根据:

1
OrderService.class

生成的。

因此 Consumer 通常必须依赖 Provider 发布的 API Jar。

大部分业务系统这样做完全没问题。

但有两个特殊场景会马上遇到困难。

场景一:统一 RPC 测试平台

测试平台希望用户输入:

1
2
3
4
5
interface
group
method
parameter types
parameters

然后直接调用任意 RPC 服务。

如果每增加一个 RPC 接口都要:

1
2
3
4
5
给测试平台加 Maven 依赖

重新编译

重新发布

这个平台就失去了“统一”意义。

场景二:HTTP -> RPC 网关

例如:

1
2
3
4
5
6
7
8
HTTP Client

RPC Gateway

OrderService
UserService
PaymentService
...

网关不可能静态依赖公司所有 RPC API。

这就需要泛化调用(Generic Invocation)。

RPC 真正需要的不是 Java Interface,而是调用元数据

一次请求要完成方法定位,真正需要的是:

1
2
3
4
5
接口名
业务分组
方法名
参数类型
参数值

因此完全可以定义统一入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface GenericService {

Object $invoke(
String methodName,
String[] paramTypes,
Object[] params
);

CompletableFuture<Object> $asyncInvoke(
String methodName,
String[] paramTypes,
Object[] params
);
}

创建 GenericService Proxy 时,再额外指定真正目标:

1
2
interfaceName = com.demo.OrderService
group = order

最终请求就可以构造成:

1
2
3
4
5
6
interface = com.demo.OrderService
group = order
method = query
types = ["java.lang.Long"]
params = [10001]
generic = true

服务端接收到请求后,仍然按照普通 RPC 的方式定位:

1
2
3
4
5
interface + group + method

Exporter

真实服务对象

因此泛化调用并不是另外发明了一种 RPC。

它只是:

不再依赖强类型接口作为调用入口,而是直接构造 RPC 所需要的元数据。

没有 Class,序列化怎么办

这才是泛化调用真正棘手的地方。

正常调用:

1
CreateOrderRequest request

Consumer 知道:

1
CreateOrderRequest.class

于是可以按照正常方式序列化。

泛化调用可能只拿到:

1
2
3
4
{
"userId": 1001,
"skuId": 2001
}

这时 Consumer 甚至没有目标 POJO Class。

因此可以为泛化调用设计单独的序列化逻辑,用通用数据结构承载 POJO:

1
2
3
4
5
6
7
Map
List
String
Number
Boolean
Array
...

服务端知道真实接口定义,所以收到 Map 后,可以依据参数类型恢复真正的对象。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Consumer:

Map
{
"userId": 1001,
"skuId": 2001
}

↓ generic serializer

binary



Provider:

CreateOrderRequest

返回值则反向转换成通用结构。

此外,协议中最好存在明确的泛化调用标记:

1
generic = true

而不是依靠某个特殊方法名“猜测”请求类型。

这样后续才能方便地为泛化请求配置独立序列化器和处理链。

多协议兼容:架构升级不应该要求全公司同一晚切换

多种 RPC 协议为什么会长期共存

企业系统通常不是一天建成的。

可能先后使用过:

1
2
3
4
5
6
WebService
Hessian
Dubbo
自研 RPC
gRPC
...

如果直接要求:

1
2
3
所有应用
同一时间
全部切换到新 RPC

对于大型系统几乎不可操作。

调用关系很可能已经是网状结构:

1
2
3
4
A ↔ B
↓ ↓
C ↔ D
↘ E ↙

很难找到一个绝对安全的升级顺序。

更合理的是协议兼容窗口

理想状态是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
老 Consumer

│ old protocol

┌────────────────┐
│ New RPC │
│ │
│ old protocol │
│ new protocol │
└────────────────┘

│ new protocol

新 Consumer

这样 Provider 只升级一次。

随后 Consumer 可以逐个迁移。

当最后一个旧 Consumer 下线之后,再移除旧协议。

用 magic number 识别协议

不同二进制协议通常都有自己的头部特征。

可以抽象成:

1
2
3
4
5
6
7
magic number
version
message type
serialization
request id
body length
payload

RPC Server 收到数据后先识别:

1
magic number

再匹配:

1
2
3
Protocol A
Protocol B
Protocol C

流程变成:

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
ARequest

Protocol B 得到:

1
BRequest

如果后面的 Filter、路由、鉴权、反射都分别支持:

1
2
3
4
ARequest
BRequest
CRequest
...

RPC 核心代码会迅速失控。

因此在协议层之后应统一转换为:

1
RequestMessage<Invocation>

类似这样的内部结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
Protocol A

ARequest

Internal Request

RPC Core

Internal Result

AResponse

Protocol A

另一种协议走:

1
2
3
4
5
6
7
Protocol B

BRequest

Internal Request

同一个 RPC Core

这样新增协议时,核心业务调用链不需要修改。

不要把协议支持写成巨型 if/else

如果代码最终变成:

1
2
3
4
5
6
7
if (magic == A) {
...
} else if (magic == B) {
...
} else if (magic == C) {
...
}

协议数量一多,维护会非常困难。

更合理的是插件模型:

1
2
3
4
Protocol SPI
├─ ProtocolA
├─ ProtocolB
└─ ProtocolC

协议插件自己声明:

1
2
3
4
支持的 magic number
Codec
Serializer
协议对象转换规则

ProtocolAdapter 只负责发现和匹配。

这也是插件化 RPC 架构的价值之一。

从 JoyRPC 的代码骨架看 RPC 如何真正跑起来

上面的能力如果只停留在概念层,仍然比较分散。

一套实际 RPC 代码可以通过三个核心流程串起来:

1
2
3
服务端启动
调用端启动
一次 RPC 调用

入口层的核心抽象:Invoker

RPC 中可以抽象出一个统一调用接口:

1
2
3
CompletableFuture<Result> invoke(
RequestMessage<Invocation> request
);

核心有两个实现方向:

1
2
3
Invoker
├─ Exporter
└─ Refer

其中:

1
2
3
Refer
Consumer 侧
负责向 Provider 发请求
1
2
3
Exporter
Provider 侧
负责接收并执行请求

这两个对象可以理解为整个入口层最关键的两个支点。

Provider 启动:先 Export,再 Open

一个简化的 Provider 配置过程类似:

1
2
3
4
5
6
7
8
9
10
11
DemoService demoService = new DemoServiceImpl();

ProviderConfig<DemoService> providerConfig = new ProviderConfig<>();

providerConfig.setServerConfig(new ServerConfig());
providerConfig.setInterfaceClazz(DemoService.class.getName());
providerConfig.setRef(demoService);
providerConfig.setAlias("joyrpc-demo");
providerConfig.setRegistry(new RegistryConfig("broadcast"));

providerConfig.exportAndOpen();

exportAndOpen() 又被拆成:

1
2
3
export()

open()

这不是单纯为了方法命名好看,而是在区分两个生命周期阶段。

Export 阶段

核心工作是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ProviderConfig

生成 registryUrl
生成 serviceUrl

创建 Registry

连接 Registry

订阅接口/全局配置

InvokerManager

创建 Exporter

可以表示成:

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
2
3
4
5
Filter Chain
认证信息
调用上下文
方法元数据
...

Open 阶段

Exporter 已经存在,但还不能对外服务。

Open 阶段负责:

1
2
3
4
5
6
7
8
9
10
11
接口预热

EndpointFactory

创建 Server

Server.open()

监听端口

Registry.register()

可以看到服务真正注册到注册中心之前,Server 应该已经具备提供服务的能力。

否则很容易出现:

1
2
3
4
5
Registry 已经可见

Consumer 开始调用

Provider 端口还没准备好

这种启动竞态。

Consumer 启动:核心是 Refer + Cluster

Consumer 的使用方式类似:

1
2
3
4
5
6
7
8
9
ConsumerConfig<DemoService> consumerConfig = new ConsumerConfig<>();

consumerConfig.setInterfaceClazz(DemoService.class.getName());
consumerConfig.setAlias("joyrpc-demo");
consumerConfig.setRegistry(new RegistryConfig("broadcast"));

CompletableFuture<DemoService> future = consumerConfig.refer();

DemoService service = future.get();

启动过程可以抽象成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ConsumerConfig

Registry

创建动态代理

订阅配置

InvokerManager

创建 Cluster

创建 Refer

Refer.open()

Cluster.open()

其中最复杂的对象之一是 Cluster

它需要负责:

1
2
3
4
5
6
7
8
Provider 节点列表
节点变化监听
Client 创建
连接建立
连接状态
心跳
协商
安全验证

因此:

1
2
3
4
5
Registry
负责告诉你“有哪些节点”

Cluster
负责把这些节点真正变成“可以调用的连接集合”

这是两个不同层面的职责。

Consumer 发起请求的完整链路

当业务代码:

1
service.sayHello("hello");

真正发生的调用链可以整理为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Proxy

ConsumerInvokerHandler

Refer.invoke()

消息透传

FilterChain

Refer.distribute()

NodeSelector

Route

LoadBalance

Refer.invokeRemote()

Client

Transport

进一步展开:

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
2
3
鉴权
链路追踪
流量旁录

可以进入 Filter。

1
2
分组
路由规则

可以进入 NodeSelector。

1
Failover

由 Route 控制。

1
2
3
4
Random
RoundRobin
一致性哈希
...

则由 LoadBalance 负责。

这就是为什么插件化会比一个几千行的 invoke() 更容易演进。

Provider 收到请求后的处理链

服务端则大致经过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Network

ProtocolAdapter

Protocol

Codec

反序列化

Channel Handler

业务线程池

BizReqHandler

根据接口/分组/方法找 Exporter

Exporter.invoke()

FilterChain

invokeMethod()

反射或预编译调用

CompletableFuture<Result>

Channel

序列化/编码

Response

其中多协议能力就自然出现在:

1
ProtocolAdapter

后面。

泛化调用可以在恢复调用参数时进入专属处理逻辑。

鉴权和业务级 Filter 可以位于:

1
Exporter.invoke()

之后。

异步则贯穿:

1
2
3
4
5
Refer.invoke()
Exporter.invoke()
Server.open()
Registry.register()
...

这些核心流程。

为什么“纯异步”与“插件化”是同一套设计思想

表面上看:

1
CompletableFuture

解决性能问题;

1
Plugin

解决扩展问题。

实际上二者共同指向一个目标:

避免核心流程被某一个具体实现长期绑死。

纯异步意味着:

1
核心调用链不假设“这个操作现在必须完成”

插件化意味着:

1
核心调用链不假设“这个功能必须由某个固定实现完成”

于是 RPC Core 更像一个协调器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
什么时候完成
由 Future 决定

怎么序列化
由 Serializer 决定

走哪个协议
由 Protocol 决定

选哪个节点
由 LoadBalance 决定

怎么鉴权
由 Filter 决定

这就是微内核架构真正有价值的地方。

把高级能力重新映射到源码位置

把前面的内容放回四层架构,可以得到一张非常有用的工程地图:

能力 主要落点 核心原因
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
2
3
4
5
6
7
8
9
这个类属于哪一层?

它是在启动阶段还是调用阶段?

它处理的是治理逻辑还是协议逻辑?

它位于 Consumer 还是 Provider?

它最终影响的是哪个核心抽象?

这些问题往往比记住某个类名更重要。

从 ICE、Hessian、Dubbo 到基础设施下沉

RPC 的演进历史本身也能解释这些设计为什么会出现。

ICE:跨语言通信问题

早期系统从 .NET 向 Java 迁移时,首先面对的是:

1
2
3
.NET Application

Java Application

这时候 RPC 解决的核心矛盾仍然是:

如何让两个不同技术栈的应用完成远程调用。

ICE 这类框架在这一阶段能够很好地解决跨语言通信问题。

Hessian:和 Java/Spring 更自然地结合

当系统逐渐以 Java 为主后,开发体验变得更加重要。

Hessian 可以更自然地和 Spring 应用结合,因此 RPC 接口开发成本下降。

但是随着规模增大,另一个问题出现:

只有 RPC 通信还不够,还需要服务发现。

如果所有 Provider 都通过 VIP 暴露:

1
2
3
4
5
Consumer

VIP / LVS

Provider Cluster

流量会集中经过中间节点,同时每一个服务都需要维护相应 VIP。

RPC 开始从:

1
通信框架

走向:

1
服务化框架

Dubbo + ZooKeeper:服务发现进入 RPC

Dubbo 一类框架把:

1
2
3
4
服务注册
服务发现
路由
负载均衡

更紧密地纳入 RPC 体系。

调用关系变为:

1
2
3
4
5
6
7
Provider -> Registry

Consumer -> Registry

Provider List

Consumer 直连 Provider

这比所有流量经过中心 VIP 更适合大规模服务化。

但随着:

1
2
3
接口越来越多
实例越来越多
扩容越来越频繁

注册中心本身也会面对更大压力。

这又推动了新的架构选择和自研需求。

从 SDK 治理走向基础设施治理

传统 RPC 经常把大量能力封装进应用 Jar:

1
2
3
4
5
6
7
8
RPC SDK
├─ 服务发现
├─ 路由
├─ 负载均衡
├─ 熔断
├─ 链路
├─ 鉴权
└─ ...

应用需要:

1
2
3
引入 SDK
升级 SDK
重新发布

随着 Kubernetes 等基础设施的发展,越来越多非业务能力开始尝试从应用进程中剥离。

Service Mesh 所代表的一种方向就是:

1
2
3
4
5
6
7
8
9
10
业务应用

│ 更少感知基础设施细节

通信代理 / 基础设施

├─ 服务发现
├─ 流量治理
├─ 安全
└─ 可观测

这并不意味着 RPC 的服务治理消失了。

恰恰相反:

治理能力一直存在,只是它可能从 SDK 移到了更低的基础设施层。

所以理解 RPC 原理并不会因为云原生出现而失去价值。

服务发现、路由、负载均衡、超时、重试、链路传播、协议转换这些问题依旧存在,只是实现位置可能发生变化。

几个很容易被误解的工程结论

异步不等于一定更快

如果四个请求互相依赖:

1
A -> B -> C -> D

就不能通过简单并行变成 1/4 时间。

如果异步任务最终又全部堆积在另一个饱和线程池里,同样不会获得无限吞吐量。

异步优化的是等待模型和线程使用方式,不是消灭真实工作量。

线程多不等于吞吐量高

当瓶颈来自:

1
2
3
4
慢 SQL
下游 RPC
磁盘 IO
外部资源

继续增加线程可能只会导致:

1
2
3
4
更多排队
更多上下文切换
更多内存占用
更差的尾延迟

真正应该先搞清楚:

1
线程到底在等什么?

内网不等于可信网络

“只有内部系统使用”并不能代替:

1
2
3
4
调用方身份
接口权限
方法权限
Provider 身份

流程制度可以减少误调用,但技术系统应该具备真正的强制边界。

TraceId 不是加个日志字段就结束了

真正困难的是:

1
2
3
4
跨 RPC
跨线程池
跨异步回调
跨 MQ

都必须保证上下文连续。

否则最需要链路信息的地方,往往恰好最容易丢 TraceId。

时间轮不是高精度定时器

它优先解决的是:

1
2
3
海量定时任务
+
减少扫描成本

而不是微秒级、严格实时触发。

流量回放也不是简单复制请求

真正的业务结果还依赖:

1
2
3
4
5
数据状态
时间
缓存
下游服务
副作用

如果忽略这些条件,很容易把“环境不同”误判成“代码有 Bug”。

动态分组不是改一个字符串那么简单

逻辑分组发生变化后:

1
2
3
4
5
服务发现
请求元数据
Provider 校验
监控
容量统计

都必须理解这种临时拓扑变化。

否则注册中心认为调用合法,Provider 自己却把请求拒绝了。

多协议兼容的重点是“内部模型统一”

magic number 只是入口。

真正决定架构能不能长期维护的是:

1
2
协议差异
必须在进入 RPC Core 之前被抹平

否则支持三个协议之后,整个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Registry
├─ 服务发现
├─ Provider 身份验证
├─ 分组
└─ 动态分组

Observability
├─ Trace
├─ Span
├─ Exception Code
└─ Logs

Replay Platform
├─ Record
├─ Store
├─ Replay
└─ Compare

这时再来看 RPC,会发现真正复杂的部分早已不是:

1
Socket 怎么发送一段二进制

而是:

1
2
3
如何让几千个服务
在持续变化的生产环境中
安全、稳定、可观测、可扩展地通信

总结

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、负责服务发现和连接管理的 RegistryCluster、处理协议的 Protocol,以及创建 Client/Server 的传输层工厂。核心 InvokerCompletableFuture 表达异步结果,外围能力再通过插件不断扩展。

所以学习 RPC 最有价值的部分,并不是记住某个框架的一组配置,而是理解这些设计问题为什么会出现,以及它们应该位于调用链的哪一个位置。

当这些问题真正想通之后,再去阅读 Dubbo、gRPC、自研 RPC,甚至 Service Mesh 中的通信与治理逻辑,会发现底层面对的仍然是同一批问题:

1
2
3
4
5
6
7
8
9
请求怎么发出去?
节点从哪里找到?
流量应该发给谁?
失败以后怎么办?
多久算超时?
谁有资格调用?
调用到底慢在哪里?
如何平滑升级?
怎样让框架继续演进?

RPC 的网络调用只是起点。

围绕远程调用建立稳定、可治理、可观测、可扩展的分布式通信体系,才是完整的 RPC 工程。


RPC 高级工程实践与源码脉络:异步、安全、可观测、流量治理与协议演进
https://allendericdalexander.github.io/2026/08/18/archtect/rpc/RPCAdvance/
作者
AtLuoFu
发布于
2026年8月18日
许可协议