RPC框架

从调用模型、协议分层到 Dubbo 调用链

Posted by Ekko on August 9, 2020

这篇笔记的目标是把 RPC 从“像本地调用一样调远程服务”这句抽象描述拆开,分别说明代理、服务发现、序列化、协议编码、网络传输、请求关联和服务治理在一次调用里各自承担什么职责。

RPC 的核心不在于替代 HTTP,而在于把“远程调用的工程复杂度”封装到统一框架中。只保留“代理 + 找到地址 + 封装请求”的理解还不够,还需要把注册发现、超时重试、负载均衡、连接复用和兼容性边界一并纳入。

参考资料:

官方文档:Apache Dubbo DocsDubbo Protocol OverviewgRPC IntroductionProtocol Buffers OverviewApache Thrift DocumentationJava RMI

实现参考:知乎柳树 - 从零开始写 RPC 框架知乎易哥 - 既然有 HTTP 请求,为什么还要用 RPC 调用EasyRPC

[TOC]


RPC 是一种调用模型和框架设计思路,不是某个固定协议。DubbogRPCThriftRMI 都属于不同形态的 RPC 实现;像 OpenFeign 这样的声明式 HTTP 客户端,也常被用来承载服务间远程调用,但它并不等同于具备完整治理能力的 RPC 框架。

RPC 并不天然比 HTTP “更快”,更准确的说法是:很多 RPC 框架会使用更紧凑的二进制协议、更少的报文冗余、连接复用和更贴近方法调用的数据模型,因此在服务间内部调用场景里常常更高效。

HTTP 是一种应用层协议;如果基于 HTTP 来实现 RPC,那么底层仍然遵循 HTTP 的请求响应语义。只是 RPC 框架会在“如何描述方法、参数、返回值,以及如何生成代理和处理调用链”这几个层面继续往上封装。

什么是RPC

RPC(Remote Procedure Call,远程过程调用)要解决的核心问题,是在不同进程甚至不同机器之间调用服务时,仍然用接近本地方法调用的编程模型表达远程交互。

一次真正可用的 RPC 调用,至少要把下面这些信息跨进程传过去:

  • 目标服务是谁
  • 调用的是哪个方法
  • 参数类型和参数值分别是什么
  • 调用超时、请求 ID、认证信息等元数据是什么
  • 返回值、异常或错误码如何表达

因此,RPC 不能只理解为“发一个网络请求”,它实际上同时处理两层问题:

  • 调用语义层:让业务代码看起来像在调用本地接口
  • 通信实现层:把接口、参数、返回值和上下文转换成网络上传输的字节序列

从这个角度看,RPC 和 HTTP 不是天然对立关系。HTTP 可以作为 RPC 的承载协议之一;RPC 关注的是远程调用模型,HTTP 关注的是应用层报文与交互语义。


先抓主线:RPC 框架到底在做什么

如果把 RPC 框架只理解成“帮我发一个请求”,会低估它做的事情。它真正要解决的是:

  1. 让业务代码按“调用本地方法”的方式去调用远程服务
  2. 在分布式环境里找到可用服务实例,而不是把 IP/端口硬编码在代码里
  3. 把“接口名、方法名、参数、超时、请求 ID”等信息编码成双方都能理解的协议
  4. 通过网络把请求发出去,并且把响应正确地对应回原来的调用线程
  5. 在多实例场景下提供负载均衡、超时、重试、熔断、监控等治理能力

RPC 框架的核心可以概括为:

通过代理实现,找到对应的 IP 地址,根据协议封装请求对象

这个概括已经比较接近,但还要补上下面几个关键词:

  • 代理:让调用端写起来像本地方法调用
  • 服务发现:不直接写死 IP,而是从注册中心或服务目录里拿到实例列表
  • 路由和负载均衡:多个提供者时,要决定这次到底调哪一台
  • 序列化和协议编码:把方法调用转换成可传输的字节流
  • 请求关联:响应回来后,要通过请求 ID 找回原来的调用方
  • 容错治理:超时、重试、降级、限流、监控,不属于“能不能调通”,但属于“框架为什么有价值”

一次 RPC 调用可以先抽象成下面这条链路:

flowchart LR
    A[业务代码发起调用] --> B[动态代理 Proxy]
    B --> C[服务发现]
    C --> D[路由 / 负载均衡]
    D --> E[组装 Invocation]
    E --> F[序列化 + 协议编码]
    F --> G[网络传输 TCP / HTTP2]
    G --> H[服务端解码 + 反序列化]
    H --> I[执行本地实现]
    I --> J[结果序列化并返回]
    J --> K[客户端解码并还原 Result]

从这个角度看,代理只是入口,不是 RPC 框架的全部。


RPC、HTTP、Dubbo、gRPC 到底是什么关系

这几个概念很容易混在一起,但它们并不在同一个层面:

名字 更准确的定位 作用
RPC 调用模型 / 编程方式 目标是让远程调用看起来像本地调用
HTTP 应用层协议 定义请求行、Header、Body、状态码等传输语义
TCP 传输层协议 负责可靠传输,很多 RPC 框架最终都跑在 TCP 上
Dubbo RPC 框架 提供代理、注册发现、集群容错、协议扩展等能力
Dubbo 协议 Dubbo 默认的私有二进制协议 通常基于 TCP 长连接,报文更紧凑
gRPC RPC 框架 通常使用 HTTP/2 + Protobuf
Thrift RPC 框架 / IDL 体系 提供接口定义、代码生成和多语言通信
RMI Java 原生 RPC 方案 更偏 Java 生态内部

