一、 RPC 是什么
RPC 全称 Remote Procedure Call,远程过程调用。
它让一个进程像调用本地函数一样,调用另一台机器或另一个进程中的函数。
本地调用:
int result = calculator.add(3, 5);
RPC 调用看起来可能完全一样:
int result = calculatorClient.add(3, 5);
但背后实际发生的是:
客户端程序
↓ 参数序列化
网络请求
↓
服务端接收请求
↓ 参数反序列化
执行 add(3, 5)
↓ 结果序列化
网络响应
↓
客户端反序列化
↓
得到结果 8
RPC 的核心目标是:
屏蔽网络通信、序列化和服务定位等细节,为调用者提供接近本地函数调用的编程体验。
但必须注意:远程调用永远不等于本地调用。远程调用可能超时、丢包、重复执行、服务不可用。
二、 一个 RPC 调用的完整过程
假设客户端调用:
UserService.getUser(1001)
第一步:调用客户端代理
客户端通常不会直接操作 Socket,而是调用生成或封装好的代理对象:
User user = userServiceClient.getUser(1001);
这个代理对象一般称为:
Client Stub
Client Proxy
客户端存根
客户端代理
它看起来实现了 UserService 接口,实际工作是构造网络请求。
第二步:构造 RPC 请求
请求通常包含:
{
"requestId": "abc-123",
"service": "UserService",
"method": "getUser",
"parameterTypes": ["long"],
"arguments": [1001]
}
常见字段包括:
| 字段 | 作用 |
|---|---|
requestId |
关联请求和响应 |
service |
要调用的服务 |
method |
要调用的方法 |
arguments |
方法参数 |
metadata |
Token、链路信息、版本等 |
deadline |
请求最晚完成时间 |
第三步:序列化
请求对象不能直接通过网络发送,需要转换成字节:
RPC请求对象 → 字节数组
这个过程称为序列化。
常见序列化方式:
JSON
XML
Protocol Buffers
MessagePack
Thrift Binary Protocol
自定义二进制协议
JSON 可读性好,但体积通常更大、解析更慢。Protocol Buffers 等二进制格式通常更紧凑,也更适合高性能 RPC。
第四步:编码成网络消息
只有序列化还不够。TCP 是字节流,没有天然的"消息边界"。
假设连续发送两条消息:
消息A:hello
消息B:world
接收端可能一次读到:
helloworld
也可能分成:
hel
lowor
ld
因此 RPC 协议通常需要定义消息帧:
+---------+---------+----------+---------+-------------+
| 魔数 | 版本号 | 消息类型 | 数据长度 | 消息体 |
+---------+---------+----------+---------+-------------+
其中"数据长度"可以帮助接收端解决 TCP 粘包和拆包问题。
第五步:发送请求
客户端通过网络协议发送请求,底层可能使用:
TCP
HTTP/1.1
HTTP/2
HTTP/3
QUIC
Unix Domain
Socket
因此,RPC 是一种调用模型,不是某一种固定的网络协议。
例如 gRPC 通常使用 HTTP/2,但 RPC 并不等于 HTTP/2。
第六步:服务端分发请求
服务端收到请求后,解析出:
服务名:UserService
方法名:getUser
参数:1001
然后通过方法映射找到具体实现:
UserService service = serviceRegistry.get("UserService");
User result = service.getUser(1001);
这部分通常由 Server Stub、Dispatcher 或 Handler 完成。
第七步:返回响应
服务端执行完方法后构造响应:
{
"requestId": "abc-123",
"status": "OK",
"result": {
"id": 1001,
"name": "Alice"
}
}
如果执行失败,则可能返回:
{
"requestId": "abc-123",
"status": "ERROR",
"errorCode": "USER_NOT_FOUND",
"message": "User does not exist"
}
客户端通过 requestId 将响应交给等待中的调用。
三、 RPC 框架由哪些部分组成
一个完整的 RPC 框架通常包括以下模块。
客户端代理
把本地方法调用转换成 RPC 请求:
userService.getUser(1001)
转换为:
service=UserService
method=getUser
arguments=[1001]
动态代理、代码生成和字节码增强都可以实现客户端代理。
序列化器
负责对象和字节之间的转换:
对象 → 字节:序列化
字节 → 对象:反序列化
序列化不仅影响性能,也影响跨语言能力和协议兼容性。
网络传输层
负责:
建立和复用连接
发送与接收数据
编解码
心跳检测
流量控制
TLS 加密
高性能 RPC 框架一般使用连接池或长连接,不会每次调用都重新建立 TCP 连接。
服务注册与发现
客户端需要知道服务端地址。
简单情况下可以写死:
UserService → 10.0.0.8:8080
但实际系统通常有多个实例:
UserService:
- 10.0.0.8:8080
- 10.0.0.9:8080
- 10.0.0.10:8080
服务端启动时注册地址,客户端查询可用实例。这就是服务注册与发现。
负载均衡器
客户端发现多个实例后,需要选择其中一个:
随机
轮询
加权轮询
最少活跃调用
一致性哈希
基于延迟选择
基于区域或机房选择
RPC 中常见的是客户端负载均衡:客户端自己选择服务实例。
服务端分发器
根据服务名和方法名找到具体代码:
(UserService, getUser) → UserServiceImpl.getUser()
容错模块
负责处理:
超时
重试
熔断
限流
降级
故障节点摘除
可观测性模块
通常记录:
请求耗时
成功率
错误码
调用链 Trace
请求数量 QPS
超时次数
重试次数
上下游服务关系
四、 IDL 与代码生成
跨语言 RPC 框架通常使用 IDL,即接口定义语言。
以类似 Protocol Buffers 的写法为例:
message GetUserRequest {
int64 user_id = 1;
}
message User {
int64 id = 1;
string name = 2;
}
service UserService {
rpc GetUser(GetUserRequest) returns (User);
}
框架可以根据 IDL 生成:
Java 客户端代码
Go 服务端代码
Python 数据结构
TypeScript 类型定义
这样 Java 客户端可以调用 Go 服务端,而不需要双方手写网络协议。
IDL 的价值包括:
明确服务接口
提供强类型约束
自动生成客户端和服务端代码
支持多语言
帮助维护协议兼容性
五、RPC 的调用类型
一元调用
一个请求对应一个响应:
Client ──Request──> Server
Client <─Response── Server
例如:
getUser(userId) → User
单向调用
客户端只发送请求,不等待业务响应:
Client ──Request──> Server
适合日志、通知等场景,但不能简单理解为"请求一定成功"。
服务端流式调用
客户端发送一个请求,服务端不断返回数据:
Client ──Request──> Server
Client <─Data 1──── Server
Client <─Data 2──── Server
Client <─Data 3──── Server
例如持续接收股票价格或大模型输出。
客户端流式调用
客户端持续发送数据,服务端最终返回一个结果:
Client ──Chunk 1──> Server
Client ──Chunk 2──> Server
Client ──Chunk 3──> Server
Client <─Result──── Server
例如上传大文件。
双向流式调用
双方可以独立、持续地发送数据:
Client <══════════> Server
适合实时协作、聊天、游戏和持续事件传输。
六、 同步、异步与 Future
同步调用
User user = client.getUser(1001);
当前线程等待响应,逻辑简单,但等待期间可能阻塞线程。
异步调用
Future<User> future = client.getUserAsync(1001);
// 执行其他工作
User user = future.get();
也可以使用回调:
client.getUserAsync(1001, user -> {
System.out.println(user);
});
或者使用协程:
val user = userClient.getUser(1001)
代码看起来同步,但底层线程不一定被阻塞。
七、 RPC 最关键的问题:失败语义
本地方法要么返回,要么抛异常。远程调用的状态更复杂。
假设客户端请求扣款,等待响应时超时:
客户端 ──扣款100元──> 服务端
客户端 <──响应── 服务端
客户端超时后无法确定:
- 请求根本没有到达服务端;
- 请求到达了,但尚未执行;
- 扣款已经完成,但响应丢失;
- 服务端执行时发生了部分失败。
所以 RPC 经常具有这种不确定性:
客户端不知道操作究竟没有执行,还是已经执行但响应没有回来。
这也是分布式系统比本地程序困难的重要原因。
八、超时、重试与幂等性
超时
每次 RPC 都应该有超时或截止时间:
调用最长允许 500ms
否则线程和连接可能无限等待,最终拖垮整个系统。
相比每一层独立设置固定超时,传播统一的 deadline 通常更合理:
请求总期限:12:00:01.500
下游服务根据剩余时间决定是否继续处理。
重试
以下错误可能适合重试:
临时网络故障
服务短暂不可用
连接被重置
某些限流或过载错误
但重试会增加系统压力,通常需要:
最大重试次数 + 指数退避 + 随机抖动
例如:
第1次等待:100ms
第2次等待:200ms
第3次等待:400ms
幂等性
如果同一个请求执行多次,最终效果与执行一次相同,就称为幂等。
天然幂等:
查询用户
把状态设置为"已关闭"
删除编号为 100 的记录
通常不天然幂等:
余额增加 100 元
创建一个新订单
发送一张优惠券
对于非幂等操作,可以加入唯一请求号:
idempotencyKey = "payment-20260724-0001"
服务端记录已经处理过的请求,避免重试导致重复扣款或重复创建订单。
九、 RPC 与 HTTP、REST 的关系
RPC 和 HTTP 不是同一层面的概念:
RPC 描述"像调用函数一样调用远程服务"
HTTP 是应用层通信协议
REST 是一种围绕资源设计接口的架构风格
REST 风格:
GET /users/1001
POST /orders
DELETE /orders/2001
RPC 风格:
UserService.GetUser(1001)
OrderService.CreateOrder(...)
OrderService.CancelOrder(2001)
RPC 可以建立在 HTTP 上。例如 gRPC 使用 HTTP/2 承载 RPC 消息。
| 方面 | RPC | REST |
|---|---|---|
| 核心抽象 | 方法、服务 | 资源 |
| 接口形式 | CreateOrder() |
POST /orders |
| 类型约束 | 通常较强 | 取决于规范 |
| 浏览器兼容 | 可能需要适配 | 通常较好 |
| 内部服务通信 | 很适合 | 也可使用 |
| 调试可读性 | 二进制协议较弱 | JSON 通常较直观 |
| 流式通信 | 部分框架原生支持 | 需要 SSE、WebSocket 等 |
RPC 更适合强调内部方法调用和强类型契约的系统;REST 常用于开放 API、浏览器 API 和资源化接口。
十、 熔断、限流与服务雪崩
假设服务调用链为:
订单服务 → 库存服务 → 商品服务 → 数据库
商品服务变慢后,库存服务的请求开始堆积;库存服务变慢又会拖住订单服务,最终导致整个系统资源耗尽,这就是服务雪崩。
常见保护机制:
限流:限制单位时间内的请求数量
熔断:失败率过高时暂时停止调用
降级:返回缓存值或简化结果
隔离:不同服务使用独立线程池或连接池
背压:下游处理不过来时通知上游减速
负载卸载:系统过载时快速拒绝部分请求
熔断器通常有三个状态:
Closed:正常调用
Open:快速失败,不再访问故障服务
Half-Open:允许少量请求探测服务是否恢复
十一、RPC 的性能成本
一次本地函数调用可能只需纳秒级到微秒级,而 RPC 通常需要经历:
代理调用
→ 请求对象创建
→ 序列化
→ 排队
→ 网络传输
→ 服务端解码
→ 线程或协程调度
→ 业务处理
→ 响应序列化
→ 网络返回
→ 客户端解码
优化方向包括:
使用紧凑的二进制序列化
复用连接
减少不必要的字段
批量请求
使用异步 I/O
避免过细的服务接口
减少跨服务调用次数
合理设置压缩阈值
控制日志和链路追踪开销
例如不要设计成:
查询用户名 → 1次 RPC
查询头像 → 1次 RPC
查询会员等级 → 1次 RPC
查询账户状态 → 1次 RPC
可以考虑合并成:
getUserProfile() → 1次 RPC
但接口也不能无限膨胀,需要根据业务边界权衡。