RPC 核心原理:从通信流程、协议与序列化到 Netty、动态代理和 gRPC

RPC(Remote Procedure Call,远程过程调用)是分布式系统中最基础的通信抽象之一。它真正解决的并不只是“让两台机器能够通信”,而是如何把接口代理、协议 framing、序列化、网络 IO、请求与响应关联、服务端分发等复杂过程隐藏起来,让业务代码尽可能获得类似本地方法调用的开发体验。本文从一次完整 RPC 调用出发,系统梳理协议设计、序列化选型、IO 多路复用、零拷贝、动态代理,并最终通过 gRPC 的收发链路把这些知识串联起来。

RPC 到底解决了什么问题

RPC 的全称是 Remote Procedure Call,也就是“远程过程调用”。

仅仅通过网络访问另一台机器,并不足以完整描述 RPC。RPC 更重要的一层含义,是尽可能屏蔽远程通信的实现细节,为调用者提供接近本地方法调用的编程模型。

假设订单服务需要查询用户信息。

如果没有 RPC 框架,业务代码可能不得不亲自处理:

  • 服务地址从哪里获得;
  • TCP 连接怎么建立;
  • 请求参数如何转换成字节;
  • 如何设计报文格式;
  • 一条消息在哪里结束;
  • 请求属于哪个接口、哪个方法;
  • 返回的数据如何与请求对应;
  • 如何反序列化响应;
  • 网络超时怎么办;
  • 连接断开怎么办。

最终,一个原本只有一句业务含义的代码:

1
User user = userService.getUser(userId);

背后可能变成几十甚至几百行网络通信代码。

RPC 框架做的事情,就是把这些逻辑下沉到基础设施层。

从调用者视角看:

1
调用接口 -> 得到结果

从 RPC 框架内部看,则是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
方法调用
-> 代理拦截
-> 构造 RPC 请求
-> 序列化
-> 协议编码
-> 网络发送
-> 协议解码
-> 反序列化
-> 服务定位
-> 方法执行
-> 构造响应
-> 序列化
-> 协议编码
-> 网络返回
-> 解码
-> 反序列化
-> 返回调用结果

RPC 的核心价值可以概括为两件事:

第一,屏蔽网络编程复杂度。

业务开发者不需要直接操作 Socket、字节流和连接生命周期。

第二,构造远程调用抽象。

调用者主要面对接口、方法、参数和返回值,而不是“发送一个网络数据包”。

这也是为什么 RPC 经常被称为分布式系统的“通信骨架”。


本地调用和远程调用终究不是一回事

RPC 经常强调“像调用本地方法一样调用远程服务”,但这里的关键词是像,而不是“完全等价”。

本地调用大致是:

1
2
3
4
5
6
7
8
9
10
线程
|
v
方法调用
|
v
内存中的对象
|
v
返回值

远程调用则至少跨越:

1
2
3
4
5
6
7
8
9
10
11
12
13
调用方 JVM
|
| 序列化
v
网络协议
|
| TCP / HTTP/2
v
服务提供方 JVM
|
| 反序列化
v
业务方法

两者在失败模型上完全不同。

本地方法执行时,通常不会突然遇到:

  • 网络抖动;
  • 连接中断;
  • 服务端已经执行成功但客户端超时;
  • 请求重复发送;
  • 服务端实例突然下线;
  • 序列化版本不兼容;
  • 下游服务过载;
  • 整条调用链超时预算耗尽。

所以:

RPC 可以隐藏远程调用的实现细节,但无法消除分布式系统本身的不确定性。

这是理解 RPC 最重要的前提之一。


一次完整的 RPC 调用经历了什么

先把所有组件放到同一张图里。

flowchart LR
    A[业务代码] --> B[接口代理 / Stub]
    B --> C[构造 RPC Request]
    C --> D[序列化]
    D --> E[协议编码]
    E --> F[网络 IO]
    F --> G[协议解码]
    G --> H[反序列化]
    H --> I[服务与方法定位]
    I --> J[业务方法执行]

    J --> K[构造 RPC Response]
    K --> L[序列化]
    L --> M[协议编码]
    M --> N[网络 IO]
    N --> O[协议解码]
    O --> P[反序列化]
    P --> Q[完成 Future / 返回结果]
    Q --> A

如果再从时序角度观察:

sequenceDiagram
    participant B as 业务代码
    participant P as Proxy/Stub
    participant C as RPC Client
    participant N as Network
    participant S as RPC Server
    participant I as Service Impl

    B->>P: userService.getUser(id)
    P->>C: 构造 RPC Request
    C->>C: 序列化 + 协议编码
    C->>N: 发送二进制数据
    N->>S: TCP/HTTP2 数据
    S->>S: 协议解码 + 反序列化
    S->>I: 调用目标方法
    I-->>S: 返回结果
    S->>S: 序列化 + 协议编码
    S-->>N: 返回响应
    N-->>C: 二进制响应
    C->>C: 解码 + 反序列化
    C-->>P: RPC Response
    P-->>B: 返回方法结果

这条链路里,有几个特别容易混淆的概念:

层次 主要解决的问题
动态代理 / Stub 如何把普通方法调用变成 RPC 调用
序列化 对象如何变成字节,以及如何恢复
RPC 协议 字节流如何表示一条完整 RPC 消息
编解码 如何按照协议格式写入和读取网络消息
网络 IO 字节如何高效地在机器之间传输
请求关联 异步并发时,哪个 Response 属于哪个 Request
服务分发 收到请求后应该调用哪个服务、哪个方法

把这几层分清之后,RPC 的内部结构就不再神秘。


序列化和协议编码不是一回事

这是 RPC 中很常见的混淆点。

例如业务代码有:

1
UserRequest request = new UserRequest(1001L);

序列化负责:

1
2
3
4
5
UserRequest 对象
|
| Protobuf / Hessian / JSON
v
业务数据字节

协议编码则是在这段数据之外继续增加 RPC 通信所需的信息:

1
2
3
4
5
6
7
+-----------------------------------------------+
| RPC Header |
| Magic | Version | Type | RequestId | Length |
+-----------------------------------------------+
| Payload |
| 序列化后的 UserRequest |
+-----------------------------------------------+

可以把两者类比成发快递。

序列化类似:

1
把家具拆成可以运输的零件

协议编码类似:

1
2
3
4
5
6
装进快递箱,并贴上:
收件信息
包裹编号
尺寸
类型
运输标记

因此完整关系是:

1
2
3
4
5
6
7
8
9
10
11
12
Object
|
| Serialization
v
Payload
|
| Protocol Encoding
v
Frame / RPC Message
|
| Network
v

接收端反过来:

1
2
3
4
5
6
7
8
9
10
11
12
13
Network
|
v
Protocol Decoding
|
v
Payload
|
v
Deserialization
|
v
Object

为什么 RPC 需要自己的应用层协议

TCP 提供的是可靠字节流。