更准确的理解可以概括为:

  • RPC 不是和 HTTP 对立的东西
  • HTTP 可以作为 RPC 的传输协议之一
  • Dubbo 协议 不是“RPC 的唯一协议”,它只是 Dubbo 的一种实现方式
  • 一个完整的 RPC 框架,通常是“代理 + 协议 + 序列化 + 传输 + 服务治理”的组合

常见组合大概是这样:

框架 / 方案 常见传输协议 常见序列化方式 特点
基于 RESTful 的接口调用 HTTP/1.1 JSON 通用性强,可读性好,但报文相对冗长
gRPC HTTP/2 Protobuf 跨语言好,IDL 清晰,流式能力强
Dubbo 2 经典协议 TCP Hessian2 等 内部服务调用常见,性能和治理能力都比较强
Dubbo 3 Triple HTTP/2 Protobuf Dubbo 对 gRPC 生态的靠拢方案
Thrift TCP / HTTP Binary / Compact Protocol 适合多语言服务
RMI TCP Java 对象序列化 简单直接,但更偏 Java 内部使用

可以把它们分成两层来看:

  • 传输层问题:到底走 TCP、HTTP/1.1、HTTP/2 还是别的通道
  • 调用层问题:怎么描述方法调用、怎么生成代理、怎么发现服务、怎么处理重试和负载均衡

很多文章容易把这两层混在一起,这也是为什么初学 RPC 时会觉得概念很绕。


方法调用

典型的 B/SC/S 模型里,客户端通过服务端接口获取数据和结果,这类调用更偏外部访问场景。

当系统演化为分布式或微服务架构后,业务会被拆成多个独立服务,调用关系开始大量发生在服务与服务之间。这时调用方更关心“如何像本地接口一样发起调用”,而不是在每一段业务代码里手动拼装 URL、Header、Body 与重试逻辑。

本地调用

假设调用函数 Multiply 来计算 lvalue * rvalue 的结果:

1
2
3
4
5
6
7
8
int Multiply(int l, int r) {
    int y = l * r;
    return y;
}
 
int lvalue = 10;
int rvalue = 20;
int l_times_r = Multiply(lvalue, rvalue);

这是标准的本地函数调用。调用方与被调用方处在同一个进程地址空间内,参数传递、栈帧切换和返回值获取都由语言运行时直接完成。

远程调用

当系统拆成多个服务后,某些公共能力会被抽到独立服务中供其他服务复用。

RPC方法调用.png

此时 Service A 内部并没有 Service BCalculatorImpl 实现类,也不存在跨机器共享内存的前提。

一种直接做法是让 Service B 暴露 RESTful 接口,再由 Service A 通过 HTTP 请求间接调用 CalculatorImpl.add()

这种方式可以完成远程调用,但业务代码通常仍然要显式处理 URL、请求方法、参数编码、异常处理与重试策略。

RPC 要解决的问题,是把这些远程通信细节收敛到框架内部,让调用方继续面向接口编程。

常见做法是为接口生成代理对象。以 Dubbo 为例,消费者注入的通常不是远端服务实现本身,而是一个代理引用;业务线程调用代理方法后,框架再完成方法描述、服务发现、负载均衡、协议编码和网络传输。底层承载协议可能是 Dubbo 私有协议,也可能是基于 HTTP/2 的 Triple 或其他实现,并不固定等同于某个 httpClient

这正是很多 RPC 框架要统一处理的核心流程。

本地调用至少依赖下面三类信息:

  • 明确的目标接口或函数
  • 调用参数
  • 返回值或异常

RPC远程调用必要元素.png

远程调用并不会减少这些要素,差别只在于这些信息必须先被编码成双方都能理解的消息格式,再通过网络送到远端。

Service A 需要通过网络明确告诉 Service B:目标方法是 add,参数分别是 35,结果如何返回,异常如何表达。

RPC调用add示例.png

传输的报文里面按照约定的协议格式给出了函数名和参数,大致这样:

RPC协议格式示例.png

上述编码仅用于说明思路,不代表真实生产报文。网络上传输的最终都是字节序列,实际框架会进一步定义消息头、消息体、长度字段、序列化类型、请求 ID 等结构。

总结: RPC 至少需要解决两个基础问题:

  1. 解决分布式系统中,服务之间的调用问题
  2. 远程调用时,仍然保持接近本地方法调用的使用体验

如果把生产级框架能力也纳入考虑,还需要再补三点:

  1. 服务实例地址不能靠人工维护,需要服务发现和注册中心
  2. 多个服务实例下,需要路由、负载均衡和容错策略
  3. 调用结果需要和原请求一一对应,需要请求 ID、超时控制和响应还原

用 Dubbo 看一次完整流程

如果把 Dubbo 当成一个具体例子,RPC 的抽象过程会更容易理解。整个过程可以拆成两个阶段:

  1. 服务启动阶段:提供者暴露服务,消费者订阅服务
  2. 正式调用阶段:消费者拿着代理对象发起远程调用

1. 服务启动阶段

flowchart LR
    A[Provider 启动] --> B[读取 ServiceConfig]
    B --> C[把本地实现包装成 Invoker]
    C --> D[通过 Protocol 暴露服务]
    D --> E[向 Registry 注册服务地址和元数据]

    F[Consumer 启动] --> G[读取 ReferenceConfig]
    G --> H[向 Registry 订阅服务]
    H --> I[拿到 Provider 地址列表]
    I --> J[生成接口代理 Proxy]

