RPC详解

一、 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元──> 服务端
客户端 <──响应── 服务端

客户端超时后无法确定:

  1. 请求根本没有到达服务端;
  2. 请求到达了,但尚未执行;
  3. 扣款已经完成,但响应丢失;
  4. 服务端执行时发生了部分失败。

所以 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

但接口也不能无限膨胀,需要根据业务边界权衡。

相关推荐
Sayuanni%32 小时前
Spring IOC
java·spring·rpc
Fu_Lin_2 小时前
Qt嵌入式从零基础到精通(01):Qt嵌入式开发全景图:从桌面Qt到ARM Linux
linux·arm开发·qt·qt5·嵌入式linux
Kari112 小时前
连锁门店开业网络验收怎么做:把 PDF 清单改造成可回放的 Skill
网络·人工智能·pdf·php
海清河晏1113 小时前
Qt实战:从零构建美化登录界面
开发语言·c++·qt
Fu_Lin_3 小时前
《Qt嵌入式从零基础到精通》前言与阅读指南
开发语言·qt
mabing99319 小时前
Qt 生成条纹图
开发语言·qt
Fu_Lin_19 小时前
Qt 嵌入式从零基础到精通总纲
开发语言·qt·嵌入式·qt嵌入式
码云数智-大飞20 小时前
PHP 接口开发规范:统一返回格式、异常处理与参数校验
开发语言·php
dadaobusi20 小时前
仿真边界处理
开发语言·php