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日
许可协议