这个阶段完成后,消费者手里虽然拿到的是一个接口代理,但它背后其实已经关联了:

  • 服务接口名
  • 注册中心返回的服务列表
  • 集群容错策略
  • 负载均衡策略
  • 通信协议和序列化方式

2. 一次真实调用阶段

下面以消费者调用 userService.queryById(1) 为例:

sequenceDiagram
    participant App as Consumer业务代码
    participant Proxy as Dubbo代理
    participant Cluster as Cluster/LoadBalance
    participant Registry as 注册中心
    participant Client as Dubbo Client
    participant Server as Dubbo Server
    participant Impl as Provider实现类

    App->>Proxy: 调用 queryById(1)
    Proxy->>Cluster: 组装 Invocation
    Cluster->>Registry: 获取/使用本地缓存的 Provider 列表
    Registry-->>Cluster: 返回可用地址
    Cluster->>Cluster: 路由 + 负载均衡选择一台 Provider
    Cluster->>Client: 编码请求并发起网络调用
    Client->>Server: 发送二进制请求报文
    Server->>Server: 解码 + 反序列化
    Server->>Impl: 调用本地实现方法
    Impl-->>Server: 返回结果
    Server-->>Client: 编码响应报文
    Client-->>Proxy: 解码并根据请求 ID 匹配响应
    Proxy-->>App: 返回 queryResult

这张时序图主要体现了 4 个关键点:

  • 对业务方透明:业务代码只看到接口调用,看不到底层网络细节
  • 对框架方明确:框架内部其实一直在处理 Invocation -> 编码 -> 网络收发 -> 解码 -> Result
  • 对地址解耦:消费者不是把 IP 写死,而是通过注册中心订阅和缓存服务列表
  • 对集群友好:多实例下不是“直接连某台机器”,而是先经过集群容错和负载均衡

3. Dubbo 里几个容易混淆的对象

ProxyInvokerExporter 这些对象名称很容易混淆,可以先按下面的方式理解:

对象 可以先怎么理解 作用
Proxy 给业务方用的“假实现” 让接口调用呈现为本地方法调用的使用方式
Invocation 一次调用的描述对象 里面会带方法名、参数、附件等信息
Invoker 可执行调用的统一抽象 不管本地、远程、集群,最终都抽象成它
Exporter 被暴露出去的服务包装 主要站在服务提供者一侧
Directory 服务列表目录 管理某个接口当前有哪些可用 Invoker
Cluster 集群容错入口 失败重试、快速失败等都在这里决定
LoadBalance 负载均衡策略 决定这次请求落到哪一个 Provider

如果只保留主线,可以记成:

接口 -> Proxy -> Invocation -> Invoker -> Protocol -> Transport -> Provider

4. Dubbo 协议在这里扮演什么角色

以经典 Dubbo 协议为例,它主要解决的是“请求报文怎么定义”这个问题。通常会包括:

  • 魔法数,用来识别这是 Dubbo 报文
  • 请求还是响应
  • 是否双向通信、是否心跳
  • 序列化方式编号
  • 请求 ID
  • 方法调用需要的元数据,比如接口名、方法名、参数类型、参数值

也就是说,Dubbo 协议 不是在决定“要不要调用这个服务”,它决定的是“调用已经发生时,这个请求和响应如何在网络里表达”。


协议确定之后,请求是怎么传出去的

前面提到“代理、服务发现、协议封装”,但一次 RPC 调用真正落到网络里,还需要再回答一个问题:

当协议已经选定之后,请求到底是怎么从当前进程发到远端机器上的?

这个问题不能只回答“走网络传输”,还要把它拆到网络分层里看。

1. 先分清:RPC、HTTP、Dubbo 协议分别处在哪一层

如果按常见的 TCP/IP 分层模型来看,可以先粗略理解成这样:

层次 典型内容 在 RPC 调用里的角色
应用层 RPC 协议、HTTP、HTTP/2、Dubbo 协议、gRPC、序列化协议 定义报文格式、方法名、参数、响应体怎么表示
传输层 TCP、UDP 负责端到端传输、可靠性、重传、流量控制、端口
网络层 IP 负责从源 IP 把数据送到目标 IP
数据链路层 Ethernet、Wi-Fi 负责同一跳链路上的帧传输
物理层 网线、光纤、电磁信号 负责把比特真正发出去

所以:

  • RPC 更像调用模型,本身不是网络分层里的某一层协议
  • Dubbo 协议HTTPgRPC 这些,主要都落在应用层
  • TCP传输层
  • IP网络层

这也是为什么“RPC 是跑在网络上的”,但不能简单说“RPC 就是传输层协议”。准确一点的说法应该是:

RPC 框架在应用层定义调用语义和报文格式,再借助底层传输通道把数据送出去;这个通道可能是 TCPUDP,也可能是基于 TCP 之上的 HTTP/2

2. 以基于 TCP 的 RPC 调用为例

假设现在已经确定使用:

  • 传输协议:TCP
  • RPC 协议:Dubbo 协议
  • 序列化方式:例如 Hessian2Protobuf

那么一次调用在客户端侧大致会经历下面几步:

flowchart TD
    A[业务代码调用代理对象] --> B[组装 Invocation]
    B --> C[序列化参数]
    C --> D[按 Dubbo / 自定义协议编码报文]
    D --> E[写入 Socket]
    E --> F[用户态切到内核态]
    F --> G[TCP 负责分段/编号/校验/重传控制]
    G --> H[IP 负责寻址和路由]
    H --> I[网卡把二进制比特发到网络]
    I --> J[服务端网卡接收数据]
    J --> K[内核协议栈逐层向上交付]
    K --> L[服务端进程从 Socket 读取字节流]
    L --> M[协议解码 + 反序列化]
    M --> N[执行本地方法并返回结果]

