RPC学习笔记
date
Apr 8, 2025
slug
RPC-learning-notes
status
Published
tags
云原生
RPC
summary
RPC学习笔记
type
Post
RPC - Remote Procedure Call - 远程过程调用
用来解决分布式系统通信问题,核心特点是可以像调用本地一样发起远程调用。RPC其实不只是微服务云原生专用名词,只要涉及到网络通信,就可能用到RPC。
举两个例子:
- 大型分布式应用系统可能会依赖消息队列、分布式缓存、分布式数据库以及统一配置中心等,应用程序与依赖的这些中间件之间都可以通过RPC进行通信。比如etcd,它作为一个统一的配置服务,客户端就是通过gRPC框架与服务端进行通信的。
- Kubernetes本身就是分布式的,Kubernetes的kube-apiserver与整个分布式集群中的每个组件间的通讯,都是通过gRPC框架进行的。
RPC涉及:
- 序列化: 将对象转换为可传输的字节流(序列化)及逆向还原(反序列化),解决跨网络和跨语言的数据交换问题。
- 压缩算法: 减少网络传输的数据量,降低带宽消耗和延迟。
- 协议: 定义客户端与服务端通信的规则,包括传输格式和交互模式:HTTP/2、TCP、UDP。
- 动态代理: 屏蔽远程调用的复杂性,使开发者像调用本地方法一样使用远程服务:JDK动态代理、字节码增强。
- 服务注册与发现: 动态管理服务实例的可用性,支持负载均衡和故障转移。注册中心如ZooKeeper、Consul、ETCD,记录服务地址和元数据。
- 加密:保障数据传输的机密性和完整性,防止中间人攻击和数据篡改。
- 网络通信:网络IO模型,实现高效、稳定的网络通信,处理连接管理、数据收发等底层细节。网络通信说起来简单,但实际上是一个非常复杂的过程,这个过程主要包括:对端节点的查找、网络连接的建立、传输数据的编码解码以及网络连接的管理等等。RPC对网络通信的整个过程做了完整包装,在搭建分布式系统时,它会使网络通信逻辑的开发变得简单,同时也会让网络通信变得更加安全可靠。
RPC集群涉及:
- 监控
- 熔断限流
- 优雅启停
- 多协议
- 分布式链路跟踪
RPC真正强大的地方:
- 连接管理
- 健康检测
- 负载均衡
- 优雅启停机
- 异常重试
- 业务分组
- 熔断限流
如果没有RPC框架,那要怎么调用另外一台服务器的接口呢?
RPC是帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地(同一个项目中的方法)一样的体验,我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。
RPC的作用主要体现在两方面:
- 屏蔽远程调用和本地调用的区别,让我们觉得这就是调用项目内的方法;
- 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。
序列化
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。需要提前把它转换成可传输的二进制数据,而且要求转换算法是可逆的。
数据的数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。
反序列化


RPC不仅可以解决通信问题,还可以发MQ、分布式缓存、数据库。
RPC和HTTP都属于应用层协议。
RPC请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地Socket中,然后被网卡发送到网络设备中。

设计可扩展的、向后兼容的协议,关键点就是利用好Header中的扩展字段以及Payload中的扩展字段,通过扩展字段向后兼容。
不同场景下合理选择序列化方法。

常用的序列化方法:
- JDK原生序列化
- 对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据。
- 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑。

