什么是 RPC?

我知道你肯定不喜欢听概念,我也是这样,看书的时候一看到概念就直接略过。不过,到后来,我才发现,“定义”是一件多么伟大的事情。当我们能够用一句话把一个东西给定义出来的时候,侧面也说明你已经彻底理解这事了,不仅知道它要解决什么问题,还要知道它的边界。所以,你可以先停下来想想,什么是 RPC。

RPC 的全称是 Remote Procedure Call,即远程过程调用。简单解读字面上的意思,远程肯定是指要跨机器而非本机,所以需要用到网络编程才能实现,但是不是只要通过网络通信访问到另一台机器的应用程序,就可以称之为 RPC 调用了?显然并不够。

我理解的 RPC 是帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地(同一个项目中的方法)一样的体验,我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。

这就好比建在小河上的桥一样连接着河的两岸,如果没有小桥,我们需要通过划船、绕道等其他方式才能到达对面,但是有了小桥之后,我们就能像在路面上一样行走到达对面,并且跟在路面上行走的体验没有区别。所以我认为,RPC 的作用就是体现在这样两个方面:

  • 屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法;
  • 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。

RPC 通信流程

理解了什么是 RPC,接下来我们讲下 RPC 框架的通信流程,方便我们进一步理解 RPC。

如前面所讲,RPC 能帮助我们的应用透明地完成远程调用,发起调用请求的那一方叫做调用方,被调用的一方叫做服务提供方。为了实现这个的目标,我们就需要在 RPC 框架里面对整个通信细节进行封装,那一个完整的 RPC 会涉及到哪些步骤呢?

我们已经知道 RPC 是一个远程调用,那肯定就需要通过网络来传输数据,并且 RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输。我们常用的 HTTP 协议也是建立在 TCP 之上的。

网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。

调用方持续地把请求参数序列化成二进制后,经过 TCP 传输给了服务提供方。服务提供方从 TCP 通道里面收到二进制数据,那如何知道一个请求的数据到哪里结束,是一个什么类型的请求呢?

在这里我们可以想想高速公路,它上面有很多出口,为了让司机清楚地知道从哪里出去,管理部门会在路上建立很多指示牌,并在指示牌上标明下一个出口是哪里、还有多远。那回到数据包识别这个场景,我们是不是也可以建立一些“指示牌”,并在上面标明数据包的类型和长度,这样就可以正确的解析数据了。确实可以,并且我们把数据格式的约定内容叫做“协议”。大多数的协议会分成两部分,分别是数据头和消息体。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。

根据协议格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。

服务提供方再根据反序列化出来的请求对象找到对应的实现类,完成真正的方法调用,然后把执行结果序列化后,回写到对应的 TCP 通道里面。调用方获取到应答的数据包后,再反序列化成应答对象,这样调用方就完成了一次 RPC 调用。

那上述几个流程就组成了一个完整的 RPC 吗?

在我看来,还缺点东西。因为对于研发人员来说,这样做要掌握太多的 RPC 底层细节,需要手动写代码去构造请求、调用序列化,并进行网络调用,整个 API 非常不友好。

那我们有什么办法来简化 API,屏蔽掉 RPC 细节,让使用方只需要关注业务接口,像调用本地一样来调用远程呢?

如果你了解 Spring,一定对其 AOP 技术很佩服,其核心是采用动态代理的技术,通过字节码增强对方法进行拦截增强,以便于增加需要的额外处理逻辑。其实这个技术也可以应用到 RPC 场景来解决我们刚才面临的问题。

由服务提供者给出业务接口声明,在调用方的程序里面,RPC 框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。

到这里,一个简单版本的 RPC 框架就实现了。我把整个流程都画出来了,供你参考:

RPC 协议

我们知道只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。

但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。对于服务提供方应用来说,他会从 TCP 通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?

这就好比让你读一篇没有标点符号的文章,你要怎么识别出每一句话到哪里结束呢?很简单啊,我们加上标点,完成断句就好了。

同理在 RPC 传输数据的时候,为了能准确地“断句”,我们也必须在应用发送请求的数据包里面加入“句号”,这样才能帮我们的接收方应用从数据流里面分割出正确的数据。这个数据包里面的句号就是消息的边界,用于标示请求数据的结束位置。举个具体例子,调用方发送 AB、CD、EF 3 个消息,如果没有边界的话,接收端就可能收到 ABCDEF 或者 ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。

RPC 相对于 HTTP 的用处,RPC 更多的是负责应用间的通信,所以性能要求相对更高,所以 RPC 会选择设计更紧凑的私有协议。

对于服务提供方来说,他是不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了。因此我们需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数我们可以统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。

在协议头里面,我们除了会放协议长度、序列化方式,还会放一些像协议标示、消息 ID、消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。这样一个完整的 RPC 协议大概就出来了,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的,具体协议如下图所示:

可扩展的协议

服务提供方收到一个过期请求,这个过期是说服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间,既然已经过期,就没有必要接着处理,直接返回一个超时就好了。那要实现这个功能,就要在协议里面传递这个配置的超时时间,那如果之前协议里面没有加超时时间参数的话,我们现在把这个超时时间加到协议体里面是不是就有点重了呢?显然,会加重 CPU 的消耗。

所以为了保证能平滑地升级改造前后的协议,我们有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分我们还是可以统称为“协议头”,具体协议如下:

序列化

网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输的,所以我们需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。反之,称为“反序列化”。

这两个过程如下图所示:

总结来说,序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程。

有哪些常用的序列化

JDK 原生序列化

那么 JDK 的序列化过程是怎样完成的呢?我们看下下面这张图:

序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。

  • 头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容
  • 对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据
  • 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑

实际上任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个新的对象,来完成反序列化。

Protobuf

Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等语言。Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类,它的优点是:

  • 序列化后体积小;
  • IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器;
  • 序列化反序列化速度很快,不需要通过反射获取类型;
  • 消息格式升级和兼容性不错,可以做到向后兼容。

其他序列化协议

JSON、Hessian、thrift、kryo等

序列化协议的选择

首先你可能想到的是性能和效率,另外还有空间开销,也就是序列化之后的二进制数据的体积大小。序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,由于 RPC 是远程调用,那么网络传输的速度将直接关系到请求响应的耗时。

序列化协议的通用性和兼容性也是我们需要考虑的。比如某个类型为集合类的入参服务调用者不能解析了,服务提供方将入参类加一个属性之后服务调用方不能正常调用,升级了 RPC 版本后发起调用时报序列化异常了。

下图是序列化协议选择维度优先级:

RPC 使用序列化时要注意的问题

了解了在 RPC 框架中如何选择序列化,那么我们在使用过程中需要注意哪些序列化上的问题呢?

我刚才讲过,在 RPC 的运营中,我遇到的最多的问题就是序列化问题了,除了早期 RPC 框架本身出现的问题以外,大多数问题都是使用方使用不正确导致的,接下来我们就盘点下这些高频出现的人为问题。

对象构造得过于复杂:属性很多,并且存在多层的嵌套,比如 A 对象关联 B 对象,B 对象又聚合 C 对象,C 对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。

对象过于庞大:我经常遇到业务过来咨询,为啥他们的 RPC 请求经常超时,排查后发现他们的入参对象非常得大,比如为一个大 List 或者大 Map,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。

使用序列化框架不支持的类作为入参类:比如 Hessian 框架,他天然是不支持 LinkHashMap、LinkedHashSet 等,而且大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如 HashMap、ArrayList。

对象有复杂的继承关系:大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题 1 一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。