这个过程里最核心的分工是:

  • RPC 框架负责“把调用翻译成报文”
  • 操作系统协议栈负责“把报文送到对端”

也就是说,RPC 框架一般不会自己去实现 TCP/IP 协议栈,而是调用操作系统提供的 Socket API,把编码后的字节流交给内核。

3. Socket 在这里到底是什么

很多资料会说“RPC 底层就是 Socket 通信”,这个说法不算错,但容易过于简化。

Socket 可以先理解成:

  • 应用程序和操作系统网络协议栈之间的一个编程接口
  • 应用层进程收发网络数据时最常见的入口

客户端并不是直接操作网卡,也不是直接去“手写 TCP 包”。通常做法是:

  1. 创建 Socket
  2. 连接到目标 IP 和端口
  3. 把 RPC 编码后的字节写入 Socket 发送缓冲区
  4. 由操作系统内核负责后续的 TCP/IP 处理

服务端也是类似:

  1. 监听某个端口
  2. 接收客户端连接
  3. 从 Socket 读取字节流
  4. 按照约定好的 RPC 协议解码
  5. 执行本地方法后再把响应写回 Socket

因此,Socket 更像“应用程序使用网络能力的门把手”,而不是完整传输机制本身。

3.1 工程实现里常见的 NIO / Netty 在做什么

上面说的是原理分层;如果落到 Java 工程实现,很多 RPC 框架不会直接手写最底层阻塞式 Socket,而是建立在:

  • Java NIO
  • Netty

之类的网络通信框架之上。

它们主要解决的是“如何更高效地管理大量连接和收发事件”,比如:

  • 用非阻塞 I/O 减少线程被读写操作长期卡住
  • Selector 或事件循环监听多个连接上的可读、可写事件
  • 维护连接、编解码器、心跳、线程模型
  • 把业务线程和 I/O 线程分开,避免网络线程被业务逻辑拖慢

所以从工程实现角度看,很多 RPC 框架的底层路径可以再细化成:

代理对象 -> 编码器 / 序列化器 -> Netty / NIO -> Socket -> 内核 TCP/IP 协议栈 -> 网络

这也是为什么在 Dubbo、gRPC、Thrift 这类框架里,除了“协议设计”之外,I/O 模型和线程模型也会显著影响吞吐量与延迟。

4. 数据到了传输层以后,TCP 具体做什么

如果 RPC 走的是 TCP,那么 RPC 框架把字节流交给 Socket 之后,后面的很多事就交给 TCP 了。TCP 主要负责:

  • 建立连接,典型就是三次握手
  • 把大的字节流切成适合传输的报文段
  • 为每个报文段编号,保证有序到达
  • 通过 ACK、重传、超时机制保证可靠传输
  • 通过滑动窗口、流量控制、拥塞控制避免把链路打爆

这也是为什么很多 RPC 框架偏爱 TCP:

  • 不需要自己处理丢包重传
  • 天然是面向连接的
  • 更适合请求/响应式调用
  • 长连接场景下可以减少频繁建连的开销

但这里也要注意一点:

TCP 只保证字节流可靠送达,不理解“这是一个 RPC 请求”这层语义。

对 TCP 来说,它看到的只是连续字节;至于哪一段字节代表:

  • 请求头
  • 请求体
  • 方法名
  • 参数列表
  • 返回值

这些都属于应用层协议要解决的问题。

5. 为什么还要做“协议头”和“消息边界”

因为 TCP 是面向字节流的,不是面向消息的。它不会天然告诉接收方:

  • 这一批字节是不是一个完整请求
  • 一个请求从哪里开始、到哪里结束
  • 当前收到的是请求还是响应

所以 RPC 协议通常都会在应用层自己定义报文结构。例如:

  • 魔法数:识别这是不是本协议的包
  • 消息长度:告诉接收方这一帧总共有多长
  • 请求 ID:把响应匹配回对应调用
  • 序列化类型:告诉接收方该怎么反序列化
  • 消息类型:请求、响应、心跳、异常

这类机制本质上是在解决两个问题:

  1. 拆包 / 粘包之后,怎么重新切出完整消息
  2. 拿到完整消息之后,怎么知道该按什么规则解析

所以“RPC 是怎么把数据传过去的”这个问题,不能只回答“通过 TCP”,还必须补一句:

通过 TCP 传输的是字节流,而 RPC 协议负责给这些字节流定义结构、边界和语义。

6. 到了网络层和链路层,又发生了什么

在 TCP 之下,数据还会继续往下走:

  • IP 负责目标地址寻址和路由选择
  • 链路层负责把 IP 包封装成帧,在当前链路上传输
  • 网卡把帧转换成电信号、光信号或无线信号发出去

到了服务端之后,过程再反过来:

  1. 网卡收到比特流
  2. 交给内核网络协议栈
  3. 链路层解帧
  4. 网络层处理 IP
  5. 传输层处理 TCP
  6. 把字节流放到目标 Socket 对应的接收缓冲区
  7. 服务端进程从 Socket 读取数据
  8. RPC 框架完成解码、反序列化和方法调用

也就是说,网络层和链路层主要负责“送达”,应用层主要负责“看懂”。

7. 如果走 HTTP 或 HTTP/2,机制有什么不同

如果 RPC 选择的是基于 HTTPHTTP/2 的方案,比如:

  • 基于 HTTP/1.1 + JSON 的接口调用
  • gRPCHTTP/2 + Protobuf
  • Dubbo3 TripleHTTP/2 + Protobuf