这里最关键的是“字节流”。

假设调用方连续发送三条消息:

1
2
3
AB
CD
EF

应用层不能假设服务端每次 read() 都会恰好得到:

1
2
3
AB
CD
EF

实际可能读到:

1
ABCDEF

也可能:

1
2
ABC
DEF

甚至:

1
2
3
A
BCDE
F

TCP 并不知道:

1
AB

是一条业务消息。

对于 TCP 来说,它们只是连续的字节。

所谓“TCP 粘包、拆包”,从应用协议的角度理解,本质就是:

TCP 是字节流,而应用需要消息边界。

所以 RPC 必须自己规定:

1
2
3
4
一条消息从哪里开始?
有多长?
是什么类型?
Payload 怎么解析?

这就是应用层 RPC 协议存在的意义。


最简单的 RPC 协议:长度 + Payload

最直接的设计方式,是在消息前写入长度。

1
2
3
4
+----------------+----------------------+
| Length | Payload |
| 固定长度 | 可变长度 |
+----------------+----------------------+

例如:

1
Length = 1024

服务端就知道:

1
接下来再读取 1024 字节

才是一条完整消息。

读取流程变成:

1
2
3
4
5
6
7
8
9
10
先读取 Length
|
v
获得 Payload 长度
|
v
继续读取指定字节数
|
v
获得完整消息

但这还不够。

服务端拿到 Payload 后,还不知道:

1
2
3
4
这是什么序列化格式?
是 Request 还是 Response?
属于哪个请求?
协议是什么版本?

于是必须继续设计 Header。


一个典型 RPC Header 应该包含什么

常见 RPC 报文可以抽象成:

1
2
3
4
5
+------------------------------------------------+
| Header |
+------------------------------------------------+
| Payload |
+------------------------------------------------+

Header 中通常需要承载类似信息:

字段 作用
Magic 判断是不是当前 RPC 协议
Total Length 整条消息长度
Header Length Header 自身长度
Version 协议版本
Message Type Request、Response、Heartbeat 等
Serializer Protobuf、Hessian 等
Request ID 请求与响应关联
Extension 扩展能力

Payload 则主要保存:

1
2
3
4
5
服务名
方法名
参数类型
参数值
附件 / Metadata

如果是响应,则可能保存:

1
2
3
4
执行状态
返回值
异常信息
扩展 Metadata

一条 RPC 请求可以抽象为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+------------------------------------------------------+
| Magic |
+------------------------------------------------------+
| Total Length |
+------------------------------------------------------+
| Header Length |
+------------------------------------------------------+
| Version | Type | Serializer |
+------------------------------------------------------+
| Request ID |
+------------------------------------------------------+
| Extensible Header |
+------------------------------------------------------+
| Payload |
| Service / Method / Args / Attachments |
+------------------------------------------------------+

为什么协议头也必须可扩展

假设第一版协议头长度是固定的:

1
88 bit

所有服务都默认:

1
2
前 88 bit = Header
后面的数据 = Payload

后来希望增加:

1
Timeout

如果直接把 Header 扩大:

1
88 bit -> 96 bit

旧版本服务仍然按照 88 bit 解析。

于是:

1
2
3
新版发送方
Header: 96 bit
Payload: ...

到了旧服务:

1
2
Header: 前 88 bit
Payload: 从第 89 bit 开始

Header 后面的 8 bit 就被误认为 Payload。

整个报文立刻错位。

因此协议设计时不能只考虑:

1
今天能不能工作

还必须考虑:

1
两年以后加字段怎么办

一种更合理的思路是:

1
2
3
4
5
固定区域
+
可变 Header
+
Payload

例如:

1
2
3
4
5
6
7
8
9
10
+-------------------------+
| 固定 Header |
| TotalLength |
| HeaderLength |
| Version |
+-------------------------+
| 可扩展 Header |
+-------------------------+
| Payload |
+-------------------------+

解析方首先读取固定区域。

得到:

1
2
HeaderLength
TotalLength

之后就能够知道:

1
2
3
Header 读到哪里结束
Payload 从哪里开始
整条消息什么时候结束

这样后续向 Header 中增加字段,就不会强迫所有旧客户端同时修改固定长度。


协议兼容性的核心不是“能增加字段”这么简单

设计可升级协议时,至少需要考虑三个层次。

Header 兼容

新增协议能力时,旧版本能否跳过自己不认识的字段。

Payload 兼容

DTO 增加字段以后,旧调用方能不能继续读取。

语义兼容

即使数据能够反序列化,也不代表业务一定兼容。

例如:

1
2
V1:
status = 0 / 1

到了 V2:

1
status = 0 / 1 / 2

旧客户端即使能成功读取字段,也不一定知道:

1
2

代表什么。

所以 RPC 接口本质上是一份长期存在的契约。

接口升级时,最好遵循:

1
2
3
4
5
优先增加兼容字段
谨慎删除字段
谨慎改变字段类型
谨慎改变字段业务语义
避免复用已经废弃的字段编号

协议层的兼容只是第一关,真正困难的是业务契约兼容。


Request ID:异步 RPC 如何找到对应的 Response

RPC 为了提高吞吐量,通常会复用连接,同时发送多个请求。

假设:

1
2
3
Request 1001
Request 1002
Request 1003

因为三个请求在服务端的执行时间不同,返回顺序完全可能是:

1
2
3
Response 1002
Response 1001
Response 1003

因此不能依赖:

1
第一个返回值就是第一个请求

常见做法是在 Request Header 中增加唯一的:

1
Request ID

客户端发送时:

1
requestId = 1001

并在内存中保存类似关系:

1
2
3
1001 -> Future A
1002 -> Future B
1003 -> Future C

服务端处理完请求以后,把相同的 Request ID 放入响应:

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

客户端收到后查找:

1
pendingRequests[1002]

然后完成对应的 Future。

整体过程:

sequenceDiagram
    participant C as RPC Client
    participant M as Pending Map
    participant S as RPC Server

    C->>M: 1001 -> Future A
    C->>S: Request(id=1001)

    C->>M: 1002 -> Future B
    C->>S: Request(id=1002)

    S-->>C: Response(id=1002)
    C->>M: remove(1002)
    M-->>C: Future B
    C->>C: complete Future B

    S-->>C: Response(id=1001)
    C->>M: remove(1001)
    M-->>C: Future A
    C->>C: complete Future A

Request ID 因此是实现:

1
2
3
4
5
连接复用
+
异步并发
+
请求响应关联

的重要基础。


一个需要纠正的 HTTP 认识

在解释为什么很多 RPC 框架会设计私有协议时,经常会看到一种过度简化的说法:

1
HTTP/1.x 每次请求都必须重新建立连接

这个结论不能直接作为 HTTP/1.1 的协议事实。

HTTP/1.1 支持持久连接。

