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方法参数
metadataToken、链路信息、版本等
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 消息。

方面RPCREST
核心抽象方法、服务资源
接口形式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

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