那么底层数据仍然要经过:

  • Socket
  • 操作系统协议栈
  • TCP
  • IP

差别主要不在“有没有经过传输层”,而在应用层协议长什么样

  • HTTP/1.1 有请求行、Header、Body
  • HTTP/2 引入二进制帧、多路复用、Header 压缩
  • Dubbo 私有协议会更贴近方法调用模型,报文通常更紧凑

所以,HTTP RPCDubbo RPC 的根本差异,不是一个“走网络层”、一个“走传输层”,而是:

  • 应用层协议格式不同
  • 报文冗余程度不同
  • 连接复用能力不同
  • 对服务治理、流式通信、多语言生态的支持重点不同

8. 一次请求返回时,为什么能回到原来的调用方

客户端发起 RPC 调用后,通常不会只是“把数据发出去”就结束。框架还会记录:

  • 请求 ID
  • 超时时间
  • Future / Promise 或回调对象
  • 当前连接和目标节点信息

服务端响应回来后,客户端会:

  1. 先按协议解码出响应对象
  2. 读取其中的请求 ID
  3. 用请求 ID 找到本地等待中的 Future
  4. 唤醒阻塞线程,或者回调异步逻辑

所以,“方法调用像本地”只是表象,底层实际上是在做一次带请求 ID 关联的网络请求响应匹配。

9. 用一句话收束这部分

从机制上看,一次 RPC 请求的传递过程可以概括为:

业务调用先在应用层被 RPC 框架转换成协议报文,再通过 Socket 交给操作系统;随后由传输层的 TCP、网络层的 IP 和链路层共同把数据送到远端;对端收到字节流后,再由 RPC 框架按协议解码、反序列化并执行本地方法。

因此,RPC 既不是单纯的“网络层能力”,也不是单纯的“传输层能力”,而是:

  • 应用层定义调用语义
  • 传输层保证端到端传输
  • 网络层负责寻址和路由
  • 链路层和物理层负责真正把数据送出去

RPC 的工程边界与常见误区

RPC 框架会尽量把远程调用伪装成本地调用,但这种“本地感”只是一层编程抽象,底层仍然是高延迟、可失败、可抖动的网络交互。真正的工程难点,往往出现在主流程之外。

问题 根因 典型后果 常见处理方式
超时后自动重试 网络抖动、瞬时过载、调用超时配置过短 非幂等写操作被重复执行 区分读写请求、限制重试次数、引入幂等键
注册中心与本地缓存短暂不一致 实例上下线传播存在延迟 调用到已下线实例,或短时间内找不到新实例 本地缓存兜底、健康检查、订阅变更推送
序列化格式或模型版本不兼容 字段删除、类型变更、协议升级不一致 反序列化失败、数据错位、兼容性回退困难 采用可演进协议,严格管理字段兼容规则
长连接、线程池或事件循环被打满 慢调用积压、连接数过高、下游响应变慢 调用雪崩、级联超时、吞吐量急剧下降 连接复用、限流、隔离线程池、熔断降级
误把远程调用当成本地调用 忽略网络异常、超时、部分失败 代码只处理业务异常,不处理通信失败 在接口层显式设计超时、取消、降级和错误码语义

因此,RPC 的真正价值并不只是“把请求发出去”,而是持续处理这些跨进程、跨网络、跨版本的工程问题。


如何实现一个RPC

完整的RPC过程.png

以上图为例,Application 是服务调用方,Client Stub 是客户端代理对象,Client Run-time Library 是负责协议编解码、连接管理和网络收发的运行时组件,最底层再由操作系统网络栈完成真实传输。

如果只做一个教学性质的最小 RPC Demo,通常至少要完成 4 件事:

  1. 服务契约描述
  2. 服务寻址
  3. 序列化与协议编码
  4. 网络传输与响应关联

1. 服务契约描述

远程调用不能像本地调用那样依赖函数指针或进程内地址空间,因此必须先回答一个最基础的问题:如何唯一描述“要调哪个方法”

在本地调用里,函数体是由当前进程地址空间里的代码和运行时直接定位的;但在远程调用里,调用方和服务提供方通常不在同一个进程里,很多时候也不在同一台机器上,所以不能直接依赖内存地址来表达“要执行哪段逻辑”。

这时就必须用一套双方都认可的描述方式,把“目标服务、目标方法、参数类型、参数值、版本信息”等内容表达出来。

常见做法大致有两类:

  • 直接使用“接口名 + 方法名 + 参数类型 + 版本”等元数据
  • 通过 IDL 生成统一的客户端和服务端桩代码,例如 gRPCThrift

有些实现会进一步把这些信息压缩成内部调用标识,例如字符串签名或整数 ID,再在客户端和服务端各自维护一份映射表。这样做的目的,是让双方都能根据同一个调用标识定位到同一个方法。

因此,“Call ID 映射”可以看作一种实现手段,但它不是 RPC 的本质。RPC 真正要解决的问题是:如何在远程场景下稳定、准确地描述方法调用,并让调用双方对这份契约保持一致。

2. 服务寻址

方法描述确定之后,还要能找到实例地址。寻址方式通常有两种:

  • 静态配置:直接写死目标主机和端口,适合简单场景或早期 Demo
  • 注册中心:由服务提供者注册实例信息,消费者订阅并动态获取实例列表,适合多实例和弹性扩缩容场景

在 Dubbo 体系里,注册中心可以对接 ZooKeeperNacos 等组件;在 Java RMI 里,则有 rmiregistry 这类注册设施,用于根据名称查找远程对象引用。