真正应该比较的是:

  • 协议头开销;
  • 编解码成本;
  • 连接复用能力;
  • 并发模型;
  • 是否支持多路复用;
  • 双向流能力;
  • 浏览器和基础设施兼容性;
  • 跨语言能力。

这也解释了一个非常重要的事实:

RPC 并不意味着一定要直接构建在裸 TCP 私有协议之上。

gRPC 就是典型例子:

1
2
3
4
5
6
7
RPC Programming Model
|
v
HTTP/2
|
v
TCP

所以:

1
HTTP 和 RPC

并不是只能二选一。

它们处在不同的抽象维度。


序列化:Java 对象为什么不能直接在网络上传输

业务方法通常操作的是对象:

1
2
3
Order order;
User user;
CreateOrderRequest request;

但网络层最终处理的是字节。

因此必须完成:

1
2
3
4
5
Object
|
| Serialize
v
Bytes

接收端再执行:

1
2
3
4
5
Bytes
|
| Deserialize
v
Object

序列化框架本质上都需要解决:

1
2
3
4
5
6
7
对象是什么类型?
有哪些字段?
字段是什么类型?
字段值是什么?
嵌套对象怎么表示?
集合怎么表示?
不同版本怎么兼容?

最终按照某种约定写入字节流。

反序列化时,再按照同样规则恢复对象。


JDK 原生序列化

Java 提供:

1
ObjectOutputStream

进行序列化,以及:

1
ObjectInputStream

进行反序列化。

典型对象:

1
2
3
4
5
6
7
public class Student implements Serializable {

private int no;
private String name;

// getter / setter
}

底层序列化数据除了真正的属性值,还需要写入大量帮助恢复对象的元信息,例如:

1
2
3
4
5
6
7
8
Magic
Version
Class 信息
字段信息
字段类型
字段值
对象引用
结束标记

对象存在:

1
2
3
继承
引用
嵌套

时,还需要继续递归处理对象关系。

JDK 序列化的优势是:

1
2
3
Java 原生
使用简单
对象模型表达能力强

但作为 RPC 默认序列化方式时,需要非常谨慎。

一方面序列化体积和性能未必理想,另一方面 Java 原生反序列化长期以来存在较大的安全攻击面。

因此:

RPC 序列化选型时,安全性不能只作为性能之后的附加指标。


JSON

JSON 最大的优点不是速度,而是通用性和可读性。

例如:

1
2
3
4
{
"id": 1001,
"name": "Mario"
}

人可以直接读取,调试非常方便。

它天然适合:

  • HTTP API;
  • Web 前后端通信;
  • 配置;
  • 日志;
  • 调试接口;
  • 对可读性要求高的系统。

但 JSON 是文本格式。

相对于紧凑的二进制协议,会产生更多:

1
2
3
4
字段名
引号
分隔符
文本数字

另外,JSON 自身没有 Java 那样完整的静态类型信息。

Java 在恢复对象时通常还需要结合:

1
2
3
4
目标 Class
泛型信息
反射
类型转换

因此对于大量、高频 RPC 调用,JSON 往往不是最追求性能时的第一选择。

不过这并不意味着:

1
JSON = 不适合 RPC

正确的结论应该是:

序列化方案应该服务于业务场景,而不是脱离场景比较跑分。


Hessian

Hessian 属于:

1
2
3
4
动态类型
二进制
跨语言
相对紧凑

的序列化方案。

相比 JDK 原生序列化和 JSON,它通常可以得到更紧凑的结果,同时保留相对方便的 Java 对象使用体验。

这也是传统 Java RPC 框架中 Hessian 很有吸引力的原因。

不过实际使用中必须关注:

1
2
3
4
5
具体 Hessian 实现
具体版本
集合类型兼容
特殊 Java 类型
数字类型转换

材料中的工程经验曾特别提到过:

1
2
3
4
LinkedHashMap
LinkedHashSet
Locale
Byte / Short

等类型的兼容问题。

这类行为具有明显的版本和实现差异,所以不要把某个版本的限制理解成所有 Hessian 实现的永久规则。

真正上线前应该通过兼容性测试验证自己的 DTO。


Protocol Buffer

Protocol Buffer 通常需要先定义 IDL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syntax = "proto3";

package hello;

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

service HelloService {
rpc Say(HelloRequest) returns (HelloReply);
}

然后通过编译器生成不同语言的代码。

这种方式最大的变化是:

1
接口契约

不再依赖某一种编程语言的 Class。

而是:

1
.proto

成为跨语言契约。

Protocol Buffer 的主要优势包括:

  • 二进制体积较小;
  • 编解码效率较高;
  • 类型信息明确;
  • 不需要运行时依赖大量反射恢复字段类型;
  • 支持多语言;
  • 字段编号机制适合协议演进。

它非常适合:

1
2
3
Java -> Go
Java -> C++
Go -> Python

这类跨语言 RPC。

代价则是增加:

1
2
3
IDL
代码生成
Schema 管理

这一层工程流程。

这其实是一种典型取舍:

1
2
3
动态方便
vs
强契约 + 高性能 + 跨语言

关于 Protobuf 的 null 要特别小心

Java 对象世界里经常有:

1
null

但 Protobuf 的字段语义并不能简单等同于 Java 对象里的 null。

因此,不应该笼统写成:

1
Protobuf 不支持 null

然后停止分析。

真正需要关注的是:

1
2
3
4
5
字段是否存在
默认值
optional 语义
生成代码的表示方式
应用 DTO 与 Proto Message 的转换规则

如果业务内部高度依赖:

1
null

区分某种业务状态,那么在设计 Proto 契约时应该显式设计语义,而不是寄希望于 Java 对象行为自动映射过去。


Protostuff

Protostuff 的吸引力在于:

1
2
3
希望获得接近 Protobuf 的编码效率
+
又不想为所有 Java DTO 编写 IDL

它允许直接基于 Java 对象进行序列化。

不过材料中的实践经验也说明了一个非常典型的问题:

序列化框架的兼容性行为会随着版本变化。

例如资料早期版本曾记录:

1
2
3
对象数组 null 元素
集合
Map

等兼容问题,后续版本又有所改进。

所以面对任何序列化框架,都不能只记:

1
支持 / 不支持

更应该问:

1
2
3
4
哪个版本?
支持到什么程度?
跨版本行为如何?
生产数据升级以后还能不能读取?

RPC 序列化应该怎么选

序列化选型很容易陷入:

1
谁最快?

但生产 RPC 的优先级不能只有 benchmark。

更合理的顺序是:

1
2
3
4
5
6
7
8
9
10
11
安全性
↓
通用性
↓
兼容性
↓
性能
↓
编解码效率
↓
空间开销

其中最容易被低估的是:

1
兼容性

因为一次序列化速度慢 10%,可能只是:

1
多消耗一点 CPU

但升级一次之后新旧服务无法互调,结果可能是:

1
整个服务不可用

可靠性通常比微小的 benchmark 差距更重要。