序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。
头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容
实际上任何一种序列化框架,核心思想就是设计一种序列化协议。
- JSON:典型的Key-Value方式,没有数据类型,是一种文本型序列化框架。
- 额外空间开销比较大,对于大数据量服务意味着巨大的内存和磁盘开销;
- JSON没有类型,但像Java这种强类型语言,需要通过反射统一解决,所以性能不好。
但是JSON序列化有两个问题:
所以如果RPC框架选用JSON序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能。
- Hessian:动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian协议要比JDK、JSON更紧凑,性能上要比JDK、JSON序列化高效很多,并且生成的字节数也更少。
- 但是Hessian本身有问题,官方版本对Java里面一些常见对象的类型不支持。
- Linked系列,LinkedHashMap、LinkedHashSet等,但可以通过扩展CollectionDeserializer类修复;
- Locale类,可以通过扩展ContextSerializerFactory类修复;
- Byte/Short反序列化的时候变成Integer
- Protobuf:Google公司内部的混合语言数据标准,结构化数据存储格式,可以用于结构化数据序列化,支持Java、Python、C++、Go等语言。Protobuf使用的时候需要定义IDL(Interface description language),然后使用不同语言的IDL编译器,生成序列化工具类,优点是
- 序列化后体积相比JSON、Hessian小很多;
- IDL能清晰的描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似XML解析器;
- 序列化反序列化速度很快,不需要通过反射获取类型;
- 消息格式升级和兼容性不错,可以做到向后兼容。
- 不支持null
- Protobuf不支持单纯的Map、List集合对象,需要包在对象里面。
Protobuf不需要依赖IDL文件,可以直接对Java领域对象进行反序列化操作,在效率上跟Protobuf差不多,生成的二进制格式和Protobuf是完全相同的,可以说是一个Java版本的Protobuf序列化框架。但在使用过程中,遇到过一些不支持的情况:
序列化协议还有Message Pack、kryo等。
影响选择序列化工具的因素:

首选序列化协议还是Hessian与Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求。其中Hessian在使用上更加方便,在对象的兼容性上更好;Protobuf则更加高效,通用性上更有优势。
RPC框架在使用时需要注意哪些问题?
- 对象构造的过于复杂。属性很多,并且存在多层嵌套。
- 对象过于庞大。
- 使用序列化框架不支持的类作为入参类。
- 对象有复杂的继承关系。
RPC框架在网络通信上更倾向于哪种网络IO模型?
常见的网络IO模型
- 同步阻塞IO(BIO)
- 同步非阻塞IO(NIO)
- IO多路复用
- 异步非阻塞IO(AIO)
只有AIO为异步IO,其他都是同步IO。
阻塞IO(blocking IO)是最简单、最常见的IO模型。在Linux中,默认情况下所有的socket都是blocking的,先看下操作流程。
首先,应用进程发起IO系统调用后,应用进程被阻塞,转到内核空间处理。然后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个IO处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。
系统内核处理IO操作分为两个阶段—等待数据和拷贝数据。而在这两个阶段中,应用进程中IO操作的线程会一直都处于阻塞状态,如果是基于Java多线程开发,那么每一个IO操作都要占用线程,直至IO操作结束。
IO多路复用
多路复用IO是在高并发场景中使用最为广泛的一种IO模型。如Java的NIO、Redis、Nginx的底层实现就是此类IO模型的应用,经典的Reactor模式也是基于此类IO模型。
多个网络连接的IO可以注册到一个复用器(select)上,当用户进程调用了select,那么整个进程会被阻塞。同时,内核会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核中拷贝到用户进程。
这里我们可以看到,当用户进程发起了select调用,进程会被阻塞,当发现该select负责的socket有准备好的数据时才返回,之后才发起一次read,整个流程要比阻塞IO要复杂,似乎也更浪费性能。但它最大的优势在于,用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断的调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
为什么说阻塞IO和IO多路复用最常见?
实际在网络IO的应用上,需要的是系统内核的支持以及编程语言的支持。
在系统内核的支持上,现在大多数系统都会支持阻塞IO、非阻塞IO和IO多路复用,但像信号驱动IO、异步IO,只有高版本的Linux系统才会支持。
在编程语言上,无论是C++还是Java,在高性能的网络编程框架的编写上,大多数都是基于Reactor模式,其中最为典型的便是Java的Netty框架,而Reactor模式是基于IO多路复用的。当然,在非高发场景下,同步阻塞IO是最为常见的。
RPC框架在网络通信上倾向于选择哪种网络IO模型?
RPC调用在大多数情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及IO模型本身的特点,在RPC框架的实现中,在网络通信的处理上,我们会选择IO多路复用的方式。开发语言的网络通信框架选型上,最优的选择是基于Reactor模式实现的框架,如Java语言,首选框架便是Netty框架(Java还有很多其他NIO框架,但目前Netty应用的最为广泛),并且在Linux环境下,也要开启epoll来提升系统性能(Windows环境下是无法开启epoll的,因为系统内核不支持)。
什么是基于Reactor模式的网络IO模型? 基于Reactor模式的网络IO模型是一种事件驱动的高性能网络编程模型,通过将I/O事件的监听、分发与业务逻辑处理解耦,实现对高并发连接的统一管理和高效响应。其核心是通过多路复用技术(如Select、epoll、kqueue)监控多个连接事件,并基于事件类型分发给对应的处理器,避免了传统阻塞式IO的线程资源浪费。 核心组件: - Reactor(反应器):负责监听所有I/O事件,并通过事件循环(Event Loop)将就绪事件分发给对应的处理器。它是整个模型的中枢,通常在一个独立线程中运行。使用多路复用器(如Selector)轮询注册Channel,检测连接、读、写等事件。 - Acceptor(连接处理器):专门处理连接建立事件,接收客户端连接请求,并将新建立的SocketChannel注册到Reactor中,后续监听其读/写事件。 - Handler(事件处理器):处理具体的业务逻辑(如数据读取、处理、写回),通常为: 读处理器:处理读就绪事件,从Channel读取数据并解码。写处理器:处理写就绪事件,将处理结果编码后写回客户端。业务处理器:执行计算、数据库操作等耗时任务,可能由线程池异步处理。
零拷贝 zero copy
系统内核处理IO操作分为两个阶段—等待数据和拷贝数据。等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。