以 Java RMI 为例,服务寻址通常依赖 RMI Registry。服务端启动后会把远程对象引用绑定到注册表中的某个名称上,客户端再根据名称查找对应的远程对象引用,然后发起远程方法调用。

这里需要区分两个概念:

  • RMI Registry 是 RMI 的注册设施,负责维护“服务名 -> 远程对象引用”的映射关系
  • JNDI 是访问命名与目录服务的统一接口抽象,在某些场景下可以用来访问 RMI Registry,但它本身并不等于注册表

因此,从机制上看,RMI 的服务发现过程可以概括为:服务端完成 bind/rebind,客户端通过 lookup 查找,拿到远程对象引用后再进行调用。

RMI架构图.png

3. 序列化与协议编码

本地调用时,参数传递、栈帧切换和返回值读取都发生在同一个进程地址空间内,调用方和被调用方可以直接通过内存与运行时机制完成数据交换。

但在远程过程调用里,客户端和服务端是两个不同的进程,很多时候还分布在不同机器上,参数和返回值都不能直接通过内存传递。这时就必须先把方法调用涉及的数据转换成可传输的字节流,发送到远端后,再由对方还原成自己能识别的数据结构。

这就是序列化与反序列化要解决的问题:

  • 序列化:把参数、返回值、异常、元数据等对象信息转换成字节序列
  • 反序列化:把接收到的字节序列还原成程序可以继续处理的对象或数据结构

从这个角度看,远程调用传输的并不是“对象本身”,而是对象经过编码后的字节表示。

但只有序列化还不够。因为网络上传输的不只是“参数内容”,还要回答下面这些问题:

  • 这一段字节到底是一条完整请求,还是几条消息拼在一起
  • 当前收到的是请求、响应、心跳,还是异常消息
  • 这一条响应应该匹配回哪个请求
  • 接收方应该按哪种序列化方式去解析这段字节

这些就属于协议编码要处理的内容。协议编码通常会在序列化后的字节流之外,再补上消息边界、消息类型、长度字段、请求 ID、压缩方式、序列化方式等协议字段。

可以把两者的分工概括为:

  • 序列化:解决“对象如何变成字节,又如何从字节还原回来”
  • 协议编码:解决“这些字节在网络上传输时如何分帧、识别、关联和解析”

常见序列化方案包括 ProtobufHessian2JSON、Java 原生序列化等。选择时不仅要看性能,还要看跨语言能力、可演进性与安全性。

4. 网络传输与响应关联

远程调用最终还是要落到网络上传输,因此在完成方法定位、序列化和协议编码之后,还需要一个真正负责收发数据的传输层,把请求从客户端送到服务端,再把执行结果从服务端返回给客户端。

这一层最直接的职责可以概括为两件事:

  • 把调用请求对应的字节流发送到远端服务
  • 把远端返回的结果、异常或响应消息再传回客户端

只要能完成这两件事,都可以作为 RPC 的底层传输通道。因此,从原理上看,RPC 并不强绑定某一种网络协议;关键不在于“必须用哪种协议”,而在于是否能够稳定完成请求发送、响应返回以及后续的关联处理。

常见承载方式包括:

  • 基于 TCP 的私有二进制协议
  • 基于 HTTP/2gRPC / Dubbo Triple
  • 基于 HTTP/1.1 + JSON 的声明式远程调用

很多 RPC 框架偏爱 TCP,因为它能提供可靠传输、长连接复用和更灵活的私有协议设计;但这并不意味着只能使用 TCP。有些场景也可以建立在 UDP 之上,而 gRPCDubbo Triple 这一类方案,则进一步选择了 HTTP/2 作为承载通道。

很多 Java RPC 框架在工程实现上会基于 NIONetty 管理连接复用、心跳、线程模型和异步收发。除了把请求发出去,还必须解决“响应回来后如何回到原来的调用方”这个问题,因此一般都会维护请求 ID 与 Future / 回调之间的对应关系。

如果只从“能跑通一次 RPC 调用”的角度看,一个最小实现通常至少需要下面三部分:

  • 调用标识或方法映射:可以是“接口名 + 方法名 + 参数类型”的描述,也可以是内部维护的整数 ID / 字符串映射
  • 序列化与反序列化:可以自己实现,也可以使用 ProtobufFlatBuffers 等现成方案
  • 网络传输能力:可以直接基于 Socket,也可以建立在 AsioZeroMQNetty 等通信库之上

如果再加上前面提到的请求 ID 关联、超时控制和异常返回处理,就已经可以形成一个教学性质的简化 RPC Demo;而真正的 RPC 框架,还会继续叠加负载均衡、服务发现、重试策略、熔断限流、认证鉴权、链路追踪和监控告警等治理能力。

RPC 代码实现可参考知乎柳树


完整的 RPC 框架

生产环境里的 RPC 框架,通常是在“可完成远程调用”这个最小闭环之上,再继续叠加注册发现、负载均衡、熔断限流、监控、认证和多协议兼容等能力。

RPC完整框架.png

其中,RPC 协议 模块负责定义消息在网络中的表达方式,但它并不是框架的全部。

RPC核心功能.png

一个更完整的 RPC 框架,通常可以拆成下面几层:

模块 主要职责
服务契约层 定义接口、方法、参数、异常和版本边界
代理与调用抽象层 生成客户端代理,封装调用上下文
注册发现层 管理服务注册、实例订阅、地址变化通知
集群治理层 负责路由、负载均衡、超时、重试、熔断、限流
协议与序列化层 编码请求与响应,处理消息边界与兼容性
传输与连接层 管理连接、线程模型、心跳、异步收发
可观测性与安全层 提供日志、指标、Tracing、认证鉴权