可以粗略比较:

方案 可读性 体积 性能 跨语言 Schema 典型特点
JDK Serialization 低 较大 一般 差 无 Java 原生,但 RPC 中应谨慎
JSON 高 较大 一般 高 弱 调试方便、生态极强
Hessian 低 较小 较高 较好 无 Java 使用方便
Protobuf 低 小 高 高 强 高性能、强契约、跨语言
Protostuff 低 小 高 主要偏 Java 弱 减少 Java 场景 IDL 工作

这个表不是绝对性能排行榜。

最终还是应该以:

1
2
3
4
自己的对象模型
自己的数据规模
自己的语言组合
自己的版本升级方式

进行测试。


RPC DTO 最好保持简单

序列化框架再快,也扛不住糟糕的对象设计。

RPC 参数最常见的几个问题如下。

对象层级过深

例如:

1
2
3
4
5
6
7
8
9
Order
├── User
│ └── Address
│ └── Province
├── Shop
│ └── Company
└── Items
└── SKU
└── ...

序列化时需要不断递归。

对象关系越复杂:

1
2
3
CPU 消耗越大
序列化结果越大
兼容风险越高

RPC DTO 不应该直接等于整个领域对象图。


一次传输超大集合

例如:

1
2
List<Order> orders;
Map<Long, User> users;

序列化之后达到数 MB。

影响的不只是网络带宽,还包括:

1
2
3
4
5
6
7
对象遍历
序列化 CPU
内存分配
缓冲区
GC
网络传输
服务端反序列化

因此“大对象 RPC 超时”未必是:

1
网络太慢

也可能大量时间耗费在网络之前和之后。


使用过于特殊的集合类型

接口 DTO 最好优先选择:

1
2
3
4
常见
稳定
语言原生
序列化框架明确支持

的数据结构。

不要因为业务内部用了某个特殊集合实现,就直接把它暴露成远程契约。


复杂继承关系

序列化:

1
2
3
Child
extends Parent
extends Base

通常意味着框架还需要继续遍历父类字段。

RPC DTO 更适合:

1
2
3
简单数据结构
+
组合

而不是复杂领域继承体系。


网络 IO 是整个 RPC 的地基

完成:

1
对象 -> 二进制

之后,还需要真正把数据送到另一台机器。

典型 RPC 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Client
|
| write
v
Socket
|
v
Network
|
v
Socket
|
| read
v
Server

因此高性能 RPC 的基础问题最终会落到:

1
网络 IO 模型

上。

常见模型可以粗略分成:

模型 特征
Blocking IO 一个 IO 操作可能长期阻塞线程
Non-blocking IO IO 未就绪立即返回
IO Multiplexing 一个线程监听大量连接的就绪事件
Asynchronous IO IO 完成后再进行异步通知

实际高并发 RPC 最重要的是:

1
IO Multiplexing

Blocking IO 为什么简单但难扛高连接数

传统阻塞式 Socket:

1
2
3
4
5
6
7
8
Thread
|
v
read()
|
| 数据没到
|
+------ 阻塞 ------>

最直接的服务器模型通常是:

1
2
3
4
Connection A -> Thread A
Connection B -> Thread B
Connection C -> Thread C
...

优势非常明显:

1
2
代码简单
同步编程容易理解

在连接数不多时完全够用。

问题则是:

1
2
3
大量连接
+
大量阻塞线程

会带来:

1
2
3
线程内存
上下文切换
线程调度

等成本。

因此 RPC 服务达到较高并发后,需要另一套模型。


IO 多路复用解决的是什么

IO 多路复用的核心不是:

1
让一个 Socket 同时执行多个请求

而是:

用较少的线程监控大量 Socket 的 IO 就绪状态。

抽象过程:

1
2
3
4
5
6
7
8
9
Socket A ─┐
Socket B ─┤
Socket C ─┼──> Selector / Poller
Socket D ─┤
Socket E ─┘
|
| 哪些 Socket Ready?
v
Event Loop

当某些连接可读:

1
2
A ready
C ready

事件循环再处理:

1
2
read(A)
read(C)

因此一个线程不需要:

1
永远阻塞在某一个连接上

而可以不断处理大量连接的就绪事件。


select、poll 和 epoll

IO 多路复用在不同系统中存在不同实现。

Linux 中经常讨论:

1
2
3
select
poll
epoll

可以从演进思路理解。

select

应用提交一组文件描述符,然后内核检查:

1
哪些已经就绪

大量连接下需要不断遍历集合。

poll

基本思想仍然类似,但数据结构和可管理的描述符数量相比传统 select 有所改善。

epoll

更适合 Linux 高并发网络场景。

不需要每次都对整个监听集合执行同样的线性扫描逻辑,而是维护事件集合,并返回已经就绪的连接。

这也是:

1
2
3
Nginx
Redis
Netty Native Transport

这类高并发组件经常围绕 epoll 进行优化的原因。


为什么 RPC 常选择 Reactor + Netty

对于 Java RPC:

1
自己直接维护 Selector

当然可以。

但这意味着还要亲自解决:

1
2
3
4
5
6
7
8
9
10
连接管理
事件循环
ByteBuffer
半包
粘包
异常传播
线程模型
Back Pressure
SSL
连接空闲检测

于是工程中通常会选择:

1
Netty

这类成熟网络框架。

Netty 最核心的思想之一就是 Reactor/EventLoop。

可以简化理解成:

flowchart LR
    A[Socket Connections] --> B[EventLoop]
    B --> C[Channel]
    C --> D[ChannelPipeline]
    D --> E[Decoder]
    E --> F[Business Handler]
    F --> G[Encoder]
    G --> C

网络线程负责:

1
处理 IO 事件

业务逻辑则可以根据架构进一步分发。

这样就把:

1
高并发连接管理

和:

1
业务执行

分离开来。


什么是零拷贝

普通网络发送过程,可以粗略理解为:

1
2
3
4
5
6
7
8
9
10
11
12
Application
|
v
User Buffer
|
| CPU Copy
v
Kernel Buffer
|
| DMA
v
NIC

读取则大致相反。

如果每次传输都发生大量:

1
2
3
内存复制
+
用户态/内核态切换

在高吞吐系统中会产生明显成本。

于是出现:

1
Zero-copy

思想。

不过“零拷贝”这个名字非常容易产生误解。

它通常并不是:

1
一个字节一次都不复制

更准确地说,是:

尽量减少 CPU 参与的无意义数据复制,以及不必要的用户态和内核态数据搬运。

常见操作系统层面的技术包括:

1
2
mmap
sendfile

它们通过内存映射或直接在内核数据路径之间转移数据,减少传统:

1
Kernel -> User -> Kernel

这样的重复复制。


操作系统零拷贝和 Netty 零拷贝要分开理解

Netty 中经常也会说“零拷贝”。

但其中相当一部分优化发生在:

1
用户空间

目标是:

1
减少 ByteBuf 之间的数据复制

而不是 Linux 内核的 sendfile 那一种问题。

这两个概念必须区分。


CompositeByteBuf:逻辑合并,而不是物理复制

假设一条协议消息由:

1
2
3
Header
+
Body

组成。

最直接的方式是新申请一个大数组:

1
newBuffer

然后:

1
2
copy(header)
copy(body)

形成:

1
Header + Body

Netty 的 CompositeByteBuf 可以让多个 ByteBuf 在逻辑上成为一个 ByteBuf:

1
2
3
ByteBuf A ─┐
├── CompositeByteBuf
ByteBuf B ─┘

底层数据不一定需要全部复制到新的连续空间。

对于频繁进行:

1
2
3
协议组装
拆包
粘包处理

的 RPC 框架非常有价值。


slice:共享底层存储区域

假设一个 ByteBuf:

1
2
3
+-------------------------------------+
| Header | Payload |
+-------------------------------------+

传统方式可能复制出:

1
2
headerBytes
payloadBytes

slice() 可以创建:

1
2
Header View
Payload View

它们仍然共享原来的底层存储。

于是:

1
切分消息

不一定意味着:

1
复制消息

wrap:直接包装已有数据

对于:

1
2
3
byte[]
ByteBuffer
ByteBuf

如果只是为了交给 Netty 使用,没有必要永远重新复制一次。

通过:

1
wrap

可以把已有数据包装成 ByteBuf 视图。

核心思想仍然只有一句:

能复用内存就不要复制。


Direct Buffer

Java 堆内存的数据进行 Socket IO 时,在某些路径中可能还需要复制到适合 native IO 使用的内存区域。

Netty 可以使用:

1
Direct Buffer

即 JVM 堆外直接内存。

这可以减少某些:

1
Heap Buffer -> Direct Buffer

的额外复制。

不过 Direct Buffer 并不等于:

1
网络传输从此完全零复制

它只是优化整个数据路径中的一部分。


FileRegion 与 transferTo

Netty 的 FileRegion 可以结合:

1
FileChannel.transferTo(...)

传输文件内容。

在支持的操作系统上,这类 API 可以进一步利用类似:

1
sendfile

的内核能力。

因此 Netty 中可以同时看到两类“零拷贝”:

1
用户空间 ByteBuf 操作优化

以及:

1
操作系统级文件传输优化

不要把两者混为一谈。


动态代理:RPC 为什么能“像本地调用”

现在回到最上层的问题。

假设存在接口:

1
2
3
4
public interface UserService {

User getUser(long id);
}

调用代码:

1
User user = userService.getUser(1001L);

问题来了。

调用方 JVM 根本不存在真正的:

1
UserServiceImpl

它在另一台机器上。

那这句:

1
userService.getUser(...)

到底调用的是谁?

答案通常是:

1
Proxy / Stub

JDK Dynamic Proxy 的核心原理

可以用一个简化示例理解。

1
2
3
public interface HelloService {
String hello(String name);
}

创建 InvocationHandler:

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
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class RpcInvocationHandler implements InvocationHandler {

private final RpcClient client;

public RpcInvocationHandler(RpcClient client) {
this.client = client;
}

@Override
public Object invoke(
Object proxy,
Method method,
Object[] args) throws Throwable {

RpcRequest request = new RpcRequest(
method.getDeclaringClass().getName(),
method.getName(),
method.getParameterTypes(),
args
);

return client.invoke(request);
}
}

再生成接口代理:

1
2
3
4
5
HelloService helloService = (HelloService) Proxy.newProxyInstance(
HelloService.class.getClassLoader(),
new Class<?>[]{HelloService.class},
new RpcInvocationHandler(rpcClient)
);

此时调用:

1
helloService.hello("Mario");

真正执行的是:

1
2
3
4
5
6
7
Proxy
|
v
InvocationHandler.invoke()
|
v
RpcClient.invoke()

于是远程调用细节被完全藏在:

1
InvocationHandler

之后。


代理类实际上帮我们干了什么

可以把代理理解成:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户调用
|
v
Interface
|
v
Generated Proxy
|
v
Invocation Handler
|
v
RPC Framework

代理层会获得:

1
2
3
4
接口类型
方法
参数类型
参数值

刚好可以构造:

1
RPC Request

所以动态代理与 RPC 的结合非常自然。

它把:

1
Java Method Invocation

转换成了:

1
Remote Invocation Metadata

JDK Proxy 的限制

JDK 动态代理生成的类:

1
2
extends Proxy
implements YourInterface

因此天然适合:

1
面向接口

的编程模型。

这恰好与 RPC 非常契合。

另外,资料中的 JDK Proxy 源码分析基于 JDK 1.7.x 时代的内部实现,因此像:

1
2
3
ProxyGenerator
内部包路径
保存生成 class 的系统属性

这类实现细节不应该直接套用到现代 JDK。

真正应该记住的是稳定原理:

1
2
3
4
5
6
7
生成代理类
|
v
接口方法被代理类实现
|
v
方法内部转发给 InvocationHandler

而不是某一个 JDK 版本的内部类名。


Javassist 和 Byte Buddy

JDK Proxy 之外,也可以通过字节码生成技术创建代理类。

Javassist

Javassist 可以直接操作和生成 JVM 字节码结构。

优势是:

1
不一定需要通过传统反射完成最终调用

因此可以生成更直接的代码路径。

代价则是:

1
2
字节码 API 更底层
理解和维护成本更高

Byte Buddy

Byte Buddy 提供了更高层的字节码生成 API。

相比直接操作底层字节码,通常可以写出更清晰的代理生成逻辑。

对于 RPC 框架而言,代理方案最终需要关注的不只是:

1
能不能代理

还包括:

1
2
3
4
5
6
代理类生成速度
生成字节码大小
代理方法执行速度
API 易用性
依赖复杂度
社区成熟度

因为 RPC 接口方法可能每秒调用几十万甚至更多次。

代理路径本身也属于热点代码。


动态代理并不是 RPC 唯一选择

这里有一个非常重要的设计点。

RPC 需要的是:

1
Stub

而不一定非得:

1
运行时动态代理

另一种方法是编译期生成代码。

例如:

1
2
3
4
5
6
7
8
9
10
IDL
|
v
Code Generator
|
v
UserServiceGrpc
|
v
UserServiceStub

gRPC 就更接近这种模式。

所以:

1
JDK Dynamic Proxy

和:

1
gRPC Generated Stub

表面实现不同,但它们承担了非常相似的角色:

把“调用方法”转换成 RPC 请求,并隐藏后面的通信细节。

这也是理解 RPC 抽象边界的一个关键点。


用 gRPC 把前面的知识全部串起来

前面的协议、序列化、网络 IO 和 Stub 如果单独学习,很容易变成几个互不相关的知识点。

gRPC 恰好可以把它们连接起来。

需要先说明版本背景:

本节调用链以资料分析的 grpc-java 1.27.0 时代实现为主。新版本内部类和具体调用路径可能继续演进,阅读源码时应以所使用版本为准。

但整体思路非常清晰:

1
2
3
4
5
6
7
8
9
IDL
+
Generated Stub
+
Marshaller
+
HTTP/2
+
Netty

第一步:用 Protocol Buffer 定义契约

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.hello";

package hello;

service HelloService {
rpc Say(HelloRequest) returns (HelloReply);
}

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

这份文件同时定义:

1
2
3
4
Service
Method
Request
Response

然后利用:

1
2
3
protoc
+
protoc-gen-grpc-java

生成 Java 代码。

于是客户端不需要自己编写:

1
2
3
4
Socket
Header
Frame
Serializer

而是得到:

1
2
3
4
HelloServiceGrpc
Stub
Request Builder
Response Parser

gRPC 客户端最外层发生了什么

典型客户端可以简化成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ManagedChannel channel =
ManagedChannelBuilder
.forAddress("127.0.0.1", 50051)
.usePlaintext()
.build();

HelloServiceGrpc.HelloServiceBlockingStub stub =
HelloServiceGrpc.newBlockingStub(channel);

HelloRequest request =
HelloRequest.newBuilder()
.setName("Mario")
.build();

HelloReply reply = stub.say(request);

业务代码只看到:

1
stub.say(request);

但它背后实际上经历了一长串调用链。


gRPC 客户端发送链路

按照资料中的核心调用关系,可以抽象成:

flowchart LR
    A[HelloWorldClient] --> B[Generated Stub]
    B --> C[ClientCalls]
    C --> D[ClientCallImpl]
    D --> E[MethodDescriptor / Marshaller]
    E --> F[MessageFramer]
    F --> G[Netty Client Stream]
    G --> H[HTTP/2 Stream]
    H --> I[Network]

可以分成几个关键阶段。

Stub

Stub 负责提供:

1
方法调用 API

也就是:

1
stub.say(request);

ClientCalls

Generated Stub 会进一步进入 gRPC 的通用客户端调用逻辑。

例如同步 Unary RPC 会进入类似:

1
blockingUnaryCall

这样的调用路径。


ClientCallImpl

ClientCallImpl 负责管理一次客户端 RPC 调用生命周期。

其中非常关键的一件事情,就是:

1
把 Message 变成可以发送的数据

MethodDescriptor 是 RPC 方法的元数据

gRPC 中的 MethodDescriptor 非常值得理解。

它并不保存真正业务逻辑,而保存与 RPC 方法有关的元信息,例如:

1
2
3
4
Service / Method
调用类型
Request Marshaller
Response Marshaller

可以把它理解为:

1
RPC Method Schema

其中 Marshaller 就负责:

1
对象 <-> 传输表示

于是:

1
Generated Stub

并不需要知道:

1
Protobuf 到底怎么编码

只需要通过 MethodDescriptor 找到对应的 Marshaller。

这是非常典型的解耦设计。


为什么 gRPC 使用 InputStream,而不是直接 byte[]

资料中的调用链有一个很有意思的设计:

1
2
3
4
5
6
7
Message
|
v
Marshaller
|
v
InputStream

而不是简单返回:

1
byte[]

原因与高性能 IO 非常相关。

如果所有数据都先强制构造成连续:

1
byte[]

那么在:

1
2
3
4
序列化缓存
网络缓存
压缩
Frame

之间可能反复出现内存复制。

使用流式抽象后,可以:

1
2
3
4
逐段读取
减少必须构造大连续数组的需求
更容易与底层 Buffer 组合
适配动态压缩和未知最终长度的数据

本质还是前面的原则:

尽量减少没有业务价值的数据搬运。


MessageFramer:把消息放入传输协议

序列化以后,只得到:

1
Message Bytes

还不能直接认为是一条完整的网络消息。

gRPC 还需要:

1
Framing

所以进入:

1
MessageFramer

其职责可以理解为:

1
2
3
4
5
6
7
业务 Message
|
v
gRPC Message Frame
|
v
HTTP/2 DATA

随后交给 Netty Client Stream 写入 HTTP/2 Stream。


HTTP/2 为什么适合 gRPC

HTTP/2 的核心能力之一是:

1
Multiplexing

同一个 TCP 连接中,可以存在多个逻辑 Stream:

1
2
3
4
5
6
7
8
9
TCP Connection
|
+-- Stream 1
|
+-- Stream 3
|
+-- Stream 5
|
+-- Stream 7

不同 RPC 不需要:

1
一个请求 = 一条 TCP 连接

多个调用可以复用同一连接。

而 Stream ID 本身又提供了天然的逻辑隔离。

HTTP/2 基本传输单位是 Frame。

Frame 拥有固定长度 Header,并通过长度、类型、标志和 Stream Identifier 等信息让接收方恢复消息结构。

因此前面自己设计 RPC 私有协议时考虑的:

1
2
3
4
消息边界
消息类型
请求关联
连接复用

在 gRPC 中有很大一部分直接建立在 HTTP/2 机制之上。


gRPC 服务端如何启动

服务端业务实现类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class HelloServiceImpl
extends HelloServiceGrpc.HelloServiceImplBase {

@Override
public void say(
HelloRequest request,
StreamObserver<HelloReply> observer) {

HelloReply reply =
HelloReply.newBuilder()
.setMessage("Hello " + request.getName())
.build();

observer.onNext(reply);
observer.onCompleted();
}
}

然后通过:

1
2
3
4
5
Server server = ServerBuilder
.forPort(50051)
.addService(new HelloServiceImpl())
.build()
.start();

完成:

1
2
3
4
5
网络端口
+
Service 注册
+
请求处理链

的建立。


gRPC 服务端接收链路

资料中的核心路径可以整理成:

flowchart LR
    A[Network] --> B[Netty Server]
    B --> C[HTTP/2 Frame Reader]
    C --> D[NettyServerHandler]
    D --> E[FrameListener]
    E --> F[MessageDeframer]
    F --> G[Server Stream Listener]
    G --> H[Unary Server Call Listener]
    H --> I[HelloServiceImpl]

关键步骤分别承担不同职责。

HTTP/2 Frame Reader

网络层收到的是:

1
连续二进制数据

HTTP/2 Frame Reader 根据协议格式恢复:

1
2
3
4
5
Header
DATA
Ping
RST
...

FrameListener

解析好的 Frame 继续进入 gRPC 的处理链。

它已经不需要再从原始 TCP 字节流中猜:

1
一条消息在哪里结束

因为 HTTP/2 framing 已经解决了这一层问题。


MessageDeframer

MessageDeframer 再从传输 Frame 中恢复出完整 gRPC Message。

也就是发送端:

1
MessageFramer

的反向过程。

1
2
3
4
5
MessageFramer
↓
Network
↓
MessageDeframer