应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由CPU将数据拷贝到系统内核的缓冲区中,之后再由DMA将这份数据拷贝到网卡中,最后由网卡发送出去。这里我们可以看到,一次写操作数据要拷贝两次才能通过网卡发送出去,而用户进程的读操作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到数据。
应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要CPU进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程)。
零拷贝技术
零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过DMA将内核中的数据拷贝到网卡,或将网卡中的数据copy到内核。

零拷贝有两种解决方案
- mmap+write方式:核心原理是通过虚拟内存解决。
- sendfile方式
mmap+write 实现原理: - 内存映射机制:通过mmap系统调用将内核读缓冲区直接映射到用户进程的虚拟地址空间,实现内核与用户空间的共享内存。此过程无需将数据从内核缓冲区拷贝到用户缓冲区,仅建立地址映射关系。 数据传输流程: - 第一次拷贝(DMA):磁盘数据通过DMA直接传输到内核读缓冲区。 共享映射:用户通过虚拟内存映射访问内核缓冲区数据。 - 第二次拷贝(CPU):调用write时,CPU将内核读缓冲区的数据拷贝到内核Socket缓冲区。 - 第三次拷贝(DMA):DMA将Socket缓冲区的数据发送到网卡。 优势与局限: 优点: - 减少一次CPU拷贝(内核→用户缓冲区的拷贝被消除)。 - 允许应用程序直接操作映射内存,适合需要对数据进行预处理(如修改、压缩)的场景。 缺点: - 仍存在4次上下文切换(两次系统调用)和3次数据拷贝。 - 维护内存映射需要额外开销,可能因文件被截断导致异常(如SIGBUS信号)。
sendfile sendfile方式将read和write合并为一次系统调用,直接在内核空间完成数据传输。 数据传输流程(分两种模式) 基础模式(无SG-DMA支持): - 第一次拷贝(DMA):磁盘→内核读缓冲区。 - 第二次拷贝(CPU):内核读缓冲区→内核Socket缓冲区。 - 第三次拷贝(DMA):Socket缓冲区→网卡。 SG-DMA优化模式: 仅需两次DMA拷贝:内核读缓冲区直接通过DMA Scatter/Gather技术传输到网卡,无需CPU参与Socket缓冲区拷贝。 优点: - 系统调用次数减少到1次,上下文切换仅2次。 - 在支持SG-DMA的硬件下实现真正的零拷贝(仅两次DMA拷贝)。 - 吞吐量提升显著,适合大文件传输。 缺点: - 数据对用户空间完全不可见,无法在传输前处理数据。 - 依赖操作系统和硬件支持。 若需数据预处理(如修改文件内容),选择mmap+write。 若仅需高效传输且无需处理数据,优先使用sendfile(尤其支持SG-DMA环境)。
Netty中的零拷贝
完全站在了用户空间上,也就是JVM上,它的零拷贝技术主要是偏向于数据操作的优化上。
- Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的Bytebuf,避免了各个Bytebuf之间的拷贝。
- ByteBuf支持slice操作,因此可以将ByteBuf分解为多个共享同一个存储区域的Bytebuf,避免了内存的拷贝。
- 通过wrap操作,我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,进而避免拷贝操作。
Netty还提供FileRegion中包装NIO的FileChannel.transferTo()方法实现了零拷贝,这与Linux中的sendfile方式在原理上也是一样的。
动态代理:面向接口编程,屏蔽RPC处理流程(这个没有看过代码说实话不是特别清楚)
关于网络通信,只要记住—可靠的传输。
RPC会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。