RPC核心功能图.png

  • 客户端(Client):服务调用方
  • 客户端存根(Client Stub):存放服务端地址信息,将客户端的请求参数打包成网络消息,再通过网络传输发送给服务端
  • 服务端存根(Server Stub):接收客户端发送过来的请求消息并进行解包,然后再调用本地服务进行处理
  • 服务端(Server):服务真正的提供者
  • Network Service:底层传输能力,可以建立在 TCPHTTP 或其他通信通道之上

在网络消息传输上,可以基于 TCPUDPHTTP 等方式来实现,各自都有不同特点。

TCP:

基于 TCP 实现的 RPC 调用,通常能够更灵活地定制协议字段,减少报文冗余,在性能、吞吐量和并发能力上更有优势,但也要自行处理更多底层细节,例如消息边界、编解码和连接管理。

一种典型做法是由服务调用方和服务提供方建立 Socket 连接,再由调用方把接口名称、方法名称和参数序列化后发送给服务端,服务端完成反序列化后,再通过分发或反射调用对应的方法实现。

在实际工程里,这一层往往还会继续封装。例如 RMI 就是在 TCP 之上传递可序列化的 Java 对象,而像 Dubbo 这类框架则会继续补上协议头、请求 ID、心跳、连接池和线程模型等能力。

TCP 连接既可以是按需建立的短连接,也可以是长期复用的长连接。长连接通常会配合心跳检测机制定期确认连接是否仍然可用,从而让多个远程调用共享同一条连接,减少频繁建连的开销。

HTTP:

基于 HTTP 实现的 RPC,更接近“访问网页”或“调用 Web 接口”的方式。它的优势在于请求和响应模型成熟,工具链完善,基于 JSONXML 之类格式进行开发和调试也比较直接。

这类方式通常由调用方向服务提供方发送 GETPOSTPUTDELETE 等请求,请求参数可以放在 URL、Query、Header 或 Body 中,服务端再按约定的接口语义完成解析和处理,最终返回 JSONXML 或其他格式的结果。

与基于 TCP 的私有协议相比,HTTP 作为更通用的应用层协议,往往会携带更多报文元数据,因此字节开销通常更高,但它在跨语言、跨团队协作、网关接入、调试工具支持等方面更具优势。

由于已经有大量成熟的 Web 服务器和中间件生态,例如 TomcatNginx、各类网关和代理组件,基于 HTTP 的 RPC 实现通常更容易落地,也更容易与现有 Web 体系整合。

因此,从完整框架视角看,RPC 的底层传输并没有唯一标准答案。真正的差别,往往不只是“走 TCP 还是走 HTTP”,而是框架在调用抽象、消息编码、连接管理和服务治理这些层面做到什么程度。


RPC vs RESTful

这一组概念最容易被拿来直接对比,但严格来说,RPCRESTful 并不是完全同一层面的东西:

  • RPC 关注的是“像调用本地方法一样调用远程服务”
  • RESTful 关注的是“把系统能力抽象成资源,并通过统一的 HTTP 语义来操作资源”

所以这两者不是简单的“谁替代谁”,而是两种不同的服务设计思路。

1. 最核心的差别:一个偏方法调用,一个偏资源操作

如果以“下单”和“查询订单”为例:

1
2
3
4
5
6
7
8
// RPC 风格,更接近调用动作
/createOrder
/queryOrder?orderId=123

// RESTful 风格,更接近操作资源
POST /orders
GET /orders/123
DELETE /orders/123

这两种写法背后的建模方式不同:

  • RPC 风格:重点是“我要调用哪个方法”,接口通常围绕动作展开,比如 createOrder()cancelOrder()queryOrderById()
  • RESTful 风格:重点是“我要操作哪个资源”,接口通常围绕资源展开,比如 ordersusersproducts

因此,RPC 更像“调用一个远程函数”,RESTful 更像“对一个资源做标准化操作”。

2. 对比时不要只看 URL,要看 5 个维度

维度 RPC RESTful
建模方式 面向方法 / 面向过程 面向资源
协议语义 可以自定义,也可以基于 HTTP 通常基于 HTTP 统一语义
接口表达 更像 queryOrder()createUser() 更像 GET /orders/123POST /users
可读性 对机器和 SDK 更友好 对人和调试工具更友好
适用场景 内部服务调用、追求效率和透明调用 对外开放接口、跨团队对接、强调通用性

这里最关键的一点是:

RESTful 的优势不只是 URL 好看,而是它把 HTTP 方法、资源路径、状态码、缓存语义这些能力一起纳入了接口设计。

例如:

  • GET 表示读取资源
  • POST 表示创建资源
  • PUT/PATCH 表示更新资源
  • DELETE 表示删除资源

这种设计天然更适合开放 API、前后端分离接口和跨语言集成。

3. 为什么说 RPC 在内部调用里更常见

如果服务之间是内部调用,很多时候调用方更关心的是:

  • 我要调哪个服务的方法
  • 参数是什么
  • 返回值是什么
  • 能不能像本地接口一样直接调用
  • 框架能不能帮我处理地址发现、超时、重试、负载均衡

这时 RPC 的表达通常更直接:

1
Order order = orderService.queryById(123L);

从业务代码看,它几乎就是一次本地方法调用。至于底层到底走的是:

  • Dubbo 私有协议
  • HTTP
  • HTTP/2
  • TCP 长连接

这些细节都由框架隐藏掉了。