Marshaller 反序列化

随后:

1
Protobuf Bytes

恢复成:

1
HelloRequest

方法分发

服务端根据:

1
2
Service
Method

找到:

1
HelloServiceImpl.say()

最终真正进入业务代码。

所以一次 gRPC Unary 调用,可以概括成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Client Stub
↓
Marshaller
↓
MessageFramer
↓
HTTP/2
↓
Netty
↓
Network
↓
Netty
↓
HTTP/2 Frame Reader
↓
MessageDeframer
↓
Marshaller
↓
Service Method

前面所有 RPC 基础知识,到这里就连接成了一条完整链路。


gRPC 其实没有“魔法”

从最底层来看,gRPC 仍然只是在解决几个经典问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
接口如何定义?
-> Protocol Buffer IDL

调用方如何使用?
-> Generated Stub

对象如何传输?
-> Protobuf

消息边界如何解决?
-> gRPC Framing + HTTP/2 Frame

连接如何高效复用?
-> HTTP/2 Multiplexing

网络怎么处理?
-> Netty

服务端怎么调用真正方法?
-> Service Binding + Listener

响应怎么回来?
-> 反向执行相同链路

所以理解 RPC 以后,再看 gRPC 就不会觉得:

1
stub.say()

是一种魔法。

它只是把大量复杂度隐藏到了框架内部。


点对点通信还不是完整的生产级 RPC

如果实现:

1
Client -> Server

已经可以称为一个最基本的 RPC。

但真正进入生产环境之后,服务通常是:

1
2
3
4
5
6
7
8
Client
|
v
Service Cluster
├── Instance A
├── Instance B
├── Instance C
└── Instance D

于是又会自然出现新的问题:

1
2
3
4
5
6
7
实例地址从哪里来?
哪个实例健康?
请求发给谁?
实例扩容后客户端怎么知道?
实例下线怎么办?
失败后能不能重试?
某个实例变慢怎么办?

这就进入:

1
2
3
4
服务注册
服务发现
负载均衡
服务治理

的范畴。

所以一个成熟 RPC 框架通常不仅仅包含:

1
协议 + 序列化 + 网络

还会逐渐发展出:

1
2
3
4
5
6
7
8
9
Registry
Load Balance
Retry
Timeout
Circuit Breaker
Rate Limit
Metrics
Tracing
Configuration

等能力。


RPC 最大的坑之一:超时

假设调用链:

1
A -> B -> C

如果:

1
2
A -> B timeout = 1s
B -> C timeout = 2s

设计显然有问题。

因为:

1
A

可能已经认为请求失败。

但:

1
B

仍然等待 C。

更糟糕的情况是:

1
服务端已经执行成功

但响应因为网络问题没有及时回到客户端。

客户端看到:

1
Timeout

却无法仅根据这个错误判断:

1
服务端到底执行了没有

所以 RPC 超时不是简单的:

1
失败

而是:

1
结果未知

这也是分布式系统比本地调用复杂得多的地方。


整条调用链必须有超时预算

更合理的思路是:

1
Total Deadline

而不是每个服务完全独立地:

1
随便配置一个 timeout

例如整个请求允许:

1
1000ms

经过:

1
2
Gateway: 50ms
Service A: 150ms

那么后续链路应该理解:

1
剩余预算已经减少

而不是重新获得完整:

1
1000ms

否则服务越调用越深:

1
A -> B -> C -> D -> E

尾部调用可能早已失去继续执行的业务价值。


重试必须和幂等性一起设计

网络失败之后,很多 RPC 框架都会提供:

1
Retry

但:

1
重试 != 永远正确

例如:

1
queryUser()

通常重复执行问题不大。

但:

1
2
3
4
createOrder()
pay()
deductStock()
transferMoney()

重复执行可能导致严重后果。

例如第一次调用:

1
服务端执行成功

只是响应丢了。

客户端随后自动重试:

1
第二次 createOrder()

于是生成两个订单。

因此:

任何自动重试策略都必须首先讨论接口幂等性。

通常需要结合:

1
2
3
4
5
业务唯一键
Request ID
幂等表
状态机
去重机制

共同解决。


少调用一次 RPC,往往比优化一次 RPC 更有效

RPC 框架做得再快:

1
远程调用

仍然比:

1
本地方法调用

昂贵得多。

例如需要查询 100 个用户。

差的设计:

1
2
for userId:
rpc.getUser(userId)

也就是:

1
100 次 RPC

更好的接口可能是:

1
batchGetUsers(userIds)

一次完成:

1
1 次 RPC

类似地,如果数据分布在不同服务实例上,可以优先:

1
按照目标实例分组

然后批量查询。

因此:

高性能 RPC 的第一原则往往不是把每次 RPC 优化到极致,而是减少没有必要的 RPC 次数。


不要让核心服务强依赖非核心服务

假设支付核心链路:

1
2
3
4
5
6
7
Payment
|
+--> Account
|
+--> Risk
|
+--> Recommendation

如果:

1
Recommendation

只是用来展示推荐商品,却因为它故障导致:

1
Payment

完全不可用,这就是依赖设计出了问题。

远程调用必须考虑:

1
这个依赖失败以后,我能不能继续?

可能的处理包括:

1
2
3
4
5
6
降级
默认值
缓存
异步化
隔离
熔断

RPC 架构设计不仅是:

1
如何调用

还包括:

1
什么时候不应该继续调用

调用方必须尊重服务提供方的容量

假设下游最大能够承受:

1
5000 QPS

上游突然发送:

1
20000 QPS

即使 RPC 框架本身性能再高,也只是在更快地:

1
把下游打挂

因此服务之间应该明确:

1
2
3
4
5
6
容量
QPS
并发
超时
重试
流量模型

必要时加入:

1
2
3
Rate Limit
Bulkhead
Circuit Breaker

高性能绝不意味着无限流量。


可观测性是 RPC 基础设施的一部分

当系统只有:

1
A -> B

日志还能人工查看。

一旦变成:

1
2
3
4
5
A
-> B
-> C
-> D
-> E

出现:

1
整个接口 2.5s

最重要的问题是:

1
到底慢在哪里?

一个成熟 RPC 系统至少应该能够观测:

1
2
3
4
5
6
7
8
9
调用量
成功率
失败率
超时率
平均耗时
最大耗时
P95
P99
P999

并能展示:

1
2
3
4
5
A
└── B 20ms
├── C 35ms
└── D 1800ms
└── E 1700ms

这样才能快速定位:

1
真正的瓶颈节点

因此 Trace ID、Metrics、日志查询和调用拓扑并不是“额外锦上添花”。

在大型 RPC 系统里,它们几乎属于必需品。


RPC 接口必须当成长期契约

本地代码修改:

1
UserDTO

编译器可能立刻提示所有错误。

RPC 不一样。

生产环境经常存在:

1
2
3
4
Consumer V1
Consumer V2
Provider V2
Provider V3

