RPC详解
一、 RPC 是什么
RPC 全称Remote Procedure Call,远程过程调用。
它让一个进程像调用本地函数一样,调用另一台机器或另一个进程中的函数。
本地调用:
int result = calculator.add(3, 5);RPC 调用看起来可能完全一样:
int result = calculatorClient.add(3, 5);但背后实际发生的是:
客户端程序 ↓ 参数序列化 网络请求 ↓ 服务端接收请求 ↓ 参数反序列化 执行 add(3, 5) ↓ 结果序列化 网络响应 ↓ 客户端反序列化 ↓ 得到结果 8RPC 的核心目标是:
屏蔽网络通信、序列化和服务定位等细节,为调用者提供接近本地函数调用的编程体验。
但必须注意:远程调用永远不等于本地调用。远程调用可能超时、丢包、重复执行、服务不可用。
二、 一个 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/2001RPC 风格:
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但接口也不能无限膨胀,需要根据业务边界权衡。