而如果用 RESTful 风格,调用方更容易感知到“我是在发一个 HTTP 请求”,例如:

1
2
GET /orders/123 HTTP/1.1
Host: order-service.example.com

因此在“服务治理 + 接口代理 + 透明调用”这一类需求上,RPC 框架通常更占优势。

4. RESTful 的优势到底在哪里

RESTful 不是“性能不如 RPC 的次优方案”,它有自己非常明确的适用边界:

  • 通用性更强:几乎任何语言、任何客户端都能直接发 HTTP 请求
  • 可读性更好:路径、方法、状态码都更接近开放接口规范
  • 生态更成熟:浏览器、网关、代理、缓存、调试工具都天然支持
  • 边界更清晰:适合给前端、第三方平台、外部合作方暴露接口

所以 RESTful 更常出现在:

  • 前后端接口
  • 对外开放平台 API
  • 第三方系统集成
  • 需要借助 HTTP 标准能力的场景

5. RPC 和 RESTful 的关系,比较准确的结论是什么

比较准确的说法不是“RPC 比 RESTful 高级”或者“RESTful 比 RPC 规范”,而是:

  • RESTful 更像一种基于 HTTP 的资源设计风格
  • RPC 更像一种远程调用模型

如果只看内部微服务调用:

  • 追求透明调用、性能、治理能力时,常常更偏向 RPC
  • 追求通用性、可读性、开放性时,常常更偏向 RESTful

如果只看系统对外接口:

  • RESTful 往往更自然

如果只看强类型服务间调用:

  • RPC 往往更顺手

6. 一句话总结这部分

RPC 和 RESTful 的核心差别,不在于 URL 写法,而在于:

RPC 以“远程方法调用”为中心组织接口,RESTful 以“资源及其状态变化”为中心组织接口。

因此,两者并不是简单互斥关系,而是面向不同目标的两种接口设计思路。

既然有 HTTP 请求,为什么还要用 RPC 调用

HTTP 协议本身已经非常通用,以其中常见的 RESTful 风格为代表,它的优势很明显:可读性好,容易得到防火墙、网关和跨语言生态的支持,接口语义也更容易被直接观察和调试。

HTTP 也有明显局限,这些局限正好对应了它的优势:

  • 报文里会携带较多 Header 和通用语义信息,有效业务数据的占比往往没有私有二进制协议高
  • 在服务间频繁调用场景下,显式封装 URL、参数名、请求方法和响应解析会增加调用复杂度
  • 如果目标是“像调用本地方法一样调用远程服务”,单纯依赖 HTTP 接口并不能天然提供这种调用抽象

HTTP和RPC关系图.png

图中的 HTTP 之所以出现两次,是因为它可能扮演两种不同角色:

  • 作为一种直接暴露给调用方的接口风格,例如 RESTful API
  • 作为 RPC 框架底层承载消息的应用层协议,例如 gRPCDubbo Triple

因此,HTTPRPC 既可能是并列比较关系,也可能是包含关系,关键取决于比较的是“跨应用调用方案”还是“RPC 通信过程中的底层承载协议”。

在分布式系统里,每个服务的边界通常都比较小,服务之间彼此调用是很常见的事情。这就会产生 Service A 调用 Service B 中某个方法的需求,也就是远程过程调用。

要让 Service A 调用 Service B 中的方法,最容易想到的做法就是直接通过 HTTP 请求来实现。例如 Service B 暴露一个 RESTful 接口,再由 Service A 去调用这个接口。

这种方式当然是可行的,而且也有非常明显的优点:

  • 接口可读性好
  • 更容易穿过防火墙、网关和代理体系
  • 跨语言支持成熟
  • 调试工具和生态更完善

但当这种调用大量发生在内部服务之间时,问题也会逐渐暴露出来:

  • 每次调用都要显式封装 URL、参数名、请求方法和响应解析逻辑
  • 对“像调用本地接口一样调用远程方法”这件事支持不够自然
  • 在高频内部调用场景下,报文冗余和调用封装成本会变得更明显
  • 负载均衡、服务发现、超时重试、熔断和监控等治理能力往往还需要额外补齐

也正是基于这种背景,RPC 才逐渐成为内部服务调用里非常常见的一类方案。它的重点并不只是“比 HTTP 更快”,而是希望把服务间调用抽象成更接近本地方法调用的工程模型。

通常,RPC 会要求调用方依赖被调用服务的接口定义或契约描述。这样一来,调用方看到的就不再是一个 URL,而是一个可以直接调用的方法入口。

为了实现“像调用内部接口一样调用远程方法”,框架一般会沿着下面这条链路工作:

  • 调用方依赖接口或 IDL 生成的客户端桩代码
  • 代理对象拦截方法调用并组装远程请求
  • 框架完成服务发现、序列化、协议编码和网络发送
  • 服务端解码后执行本地实现,再将结果返回客户端

RPC调用与被调用过程.png

因此,RPC 的优势主要集中在内部服务调用场景,而不是要全面替代 HTTP API

如果只看结论,可以概括为:

  • 对外开放接口、浏览器访问、第三方集成,HTTP/RESTful 往往更自然
  • 内部高频服务调用、强类型契约、统一治理需求,RPC 框架通常更合适
  • HTTP 可以是 RPC 的承载协议,因此两者不是简单替代关系

总结:

“既然有 HTTP 请求,为什么还要 RPC”这个问题,答案不在于谁绝对更先进,而在于两者优化的目标不同:HTTP/RESTful 更强调通用性、可读性和生态兼容;RPC 更强调调用抽象、契约表达、治理能力和内部服务协同效率。