RPC 核心原理:从通信流程、协议与序列化到 Netty、动态代理和 gRPC
RPC(Remote Procedure Call,远程过程调用)是分布式系统中最基础的通信抽象之一。它真正解决的并不只是“让两台机器能够通信”,而是如何把接口代理、协议 framing、序列化、网络 IO、请求与响应关联、服务端分发等复杂过程隐藏起来,让业务代码尽可能获得类似本地方法调用的开发体验。本文从一次完整 RPC 调用出发,系统梳理协议设计、序列化选型、IO 多路复用、零拷贝、动态代理,并最终通过 gRPC 的收发链路把这些知识串联起来。
RPC 到底解决了什么问题
RPC 的全称是 Remote Procedure Call,也就是“远程过程调用”。
仅仅通过网络访问另一台机器,并不足以完整描述 RPC。RPC 更重要的一层含义,是尽可能屏蔽远程通信的实现细节,为调用者提供接近本地方法调用的编程模型。
假设订单服务需要查询用户信息。
如果没有 RPC 框架,业务代码可能不得不亲自处理:
- 服务地址从哪里获得;
- TCP 连接怎么建立;
- 请求参数如何转换成字节;
- 如何设计报文格式;
- 一条消息在哪里结束;
- 请求属于哪个接口、哪个方法;
- 返回的数据如何与请求对应;
- 如何反序列化响应;
- 网络超时怎么办;
- 连接断开怎么办。
最终,一个原本只有一句业务含义的代码:
1 | |
背后可能变成几十甚至几百行网络通信代码。
RPC 框架做的事情,就是把这些逻辑下沉到基础设施层。
从调用者视角看:
1 | |
从 RPC 框架内部看,则是:
1 | |
RPC 的核心价值可以概括为两件事:
第一,屏蔽网络编程复杂度。
业务开发者不需要直接操作 Socket、字节流和连接生命周期。
第二,构造远程调用抽象。
调用者主要面对接口、方法、参数和返回值,而不是“发送一个网络数据包”。
这也是为什么 RPC 经常被称为分布式系统的“通信骨架”。
本地调用和远程调用终究不是一回事
RPC 经常强调“像调用本地方法一样调用远程服务”,但这里的关键词是像,而不是“完全等价”。
本地调用大致是:
1 | |
远程调用则至少跨越:
1 | |
两者在失败模型上完全不同。
本地方法执行时,通常不会突然遇到:
- 网络抖动;
- 连接中断;
- 服务端已经执行成功但客户端超时;
- 请求重复发送;
- 服务端实例突然下线;
- 序列化版本不兼容;
- 下游服务过载;
- 整条调用链超时预算耗尽。
所以:
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 | |
序列化负责:
1 | |
协议编码则是在这段数据之外继续增加 RPC 通信所需的信息:
1 | |
可以把两者类比成发快递。
序列化类似:
1 | |
协议编码类似:
1 | |
因此完整关系是:
1 | |
接收端反过来:
1 | |
为什么 RPC 需要自己的应用层协议
TCP 提供的是可靠字节流。
这里最关键的是“字节流”。
假设调用方连续发送三条消息:
1 | |
应用层不能假设服务端每次 read() 都会恰好得到:
1 | |
实际可能读到:
1 | |
也可能:
1 | |
甚至:
1 | |
TCP 并不知道:
1 | |
是一条业务消息。
对于 TCP 来说,它们只是连续的字节。
所谓“TCP 粘包、拆包”,从应用协议的角度理解,本质就是:
TCP 是字节流,而应用需要消息边界。
所以 RPC 必须自己规定:
1 | |
这就是应用层 RPC 协议存在的意义。
最简单的 RPC 协议:长度 + Payload
最直接的设计方式,是在消息前写入长度。
1 | |
例如:
1 | |
服务端就知道:
1 | |
才是一条完整消息。
读取流程变成:
1 | |
但这还不够。
服务端拿到 Payload 后,还不知道:
1 | |
于是必须继续设计 Header。
一个典型 RPC Header 应该包含什么
常见 RPC 报文可以抽象成:
1 | |
Header 中通常需要承载类似信息:
| 字段 | 作用 |
|---|---|
| Magic | 判断是不是当前 RPC 协议 |
| Total Length | 整条消息长度 |
| Header Length | Header 自身长度 |
| Version | 协议版本 |
| Message Type | Request、Response、Heartbeat 等 |
| Serializer | Protobuf、Hessian 等 |
| Request ID | 请求与响应关联 |
| Extension | 扩展能力 |
Payload 则主要保存:
1 | |
如果是响应,则可能保存:
1 | |
一条 RPC 请求可以抽象为:
1 | |
为什么协议头也必须可扩展
假设第一版协议头长度是固定的:
1 | |
所有服务都默认:
1 | |
后来希望增加:
1 | |
如果直接把 Header 扩大:
1 | |
旧版本服务仍然按照 88 bit 解析。
于是:
1 | |
到了旧服务:
1 | |
Header 后面的 8 bit 就被误认为 Payload。
整个报文立刻错位。
因此协议设计时不能只考虑:
1 | |
还必须考虑:
1 | |
一种更合理的思路是:
1 | |
例如:
1 | |
解析方首先读取固定区域。
得到:
1 | |
之后就能够知道:
1 | |
这样后续向 Header 中增加字段,就不会强迫所有旧客户端同时修改固定长度。
协议兼容性的核心不是“能增加字段”这么简单
设计可升级协议时,至少需要考虑三个层次。
Header 兼容
新增协议能力时,旧版本能否跳过自己不认识的字段。
Payload 兼容
DTO 增加字段以后,旧调用方能不能继续读取。
语义兼容
即使数据能够反序列化,也不代表业务一定兼容。
例如:
1 | |
到了 V2:
1 | |
旧客户端即使能成功读取字段,也不一定知道:
1 | |
代表什么。
所以 RPC 接口本质上是一份长期存在的契约。
接口升级时,最好遵循:
1 | |
协议层的兼容只是第一关,真正困难的是业务契约兼容。
Request ID:异步 RPC 如何找到对应的 Response
RPC 为了提高吞吐量,通常会复用连接,同时发送多个请求。
假设:
1 | |
因为三个请求在服务端的执行时间不同,返回顺序完全可能是:
1 | |
因此不能依赖:
1 | |
常见做法是在 Request Header 中增加唯一的:
1 | |
客户端发送时:
1 | |
并在内存中保存类似关系:
1 | |
服务端处理完请求以后,把相同的 Request ID 放入响应:
1 | |
客户端收到后查找:
1 | |
然后完成对应的 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 | |
的重要基础。
一个需要纠正的 HTTP 认识
在解释为什么很多 RPC 框架会设计私有协议时,经常会看到一种过度简化的说法:
1 | |
这个结论不能直接作为 HTTP/1.1 的协议事实。
HTTP/1.1 支持持久连接。
真正应该比较的是:
- 协议头开销;
- 编解码成本;
- 连接复用能力;
- 并发模型;
- 是否支持多路复用;
- 双向流能力;
- 浏览器和基础设施兼容性;
- 跨语言能力。
这也解释了一个非常重要的事实:
RPC 并不意味着一定要直接构建在裸 TCP 私有协议之上。
gRPC 就是典型例子:
1 | |
所以:
1 | |
并不是只能二选一。
它们处在不同的抽象维度。
序列化:Java 对象为什么不能直接在网络上传输
业务方法通常操作的是对象:
1 | |
但网络层最终处理的是字节。
因此必须完成:
1 | |
接收端再执行:
1 | |
序列化框架本质上都需要解决:
1 | |
最终按照某种约定写入字节流。
反序列化时,再按照同样规则恢复对象。
JDK 原生序列化
Java 提供:
1 | |
进行序列化,以及:
1 | |
进行反序列化。
典型对象:
1 | |
底层序列化数据除了真正的属性值,还需要写入大量帮助恢复对象的元信息,例如:
1 | |
对象存在:
1 | |
时,还需要继续递归处理对象关系。
JDK 序列化的优势是:
1 | |
但作为 RPC 默认序列化方式时,需要非常谨慎。
一方面序列化体积和性能未必理想,另一方面 Java 原生反序列化长期以来存在较大的安全攻击面。
因此:
RPC 序列化选型时,安全性不能只作为性能之后的附加指标。
JSON
JSON 最大的优点不是速度,而是通用性和可读性。
例如:
1 | |
人可以直接读取,调试非常方便。
它天然适合:
- HTTP API;
- Web 前后端通信;
- 配置;
- 日志;
- 调试接口;
- 对可读性要求高的系统。
但 JSON 是文本格式。
相对于紧凑的二进制协议,会产生更多:
1 | |
另外,JSON 自身没有 Java 那样完整的静态类型信息。
Java 在恢复对象时通常还需要结合:
1 | |
因此对于大量、高频 RPC 调用,JSON 往往不是最追求性能时的第一选择。
不过这并不意味着:
1 | |
正确的结论应该是:
序列化方案应该服务于业务场景,而不是脱离场景比较跑分。
Hessian
Hessian 属于:
1 | |
的序列化方案。
相比 JDK 原生序列化和 JSON,它通常可以得到更紧凑的结果,同时保留相对方便的 Java 对象使用体验。
这也是传统 Java RPC 框架中 Hessian 很有吸引力的原因。
不过实际使用中必须关注:
1 | |
材料中的工程经验曾特别提到过:
1 | |
等类型的兼容问题。
这类行为具有明显的版本和实现差异,所以不要把某个版本的限制理解成所有 Hessian 实现的永久规则。
真正上线前应该通过兼容性测试验证自己的 DTO。
Protocol Buffer
Protocol Buffer 通常需要先定义 IDL:
1 | |
然后通过编译器生成不同语言的代码。
这种方式最大的变化是:
1 | |
不再依赖某一种编程语言的 Class。
而是:
1 | |
成为跨语言契约。
Protocol Buffer 的主要优势包括:
- 二进制体积较小;
- 编解码效率较高;
- 类型信息明确;
- 不需要运行时依赖大量反射恢复字段类型;
- 支持多语言;
- 字段编号机制适合协议演进。
它非常适合:
1 | |
这类跨语言 RPC。
代价则是增加:
1 | |
这一层工程流程。
这其实是一种典型取舍:
1 | |
关于 Protobuf 的 null 要特别小心
Java 对象世界里经常有:
1 | |
但 Protobuf 的字段语义并不能简单等同于 Java 对象里的 null。
因此,不应该笼统写成:
1 | |
然后停止分析。
真正需要关注的是:
1 | |
如果业务内部高度依赖:
1 | |
区分某种业务状态,那么在设计 Proto 契约时应该显式设计语义,而不是寄希望于 Java 对象行为自动映射过去。
Protostuff
Protostuff 的吸引力在于:
1 | |
它允许直接基于 Java 对象进行序列化。
不过材料中的实践经验也说明了一个非常典型的问题:
序列化框架的兼容性行为会随着版本变化。
例如资料早期版本曾记录:
1 | |
等兼容问题,后续版本又有所改进。
所以面对任何序列化框架,都不能只记:
1 | |
更应该问:
1 | |
RPC 序列化应该怎么选
序列化选型很容易陷入:
1 | |
但生产 RPC 的优先级不能只有 benchmark。
更合理的顺序是:
1 | |
其中最容易被低估的是:
1 | |
因为一次序列化速度慢 10%,可能只是:
1 | |
但升级一次之后新旧服务无法互调,结果可能是:
1 | |
可靠性通常比微小的 benchmark 差距更重要。
可以粗略比较:
| 方案 | 可读性 | 体积 | 性能 | 跨语言 | Schema | 典型特点 |
|---|---|---|---|---|---|---|
| JDK Serialization | 低 | 较大 | 一般 | 差 | 无 | Java 原生,但 RPC 中应谨慎 |
| JSON | 高 | 较大 | 一般 | 高 | 弱 | 调试方便、生态极强 |
| Hessian | 低 | 较小 | 较高 | 较好 | 无 | Java 使用方便 |
| Protobuf | 低 | 小 | 高 | 高 | 强 | 高性能、强契约、跨语言 |
| Protostuff | 低 | 小 | 高 | 主要偏 Java | 弱 | 减少 Java 场景 IDL 工作 |
这个表不是绝对性能排行榜。
最终还是应该以:
1 | |
进行测试。
RPC DTO 最好保持简单
序列化框架再快,也扛不住糟糕的对象设计。
RPC 参数最常见的几个问题如下。
对象层级过深
例如:
1 | |
序列化时需要不断递归。
对象关系越复杂:
1 | |
RPC DTO 不应该直接等于整个领域对象图。
一次传输超大集合
例如:
1 | |
序列化之后达到数 MB。
影响的不只是网络带宽,还包括:
1 | |
因此“大对象 RPC 超时”未必是:
1 | |
也可能大量时间耗费在网络之前和之后。
使用过于特殊的集合类型
接口 DTO 最好优先选择:
1 | |
的数据结构。
不要因为业务内部用了某个特殊集合实现,就直接把它暴露成远程契约。
复杂继承关系
序列化:
1 | |
通常意味着框架还需要继续遍历父类字段。
RPC DTO 更适合:
1 | |
而不是复杂领域继承体系。
网络 IO 是整个 RPC 的地基
完成:
1 | |
之后,还需要真正把数据送到另一台机器。
典型 RPC 请求:
1 | |
因此高性能 RPC 的基础问题最终会落到:
1 | |
上。
常见模型可以粗略分成:
| 模型 | 特征 |
|---|---|
| Blocking IO | 一个 IO 操作可能长期阻塞线程 |
| Non-blocking IO | IO 未就绪立即返回 |
| IO Multiplexing | 一个线程监听大量连接的就绪事件 |
| Asynchronous IO | IO 完成后再进行异步通知 |
实际高并发 RPC 最重要的是:
1 | |
Blocking IO 为什么简单但难扛高连接数
传统阻塞式 Socket:
1 | |
最直接的服务器模型通常是:
1 | |
优势非常明显:
1 | |
在连接数不多时完全够用。
问题则是:
1 | |
会带来:
1 | |
等成本。
因此 RPC 服务达到较高并发后,需要另一套模型。
IO 多路复用解决的是什么
IO 多路复用的核心不是:
1 | |
而是:
用较少的线程监控大量 Socket 的 IO 就绪状态。
抽象过程:
1 | |
当某些连接可读:
1 | |
事件循环再处理:
1 | |
因此一个线程不需要:
1 | |
而可以不断处理大量连接的就绪事件。
select、poll 和 epoll
IO 多路复用在不同系统中存在不同实现。
Linux 中经常讨论:
1 | |
可以从演进思路理解。
select
应用提交一组文件描述符,然后内核检查:
1 | |
大量连接下需要不断遍历集合。
poll
基本思想仍然类似,但数据结构和可管理的描述符数量相比传统 select 有所改善。
epoll
更适合 Linux 高并发网络场景。
不需要每次都对整个监听集合执行同样的线性扫描逻辑,而是维护事件集合,并返回已经就绪的连接。
这也是:
1 | |
这类高并发组件经常围绕 epoll 进行优化的原因。
为什么 RPC 常选择 Reactor + Netty
对于 Java RPC:
1 | |
当然可以。
但这意味着还要亲自解决:
1 | |
于是工程中通常会选择:
1 | |
这类成熟网络框架。
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 | |
业务逻辑则可以根据架构进一步分发。
这样就把:
1 | |
和:
1 | |
分离开来。
什么是零拷贝
普通网络发送过程,可以粗略理解为:
1 | |
读取则大致相反。
如果每次传输都发生大量:
1 | |
在高吞吐系统中会产生明显成本。
于是出现:
1 | |
思想。
不过“零拷贝”这个名字非常容易产生误解。
它通常并不是:
1 | |
更准确地说,是:
尽量减少 CPU 参与的无意义数据复制,以及不必要的用户态和内核态数据搬运。
常见操作系统层面的技术包括:
1 | |
它们通过内存映射或直接在内核数据路径之间转移数据,减少传统:
1 | |
这样的重复复制。
操作系统零拷贝和 Netty 零拷贝要分开理解
Netty 中经常也会说“零拷贝”。
但其中相当一部分优化发生在:
1 | |
目标是:
1 | |
而不是 Linux 内核的 sendfile 那一种问题。
这两个概念必须区分。
CompositeByteBuf:逻辑合并,而不是物理复制
假设一条协议消息由:
1 | |
组成。
最直接的方式是新申请一个大数组:
1 | |
然后:
1 | |
形成:
1 | |
Netty 的 CompositeByteBuf 可以让多个 ByteBuf 在逻辑上成为一个 ByteBuf:
1 | |
底层数据不一定需要全部复制到新的连续空间。
对于频繁进行:
1 | |
的 RPC 框架非常有价值。
slice:共享底层存储区域
假设一个 ByteBuf:
1 | |
传统方式可能复制出:
1 | |
slice() 可以创建:
1 | |
它们仍然共享原来的底层存储。
于是:
1 | |
不一定意味着:
1 | |
wrap:直接包装已有数据
对于:
1 | |
如果只是为了交给 Netty 使用,没有必要永远重新复制一次。
通过:
1 | |
可以把已有数据包装成 ByteBuf 视图。
核心思想仍然只有一句:
能复用内存就不要复制。
Direct Buffer
Java 堆内存的数据进行 Socket IO 时,在某些路径中可能还需要复制到适合 native IO 使用的内存区域。
Netty 可以使用:
1 | |
即 JVM 堆外直接内存。
这可以减少某些:
1 | |
的额外复制。
不过 Direct Buffer 并不等于:
1 | |
它只是优化整个数据路径中的一部分。
FileRegion 与 transferTo
Netty 的 FileRegion 可以结合:
1 | |
传输文件内容。
在支持的操作系统上,这类 API 可以进一步利用类似:
1 | |
的内核能力。
因此 Netty 中可以同时看到两类“零拷贝”:
1 | |
以及:
1 | |
不要把两者混为一谈。
动态代理:RPC 为什么能“像本地调用”
现在回到最上层的问题。
假设存在接口:
1 | |
调用代码:
1 | |
问题来了。
调用方 JVM 根本不存在真正的:
1 | |
它在另一台机器上。
那这句:
1 | |
到底调用的是谁?
答案通常是:
1 | |
JDK Dynamic Proxy 的核心原理
可以用一个简化示例理解。
1 | |
创建 InvocationHandler:
1 | |
再生成接口代理:
1 | |
此时调用:
1 | |
真正执行的是:
1 | |
于是远程调用细节被完全藏在:
1 | |
之后。
代理类实际上帮我们干了什么
可以把代理理解成:
1 | |
代理层会获得:
1 | |
刚好可以构造:
1 | |
所以动态代理与 RPC 的结合非常自然。
它把:
1 | |
转换成了:
1 | |
JDK Proxy 的限制
JDK 动态代理生成的类:
1 | |
因此天然适合:
1 | |
的编程模型。
这恰好与 RPC 非常契合。
另外,资料中的 JDK Proxy 源码分析基于 JDK 1.7.x 时代的内部实现,因此像:
1 | |
这类实现细节不应该直接套用到现代 JDK。
真正应该记住的是稳定原理:
1 | |
而不是某一个 JDK 版本的内部类名。
Javassist 和 Byte Buddy
JDK Proxy 之外,也可以通过字节码生成技术创建代理类。
Javassist
Javassist 可以直接操作和生成 JVM 字节码结构。
优势是:
1 | |
因此可以生成更直接的代码路径。
代价则是:
1 | |
Byte Buddy
Byte Buddy 提供了更高层的字节码生成 API。
相比直接操作底层字节码,通常可以写出更清晰的代理生成逻辑。
对于 RPC 框架而言,代理方案最终需要关注的不只是:
1 | |
还包括:
1 | |
因为 RPC 接口方法可能每秒调用几十万甚至更多次。
代理路径本身也属于热点代码。
动态代理并不是 RPC 唯一选择
这里有一个非常重要的设计点。
RPC 需要的是:
1 | |
而不一定非得:
1 | |
另一种方法是编译期生成代码。
例如:
1 | |
gRPC 就更接近这种模式。
所以:
1 | |
和:
1 | |
表面实现不同,但它们承担了非常相似的角色:
把“调用方法”转换成 RPC 请求,并隐藏后面的通信细节。
这也是理解 RPC 抽象边界的一个关键点。
用 gRPC 把前面的知识全部串起来
前面的协议、序列化、网络 IO 和 Stub 如果单独学习,很容易变成几个互不相关的知识点。
gRPC 恰好可以把它们连接起来。
需要先说明版本背景:
本节调用链以资料分析的 grpc-java 1.27.0 时代实现为主。新版本内部类和具体调用路径可能继续演进,阅读源码时应以所使用版本为准。
但整体思路非常清晰:
1 | |
第一步:用 Protocol Buffer 定义契约
例如:
1 | |
这份文件同时定义:
1 | |
然后利用:
1 | |
生成 Java 代码。
于是客户端不需要自己编写:
1 | |
而是得到:
1 | |
gRPC 客户端最外层发生了什么
典型客户端可以简化成:
1 | |
业务代码只看到:
1 | |
但它背后实际上经历了一长串调用链。
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 | |
也就是:
1 | |
ClientCalls
Generated Stub 会进一步进入 gRPC 的通用客户端调用逻辑。
例如同步 Unary RPC 会进入类似:
1 | |
这样的调用路径。
ClientCallImpl
ClientCallImpl 负责管理一次客户端 RPC 调用生命周期。
其中非常关键的一件事情,就是:
1 | |
MethodDescriptor 是 RPC 方法的元数据
gRPC 中的 MethodDescriptor 非常值得理解。
它并不保存真正业务逻辑,而保存与 RPC 方法有关的元信息,例如:
1 | |
可以把它理解为:
1 | |
其中 Marshaller 就负责:
1 | |
于是:
1 | |
并不需要知道:
1 | |
只需要通过 MethodDescriptor 找到对应的 Marshaller。
这是非常典型的解耦设计。
为什么 gRPC 使用 InputStream,而不是直接 byte[]
资料中的调用链有一个很有意思的设计:
1 | |
而不是简单返回:
1 | |
原因与高性能 IO 非常相关。
如果所有数据都先强制构造成连续:
1 | |
那么在:
1 | |
之间可能反复出现内存复制。
使用流式抽象后,可以:
1 | |
本质还是前面的原则:
尽量减少没有业务价值的数据搬运。
MessageFramer:把消息放入传输协议
序列化以后,只得到:
1 | |
还不能直接认为是一条完整的网络消息。
gRPC 还需要:
1 | |
所以进入:
1 | |
其职责可以理解为:
1 | |
随后交给 Netty Client Stream 写入 HTTP/2 Stream。
HTTP/2 为什么适合 gRPC
HTTP/2 的核心能力之一是:
1 | |
同一个 TCP 连接中,可以存在多个逻辑 Stream:
1 | |
不同 RPC 不需要:
1 | |
多个调用可以复用同一连接。
而 Stream ID 本身又提供了天然的逻辑隔离。
HTTP/2 基本传输单位是 Frame。
Frame 拥有固定长度 Header,并通过长度、类型、标志和 Stream Identifier 等信息让接收方恢复消息结构。
因此前面自己设计 RPC 私有协议时考虑的:
1 | |
在 gRPC 中有很大一部分直接建立在 HTTP/2 机制之上。
gRPC 服务端如何启动
服务端业务实现类似:
1 | |
然后通过:
1 | |
完成:
1 | |
的建立。
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 | |
FrameListener
解析好的 Frame 继续进入 gRPC 的处理链。
它已经不需要再从原始 TCP 字节流中猜:
1 | |
因为 HTTP/2 framing 已经解决了这一层问题。
MessageDeframer
MessageDeframer 再从传输 Frame 中恢复出完整 gRPC Message。
也就是发送端:
1 | |
的反向过程。
1 | |
Marshaller 反序列化
随后:
1 | |
恢复成:
1 | |
方法分发
服务端根据:
1 | |
找到:
1 | |
最终真正进入业务代码。
所以一次 gRPC Unary 调用,可以概括成:
1 | |
前面所有 RPC 基础知识,到这里就连接成了一条完整链路。
gRPC 其实没有“魔法”
从最底层来看,gRPC 仍然只是在解决几个经典问题:
1 | |
所以理解 RPC 以后,再看 gRPC 就不会觉得:
1 | |
是一种魔法。
它只是把大量复杂度隐藏到了框架内部。
点对点通信还不是完整的生产级 RPC
如果实现:
1 | |
已经可以称为一个最基本的 RPC。
但真正进入生产环境之后,服务通常是:
1 | |
于是又会自然出现新的问题:
1 | |
这就进入:
1 | |
的范畴。
所以一个成熟 RPC 框架通常不仅仅包含:
1 | |
还会逐渐发展出:
1 | |
等能力。
RPC 最大的坑之一:超时
假设调用链:
1 | |
如果:
1 | |
设计显然有问题。
因为:
1 | |
可能已经认为请求失败。
但:
1 | |
仍然等待 C。
更糟糕的情况是:
1 | |
但响应因为网络问题没有及时回到客户端。
客户端看到:
1 | |
却无法仅根据这个错误判断:
1 | |
所以 RPC 超时不是简单的:
1 | |
而是:
1 | |
这也是分布式系统比本地调用复杂得多的地方。
整条调用链必须有超时预算
更合理的思路是:
1 | |
而不是每个服务完全独立地:
1 | |
例如整个请求允许:
1 | |
经过:
1 | |
那么后续链路应该理解:
1 | |
而不是重新获得完整:
1 | |
否则服务越调用越深:
1 | |
尾部调用可能早已失去继续执行的业务价值。
重试必须和幂等性一起设计
网络失败之后,很多 RPC 框架都会提供:
1 | |
但:
1 | |
例如:
1 | |
通常重复执行问题不大。
但:
1 | |
重复执行可能导致严重后果。
例如第一次调用:
1 | |
只是响应丢了。
客户端随后自动重试:
1 | |
于是生成两个订单。
因此:
任何自动重试策略都必须首先讨论接口幂等性。
通常需要结合:
1 | |
共同解决。
少调用一次 RPC,往往比优化一次 RPC 更有效
RPC 框架做得再快:
1 | |
仍然比:
1 | |
昂贵得多。
例如需要查询 100 个用户。
差的设计:
1 | |
也就是:
1 | |
更好的接口可能是:
1 | |
一次完成:
1 | |
类似地,如果数据分布在不同服务实例上,可以优先:
1 | |
然后批量查询。
因此:
高性能 RPC 的第一原则往往不是把每次 RPC 优化到极致,而是减少没有必要的 RPC 次数。
不要让核心服务强依赖非核心服务
假设支付核心链路:
1 | |
如果:
1 | |
只是用来展示推荐商品,却因为它故障导致:
1 | |
完全不可用,这就是依赖设计出了问题。
远程调用必须考虑:
1 | |
可能的处理包括:
1 | |
RPC 架构设计不仅是:
1 | |
还包括:
1 | |
调用方必须尊重服务提供方的容量
假设下游最大能够承受:
1 | |
上游突然发送:
1 | |
即使 RPC 框架本身性能再高,也只是在更快地:
1 | |
因此服务之间应该明确:
1 | |
必要时加入:
1 | |
高性能绝不意味着无限流量。
可观测性是 RPC 基础设施的一部分
当系统只有:
1 | |
日志还能人工查看。
一旦变成:
1 | |
出现:
1 | |
最重要的问题是:
1 | |
一个成熟 RPC 系统至少应该能够观测:
1 | |
并能展示:
1 | |
这样才能快速定位:
1 | |
因此 Trace ID、Metrics、日志查询和调用拓扑并不是“额外锦上添花”。
在大型 RPC 系统里,它们几乎属于必需品。
RPC 接口必须当成长期契约
本地代码修改:
1 | |
编译器可能立刻提示所有错误。
RPC 不一样。
生产环境经常存在:
1 | |
多个版本同时运行。
因此接口修改需要首先思考:
1 | |
这也是 Protocol Buffer 这类 Schema 机制非常有价值的原因。
远程接口不只是 Java 方法。
它是一份:
1 | |
的长期契约。
RPC 和 REST 并不是简单的“内部与外部”关系
实际工程里经常看到:
1 | |
这种架构很常见,但原因不应该简单理解成:
1 | |
二者都可以使用 TLS、认证和鉴权机制。
真正的差异更多在于场景。
REST/HTTP API 的优势是:
1 | |
内部 RPC 更强调:
1 | |
而 gRPC 又进一步说明:
1 | |
完全可以作为:
1 | |
因此更合理的认识是:
REST 是一种面向资源的 API 风格,RPC 是一种远程调用抽象。HTTP 则是网络应用协议。三者并不是同一层面的概念。
什么情况下应该考虑压缩
压缩不是:
1 | |
它实际上是在交换:
1 | |
和:
1 | |
原始数据:
1 | |
压缩后:
1 | |
网络传输可能明显变快。
但发送端需要:
1 | |
接收端还需要:
1 | |
如果消息本来只有:
1 | |
开启压缩很可能得不偿失。
所以是否压缩,需要关注:
1 | |
实践中更适合:
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 | |
已经可以完成一个最基本的 RPC。
如果要进入生产环境,则继续增加:
1 | |
这也解释了为什么真正成熟的 RPC 框架代码量会迅速膨胀。
困难的从来不是:
1 | |
而是:
如何让成千上万个服务在故障、升级、扩容和高并发环境下仍然稳定地相互调用。
再从头看一次 RPC
现在重新看业务代码:
1 | |
这一行背后可能真正发生的是:
1 | |
而开发者只看到:
1 | |
这才是 RPC 最大的价值。
不是因为:
1 | |
而是因为:
1 | |
总结
理解 RPC,可以围绕五个问题建立完整知识框架。
RPC 怎么让远程调用看起来像本地调用?
通过 Proxy 或 Generated Stub,把接口调用转换成 RPC Request。
Java 对象怎么通过网络传输?
通过序列化把对象转换成字节,再通过协议编码形成完整网络消息。
TCP 为什么知道不了一条 RPC 请求在哪里结束?
因为 TCP 是字节流,消息边界必须由应用层协议定义。
大量连接怎样高效处理?
通常依靠 IO 多路复用和 Reactor/EventLoop 模型,Java 生态中 Netty 是非常典型的实现。
请求到了服务器以后怎么执行真正业务方法?
协议解码、反序列化后,根据服务和方法元数据找到目标实现,调用业务代码,再沿相反路径返回 Response。
gRPC 并没有改变这些基本原理。
它只是分别选择了成熟方案:
1 | |
当这些组件连接起来之后,RPC 就不再是一句模糊的“远程调用”,而是一套层次非常清晰的分布式通信体系。
而理解 RPC 真正有价值的地方也正在这里:框架可以帮助我们隐藏网络,但架构设计者永远不能忘记网络的存在。 超时、重试、幂等、容量、协议兼容、服务治理和可观测性,最终决定的不是一次调用能不能跑通,而是整个分布式系统能不能长期稳定运行。