一、RPC 框架设计:从零开始理解远程调用

说实话,RPC 这个概念我刚开始接触时也觉得挺玄乎的。说白了,它就是让你可以像调用本地函数一样,去调用另一台机器上的函数。嗯,就这么简单。

我在做微服务架构的时候,天天跟 RPC 打交道。那时候我们团队自己撸了一个轻量级框架,踩了不少坑。今天我就把这些经验掰开揉碎了讲给你听。

1.1 RPC 核心概念

RPC,全称 Remote Procedure Call,远程过程调用。它的核心思想就八个字:本地调用,远程执行

你想想看,客户端写了一句:

User user = userService.getUserById(123);

实际上,这个 getUserById 方法是在另一台服务器上执行的。数据通过网络传过去,结果再传回来。整个过程对调用方是透明的——这就是 RPC 的魅力。

RPC 的核心要素:
  • 代理(Proxy):负责屏蔽网络通信细节,让调用方感觉像在调用本地方法
  • 序列化/反序列化:把内存中的对象变成字节流,再变回来
  • 网络传输:把字节流从客户端送到服务端
  • 服务寻址:客户端怎么知道该连哪台机器?

我记得有一次,团队里新来的同学问我:「RPC 和 HTTP 有啥区别?」我当时的回答是:HTTP 是应用层协议,RPC 是一种设计思想。你可以用 HTTP 实现 RPC,也可以用 TCP 直接搞。说白了,RPC 更关注「怎么调」,而不是「怎么传」。

1.2 服务注册与发现

这是 RPC 框架里最容易出问题的一环。我踩过最大的坑就是——服务地址写死在配置文件里。

你想想看,如果服务端扩容了,或者某台机器挂了,客户端还得手动改配置重启。这在生产环境里简直是灾难。

所以,我们需要一个「中间人」来管理所有服务的位置信息。这个中间人就是注册中心

注册中心的工作流程

  1. 服务启动:服务端启动后,把自己的 IP、端口、服务名等信息注册到注册中心
  2. 心跳检测:服务端定期给注册中心发心跳,证明自己还活着
  3. 服务发现:客户端从注册中心拉取服务列表,或者订阅变更通知
  4. 下线通知:服务端优雅关闭时,主动从注册中心注销
我的经验:心跳间隔不要设太短,否则注册中心压力大。一般 5-10 秒一次就够了。超时时间设 3 倍心跳间隔,避免网络抖动导致误判。

常见的注册中心有 ZooKeeper、Consul、Etcd。我个人比较喜欢 Etcd,因为它的 watch 机制做得很优雅,变更通知几乎是实时的。

1.3 序列化与反序列化

序列化,就是把内存里的对象变成字节流。反序列化,就是反过来。这个环节直接影响 RPC 的性能。

我见过最惨的一次线上事故:某团队用了 Java 原生序列化,结果一个对象里嵌套了多层引用,序列化后的字节流膨胀了 10 倍。网络带宽直接被打满,服务大面积超时。

常见序列化方案对比

方案 优点 缺点 适用场景
Protobuf 体积小、速度快、跨语言 需要定义 .proto 文件 高性能 RPC 首选
JSON 可读性好、调试方便 体积大、解析慢 内部调试、低并发场景
MessagePack 比 JSON 小、支持动态类型 生态不如 Protobuf 需要动态类型的场景
Thrift 与 RPC 框架深度集成 绑定 Thrift 框架 Thrift 生态用户
避坑指南:我曾经在序列化时忽略了一个问题——对象里包含循环引用。比如 A 引用了 B,B 又引用了 A。如果不做特殊处理,序列化会陷入死循环。Protobuf 和 JSON 都不支持循环引用,这点要特别注意。

1.4 网络传输层

网络传输层是 RPC 的「血管」。数据能不能高效、可靠地到达对端,全看这一层。

我习惯把网络传输层拆成三个子问题:

  • 连接管理:长连接还是短连接?连接池怎么设计?
  • 协议设计:怎么区分请求和响应?怎么处理粘包?
  • IO 模型:用阻塞 IO 还是非阻塞 IO?多线程还是事件驱动?

连接管理

RPC 几乎都用长连接。短连接每次都要三次握手四次挥手,性能太差了。但长连接也有问题——连接数太多会耗尽系统资源。

我的做法是:客户端维护一个连接池,每个目标服务保持 2-4 个连接。请求过来时,用轮询或者最小连接数策略选一个连接发出去。

协议设计

TCP 是流式协议,没有消息边界。所以我们需要自己定义协议格式。最简单的做法是:

+----------------+------------------+
|  消息长度 (4字节) |  消息体 (变长)    |
+----------------+------------------+

先读 4 个字节得到消息长度,再读对应长度的字节作为消息体。这样就能完美解决粘包问题。

小技巧:消息长度字段建议用无符号整数,最大支持 4GB 的消息体。虽然实际用不到这么大,但留点余量总是好的。

IO 模型

高性能 RPC 框架几乎都走非阻塞 IO + 事件驱动路线。Linux 上用 epoll,macOS 上用 kqueue。C++ 里我推荐用 libevent 或者自己封装 epoll。

我记得有一次,我们压测发现 CPU 使用率很高,但 QPS 上不去。排查了半天,发现是每个请求都创建了一个线程。后来改成线程池 + epoll,QPS 直接翻了 5 倍。

1.5 实战:简易 RPC 框架实现

光说不练假把式。我们来手写一个迷你 RPC 框架,把上面讲的知识点串起来。

整体架构

简易 RPC 框架架构图 客户端 调用代理 (Proxy) 序列化请求 发送到服务端 网络传输层 TCP 长连接 自定义协议 非阻塞 IO (epoll) 服务端 反序列化请求 调用本地服务 序列化响应 请求 请求 响应 响应 注册中心 服务注册 / 发现 注册服务 发现服务 ① 服务端启动时向注册中心注册 ② 客户端从注册中心获取服务端地址 ③ 客户端通过网络传输层调用服务端

核心代码示例

下面是一个极简的 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,再发序列化后的 RpcRequestRpcResponse。接收方先读 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);
        }
    }
}
我曾经踩过的坑:在步骤 5 中,如果服务调用耗时很长(比如查数据库),会阻塞整个事件循环。正确的做法是把耗时操作丢到线程池里执行,事件循环只负责 IO。否则一个慢请求会拖垮所有请求。

小结

RPC 框架设计,说白了就是解决「怎么让两台机器像一台机器一样协作」的问题。核心就三件事:怎么找到服务(注册与发现)、怎么传数据(序列化与网络传输)、怎么调(代理与协议)。

我给你的建议是:先别急着上 ZooKeeper、Protobuf 这些重型武器。用 JSON + TCP 长连接先跑通一个最小原型,把流程走通。然后再逐步替换成高性能组件。这样你对每个环节的理解会深刻得多。

嗯,这一章就到这里。代码示例你可以直接拿去用,但记得根据实际场景调整。毕竟,生产环境和玩具代码之间,差的往往是那些「我踩过的坑」。


公众号:蓝海资料掘金营,微信deep3321