一、RPC 框架设计:从零开始理解远程调用
说实话,RPC 这个概念我刚开始接触时也觉得挺玄乎的。说白了,它就是让你可以像调用本地函数一样,去调用另一台机器上的函数。嗯,就这么简单。
我在做微服务架构的时候,天天跟 RPC 打交道。那时候我们团队自己撸了一个轻量级框架,踩了不少坑。今天我就把这些经验掰开揉碎了讲给你听。
1.1 RPC 核心概念
RPC,全称 Remote Procedure Call,远程过程调用。它的核心思想就八个字:本地调用,远程执行。
你想想看,客户端写了一句:
User user = userService.getUserById(123);
实际上,这个 getUserById 方法是在另一台服务器上执行的。数据通过网络传过去,结果再传回来。整个过程对调用方是透明的——这就是 RPC 的魅力。
- 代理(Proxy):负责屏蔽网络通信细节,让调用方感觉像在调用本地方法
- 序列化/反序列化:把内存中的对象变成字节流,再变回来
- 网络传输:把字节流从客户端送到服务端
- 服务寻址:客户端怎么知道该连哪台机器?
我记得有一次,团队里新来的同学问我:「RPC 和 HTTP 有啥区别?」我当时的回答是:HTTP 是应用层协议,RPC 是一种设计思想。你可以用 HTTP 实现 RPC,也可以用 TCP 直接搞。说白了,RPC 更关注「怎么调」,而不是「怎么传」。
1.2 服务注册与发现
这是 RPC 框架里最容易出问题的一环。我踩过最大的坑就是——服务地址写死在配置文件里。
你想想看,如果服务端扩容了,或者某台机器挂了,客户端还得手动改配置重启。这在生产环境里简直是灾难。
所以,我们需要一个「中间人」来管理所有服务的位置信息。这个中间人就是注册中心。
注册中心的工作流程
- 服务启动:服务端启动后,把自己的 IP、端口、服务名等信息注册到注册中心
- 心跳检测:服务端定期给注册中心发心跳,证明自己还活着
- 服务发现:客户端从注册中心拉取服务列表,或者订阅变更通知
- 下线通知:服务端优雅关闭时,主动从注册中心注销
常见的注册中心有 ZooKeeper、Consul、Etcd。我个人比较喜欢 Etcd,因为它的 watch 机制做得很优雅,变更通知几乎是实时的。
1.3 序列化与反序列化
序列化,就是把内存里的对象变成字节流。反序列化,就是反过来。这个环节直接影响 RPC 的性能。
我见过最惨的一次线上事故:某团队用了 Java 原生序列化,结果一个对象里嵌套了多层引用,序列化后的字节流膨胀了 10 倍。网络带宽直接被打满,服务大面积超时。
常见序列化方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Protobuf | 体积小、速度快、跨语言 | 需要定义 .proto 文件 | 高性能 RPC 首选 |
| JSON | 可读性好、调试方便 | 体积大、解析慢 | 内部调试、低并发场景 |
| MessagePack | 比 JSON 小、支持动态类型 | 生态不如 Protobuf | 需要动态类型的场景 |
| Thrift | 与 RPC 框架深度集成 | 绑定 Thrift 框架 | Thrift 生态用户 |
1.4 网络传输层
网络传输层是 RPC 的「血管」。数据能不能高效、可靠地到达对端,全看这一层。
我习惯把网络传输层拆成三个子问题:
- 连接管理:长连接还是短连接?连接池怎么设计?
- 协议设计:怎么区分请求和响应?怎么处理粘包?
- IO 模型:用阻塞 IO 还是非阻塞 IO?多线程还是事件驱动?
连接管理
RPC 几乎都用长连接。短连接每次都要三次握手四次挥手,性能太差了。但长连接也有问题——连接数太多会耗尽系统资源。
我的做法是:客户端维护一个连接池,每个目标服务保持 2-4 个连接。请求过来时,用轮询或者最小连接数策略选一个连接发出去。
协议设计
TCP 是流式协议,没有消息边界。所以我们需要自己定义协议格式。最简单的做法是:
+----------------+------------------+
| 消息长度 (4字节) | 消息体 (变长) |
+----------------+------------------+
先读 4 个字节得到消息长度,再读对应长度的字节作为消息体。这样就能完美解决粘包问题。
IO 模型
高性能 RPC 框架几乎都走非阻塞 IO + 事件驱动路线。Linux 上用 epoll,macOS 上用 kqueue。C++ 里我推荐用 libevent 或者自己封装 epoll。
我记得有一次,我们压测发现 CPU 使用率很高,但 QPS 上不去。排查了半天,发现是每个请求都创建了一个线程。后来改成线程池 + epoll,QPS 直接翻了 5 倍。
1.5 实战:简易 RPC 框架实现
光说不练假把式。我们来手写一个迷你 RPC 框架,把上面讲的知识点串起来。
整体架构
核心代码示例
下面是一个极简的 RPC 请求结构体定义:
// RPC 请求结构体
struct RpcRequest {
uint32_t service_id; // 服务 ID
uint32_t method_id; // 方法 ID
uint32_t request_id; // 请求 ID(用于匹配响应)
std::string payload; // 序列化后的参数数据
};
// RPC 响应结构体
struct RpcResponse {
uint32_t request_id; // 对应的请求 ID
int32_t error_code; // 错误码,0 表示成功
std::string payload; // 序列化后的返回数据
};
// 网络协议头
struct ProtocolHeader {
uint32_t magic; // 魔数,用于校验
uint32_t body_length; // 消息体长度
uint8_t message_type; // 0=请求, 1=响应
};
发送数据时,先发 ProtocolHeader,再发序列化后的 RpcRequest 或 RpcResponse。接收方先读 12 字节的头部,根据 body_length 再读消息体。
- 魔数:用来快速判断是不是合法的 RPC 数据包,避免解析垃圾数据
- 请求 ID:异步场景下,客户端发多个请求,靠 request_id 匹配哪个响应对应哪个请求
- 错误码:服务端处理失败时,通过错误码告诉客户端具体原因,而不是直接断开连接
服务端处理流程
// 伪代码:服务端主循环
while (true) {
// 1. 从 epoll 中获取就绪的事件
auto events = epoll_wait(epoll_fd, ...);
for (auto& event : events) {
if (event.is_new_connection()) {
// 2. 接受新连接
accept_connection(event.fd);
} else if (event.is_readable()) {
// 3. 读取数据
auto [header, body] = read_message(event.fd);
// 4. 反序列化请求
auto request = deserialize<RpcRequest>(body);
// 5. 查找本地服务并调用
auto service = find_service(request.service_id);
auto response = service->invoke(request.method_id, request.payload);
// 6. 序列化响应并发送
auto response_bytes = serialize(response);
send_message(event.fd, response_bytes);
}
}
}
小结
RPC 框架设计,说白了就是解决「怎么让两台机器像一台机器一样协作」的问题。核心就三件事:怎么找到服务(注册与发现)、怎么传数据(序列化与网络传输)、怎么调(代理与协议)。
我给你的建议是:先别急着上 ZooKeeper、Protobuf 这些重型武器。用 JSON + TCP 长连接先跑通一个最小原型,把流程走通。然后再逐步替换成高性能组件。这样你对每个环节的理解会深刻得多。
嗯,这一章就到这里。代码示例你可以直接拿去用,但记得根据实际场景调整。毕竟,生产环境和玩具代码之间,差的往往是那些「我踩过的坑」。