- 代理类是在运行中生成的,那么代理框架生成生成代理类的速度、生成代理类的字节码大小等等,都会影响到性能—生成的字节码越小,运行所占资源就越小。
- 我们生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时生成的代理类的执行效率就需要很高效。
- 我们希望选择一个使用起来方便的代理类框架。API设计是否好理解、社区活跃度、还有就是依赖复杂度。
gRPC

协议封装
我们需要在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,在两个“断句”符号中间放的内容就是我们请求的二进制数据,这个过程叫做协议封装。


服务发现:到底是要CP还是AP?

- 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心中,注册中心将这个服务节点的IP和接口保存下来。
- 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的IP,然后缓存到本地,并用于后续的远程调用。

如果使用DNS来进行服务发现:
如果我们用DNS来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过DNS拿到随机的一个服务提供者的IP,并与之建立长连接,看上去没有问题,但是需要考虑以下情况:
- 如果IP端口下线,服务调用者能否及时摘除服务节点?
- 如果在之前已经上线了一部分服务节点,这时突然对服务进行扩容,那么新上线的服务节点能否及时接收流量?
答案都是“不能”。这是因为为了提升性能和减少DNS服务的压力,DNS采取了多级缓存机制,一般配置的缓存时间较长。
基于ZooKeeper的服务发现

- 服务平台管理端先在ZooKeeper中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息。
- 当服务提供方发起注册时,会在服务提供方目录创建一个临时节点,节点中存储该服务提供方的注册信息。
- 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调起方watch该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
- 当服务提供方目录下有节点数据发起变更时,ZooKeeper就会通知给发起订阅的服务调用方。
基于消息总线的最终一致性的注册中心
ZooKeeper的一大特点就是强一致性,ZooKeeper集群的每个节点的数据每次发生更新操作,都会通知其它ZooKeeper节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了ZooKeeper集群性能上的下降。
而RPC框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,所以我们可以牺牲掉CP(强制一致性),而选择AP(最终一致),来换取整个注册中心集群的性能和稳定性。
因为要求最终一致性,可以考虑采用消息总线机制。注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册,会产生一个消息推送给服务总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图:

后记
后续应该要解读一些gRPC和Kitex的代码,此外还有字节跳动云原生的公众号的文章可以学习一下。真正重要的事情其实是抛开那些开源项目掌握更基础的知识。