多个版本同时运行。

因此接口修改需要首先思考:

1
2
3
4
旧 Consumer 能不能调用新 Provider?
新 Consumer 能不能临时调用旧 Provider?
灰度期间怎么办?
回滚怎么办?

这也是 Protocol Buffer 这类 Schema 机制非常有价值的原因。

远程接口不只是 Java 方法。

它是一份:

1
2
3
4
跨进程
跨机器
跨版本
甚至跨语言

的长期契约。


RPC 和 REST 并不是简单的“内部与外部”关系

实际工程里经常看到:

1
2
内部服务 -> RPC
公网接口 -> REST

这种架构很常见,但原因不应该简单理解成:

1
2
RPC 安全
REST 不安全

二者都可以使用 TLS、认证和鉴权机制。

真正的差异更多在于场景。

REST/HTTP API 的优势是:

1
2
3
4
5
标准化
通用
浏览器和网关友好
易调试
生态成熟

内部 RPC 更强调:

1
2
3
4
5
6
强契约
高频服务调用
低通信开销
跨语言 Stub
连接复用
服务治理

而 gRPC 又进一步说明:

1
HTTP

完全可以作为:

1
RPC 的传输基础

因此更合理的认识是:

REST 是一种面向资源的 API 风格,RPC 是一种远程调用抽象。HTTP 则是网络应用协议。三者并不是同一层面的概念。


什么情况下应该考虑压缩

压缩不是:

1
开启以后一定更快

它实际上是在交换:

1
CPU

和:

1
网络带宽

原始数据:

1
10 MB

压缩后:

1
2 MB

网络传输可能明显变快。

但发送端需要:

1
Compress CPU

接收端还需要:

1
Decompress CPU

如果消息本来只有:

1
200 Bytes

开启压缩很可能得不偿失。

所以是否压缩,需要关注:

1
2
3
4
5
6
Payload 大小
压缩率
CPU 使用率
网络带宽
调用频率
延迟目标

实践中更适合:

1
达到一定消息大小才压缩

而不是所有 RPC 无脑开启。


一个生产 RPC 框架大概长什么样

把所有知识重新组合,可以得到一个完整 RPC 架构:

flowchart TB
    subgraph Consumer[Service Consumer]
        A[Business Code]
        B[Proxy / Generated Stub]
        C[Cluster Invoker]
        D[Load Balancer]
        E[Serializer]
        F[Protocol Encoder]
        G[Netty Client]
    end

    subgraph Governance[Service Governance]
        R[Registry / Discovery]
        M[Metrics]
        T[Tracing]
        L[Rate Limit / Circuit Breaker]
    end

    subgraph Provider[Service Provider]
        H[Netty Server]
        I[Protocol Decoder]
        J[Deserializer]
        K[Service Dispatcher]
        N[Business Service]
    end

    A --> B
    B --> C
    C --> L
    C --> D
    R --> D
    D --> E
    E --> F
    F --> G

    G --> H

    H --> I
    I --> J
    J --> K
    K --> N

    C --> M
    K --> M
    C --> T
    K --> T

如果只实现:

1
2
3
4
5
Proxy
Serializer
Protocol
Network
Dispatcher

已经可以完成一个最基本的 RPC。

如果要进入生产环境,则继续增加:

1
2
3
4
5
6
7
8
9
Service Discovery
Load Balance
Timeout
Retry
Circuit Breaker
Rate Limit
Tracing
Metrics
Configuration

这也解释了为什么真正成熟的 RPC 框架代码量会迅速膨胀。

困难的从来不是:

1
把一个对象发送到另一台机器

而是:

如何让成千上万个服务在故障、升级、扩容和高并发环境下仍然稳定地相互调用。


再从头看一次 RPC

现在重新看业务代码:

1
Order order = orderService.getOrder(orderId);

这一行背后可能真正发生的是:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
1. Proxy/Stub 拦截 getOrder()

2. 获取:
Service = OrderService
Method = getOrder
Args = [orderId]

3. 服务发现:
OrderService
-> 10.0.1.12
-> 10.0.1.13
-> 10.0.1.14

4. 负载均衡:
-> 10.0.1.13

5. 创建:
RequestId = 867421

6. 序列化:
OrderRequest
-> Protobuf Bytes

7. 协议编码:
Header + Payload

8. Netty:
写入 Channel

9. TCP / HTTP2:
网络传输

10. Server:
解码 Frame

11. 反序列化:
Bytes
-> OrderRequest

12. Dispatcher:
OrderService.getOrder()

13. 执行业务逻辑

14. 构造 Response

15. 序列化 + 编码

16. 网络返回

17. Client:
RequestId = 867421

18. 找到对应 Future

19. 反序列化 Response

20. Proxy/Stub 返回 Order

而开发者只看到:

1
orderService.getOrder(orderId);

这才是 RPC 最大的价值。

不是因为:

1
网络通信消失了

而是因为:

1
复杂度被正确放到了基础设施层

总结

理解 RPC,可以围绕五个问题建立完整知识框架。

RPC 怎么让远程调用看起来像本地调用?

通过 Proxy 或 Generated Stub,把接口调用转换成 RPC Request。

Java 对象怎么通过网络传输?

通过序列化把对象转换成字节,再通过协议编码形成完整网络消息。

TCP 为什么知道不了一条 RPC 请求在哪里结束?

因为 TCP 是字节流,消息边界必须由应用层协议定义。

大量连接怎样高效处理?

通常依靠 IO 多路复用和 Reactor/EventLoop 模型,Java 生态中 Netty 是非常典型的实现。

请求到了服务器以后怎么执行真正业务方法?

协议解码、反序列化后,根据服务和方法元数据找到目标实现,调用业务代码,再沿相反路径返回 Response。

gRPC 并没有改变这些基本原理。

它只是分别选择了成熟方案:

1
2
3
4
5
6
7
8
接口契约        -> Protocol Buffer IDL
客户端抽象 -> Generated Stub
序列化 -> Protocol Buffer
应用层传输 -> HTTP/2
多路复用 -> HTTP/2 Stream
网络实现 -> Netty
消息组装 -> Framer / Deframer
服务分发 -> Server Call / Service Binding

当这些组件连接起来之后,RPC 就不再是一句模糊的“远程调用”,而是一套层次非常清晰的分布式通信体系。

而理解 RPC 真正有价值的地方也正在这里:框架可以帮助我们隐藏网络,但架构设计者永远不能忘记网络的存在。 超时、重试、幂等、容量、协议兼容、服务治理和可观测性,最终决定的不是一次调用能不能跑通,而是整个分布式系统能不能长期稳定运行。


RPC 核心原理:从通信流程、协议与序列化到 Netty、动态代理和 gRPC
https://allendericdalexander.github.io/2026/08/18/archtect/rpc/RPCCore/
作者
AtLuoFu
发布于
2026年8月18日
许可协议