远程服务调用 — RPC

什么是 RPC?

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

RPC 的作用主要体现在两个方面:

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

RPC 通信流程

RPC 一般默认采用 TCP 来传输。

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

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

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

为什么不用 HTTP?

HTTP 协议跟 RPC 都属于应用层协议,那有了现成的 HTTP 协议,为啥不直接用,还要为 RPC 设计私有协议呢?

相对于 HTTP 的用处,RPC 更多的是负责应用间的通信,所以性能要求相对更高。但 HTTP 协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;还有一个更重要的原因是,HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。

序列化框架

任何一种序列化框架,核心思想就是设计一种序列化协议。对于序列化框架的选择,一定要考虑跨语言性,如果绑定特定语言,会对未来 RPC 框架支持多语言带来极大的困难。

1. Protobuf

Protobuf 全称 Google Protocol Buffers,它由谷歌开源而来,在谷歌内部久经考验。它将数据结构以.proto 文件进行描述,通过代码生成工具可以生成对应数据结构的 POJO 对象和 Protobuf 相关的方法和属性。

特点:

  1. 结构化数据存储格式(XML,JSON 等)
  2. 高效的编解码性能
  3. 语言无关、平台无关、扩展性好
  4. 官方支持 Java、C++ 和 Python 三种语言(社区会支持更多中语言)

优点:

  1. IDL 契约:利用数据描述文件对数据结构进行说明,可以实现语言和平台无关,通过标识字段的顺序,可以实现协议的前向兼容,同时提供代码生成工具,可以生成各种语言的服务端和客户端代码。
  2. 性能:相比于其它序列化框架,它的性能更优

2. Apache Thrift

Thrift 源于 Facebook,在多种不同的语言之间通信,Thrift 可以作为高性能的通信中间件使用,它支持数据(对象)序列化和多种类型的 RPC 服务。Thrift 适用于静态的数据交换,需要先确定好它的数据结构,当数据结构发生变化时,必须重新编辑 IDL 文件,生成代码和编译,这一点跟其他 IDL 工具相比可以视为是 Thrift 的弱项。

Thrift 适用于搭建大型数据交换及存储的通用工具,对于大型系统中的内部数据传输,相对于 JSON 和 XML 在性能和传输大小上都有明显的优势。

网络通信

一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。可以说,网络通信是整个 RPC 调用流程的基础。

常见的网络 IO 模型

所谓的两台 PC 机之间的网络通信,实际上就是两台 PC 机对网络 IO 的操作。

常见的网络 IO 模型分为四种:同步阻塞 IO(BIO)、同步非阻塞 IO(NIO)、IO 多路复用和异步非阻塞 IO(AIO)。在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO。其中,最常用的就是同步阻塞 IO 和 IO 多路复用。

1. 阻塞 IO(Blocking IO)

同步阻塞 IO 是最简单、最常见的 IO 模型,在 Linux 中,默认情况下所有的 Socket 都是 Blocking 的,操作流程如下:

首先,应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理。之后,内核开 始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个 IO 处理完毕后 返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。

可以看到,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这 两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态,如果是基于 Java 多线程 开发,那么每一个 IO 操作都要占用线程,直至 IO 操作结束。

这个流程就好比我们去餐厅吃饭,我们到达餐厅,向服务员点餐,之后要一直在餐厅等待后 厨将菜做好,然后服务员会将菜端给我们,我们才能享用。

2. IO 多路复用(IO Multiplexing)

多路复用 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 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

同样好比我们去餐厅吃饭,这次我们是几个人一起去的,我们专门留了一个人在餐厅排号等位,其他人就去逛街了,等排号的朋友通知我们可以吃饭了,我们就直接去享用了。

RPC 框架使用的网络 IO 模型

IO 多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 Socket 的 IO 请求,但使用难度比较高。当然高级的编程语言支持得还是比较好的,比如 Java 语言有很多 的开源框架对 Java 原生 API 做了封装,如 Netty 框架,使用非常简便;而 GO 语言,语 言本身对 IO 多路复用的封装就已经很简洁了。

而阻塞 IO 与 IO 多路复用相比,阻塞 IO 每处理一个 Socket 的 IO 请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低。

RPC 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语 言的支持以及 IO 模型本身的特点,在 RPC 框架的实现中,在网络通信的处理上,我们会 选择 IO 多路复用的方式。开发语言的网络通信框架的选型上,我们最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架(Java 还有很多其 他 NIO 框架,但目前 Netty 应用得最为广泛),并且在 Linux 环境下,也要开启 epoll 来 提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)。

零拷贝

系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据, 就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。以下是具体流程:

应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。用户进程的读操 作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到数据。

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU 进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),非常浪费 CPU 和性能。

这时就需要零拷贝技术,取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,可以通过一种方式,直接将数据写入内核或从内核中读取数据,再通过 DMA 将内 核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。

零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,其核心原理都是通过虚拟内存来解决的。

灵活的 RPC 框架

服务发现

为了高可用,在生产环境中服务提供方都是以集群的方式对外提供服务,集群里面的 这些 IP 随时可能变化,我们需要用一本「通信录」及时获取到对应的服务节点,这个获取的过程称为 服务发现

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

健康检测

健康监测能帮助我们从连接列表里面过滤掉一些存在问题的节点,避免在发请求的时候选择出有问题的节点而影响业务。但是在设计健康检测方案的时候,我们不能简单地从 TCP 连接是否健康、心跳是否正常等简单维度考虑,因为健康检测的目的就是要保证「业务无损」,所以在设计方案的时候,我们可以加入业务请求可用率因素,这样能最大化地提升 RPC 接口可用率。

心跳机制

业内常用的检测方法就是心跳机制,服务调用方每隔一段时间就问一下服务提供方的状态:

  1. 健康状态:建立连接成功,并且心跳探活也一直成功;
  2. 亚健康状态:建立连接成功,但是心跳请求连续失败;
  3. 死亡状态:建立连接失败。

路由策略

在日常的上线过程中,为了验证服务的正确性,通常会让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果。

在调用方发起 RPC 请求时,有一个步骤就是从服务提供方节点集合里选择一个合适的节点,可以再选择节点前加上「筛选逻辑」,把符合要求的节点筛选出来。

负载均衡

当我们的一个服务节点无法支撑现有的访问量时,我们会部署多个节点,组成一个集群,然后通过负载均衡,将请求分发给这个集群下的每个服务节点,从而达到多个服务节点共同分担请求压力的目的。

负载均衡主要分为软负载和硬负载,软负载就是在一台或多台服务器上安装负载均衡的软件,如 LVS、Nginx 等,硬负载就是通过硬件设备来实现的负载均衡,如 F5 服务器等。负 载均衡的算法主要有随机法、轮询法、最小连接法等。

与传统的 Web 服务实现负载均衡所采用策略不同,RPC 的负载均衡完全由 RPC 框架自身实现,RPC 的服务调用者会与「注册中心」下发的所有服务节点建立长连接,在每次发起 RPC 调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起 RPC 调用请求。

自适应的负载均衡的整体设计方案如下:

  1. 添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器。
  2. 运行时状态指标收集器收集服务节点 CPU 核数、CPU 负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取。
  3. 请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999 等。
  4. 可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
  5. 通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。

重试机制

当调用端发起的请求失败时,RPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。

在使用 RPC 框架的时候,我们要确保被调用的服务的业务逻辑是 幂等 的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。

优雅关闭

在重启服务的过程中,RPC 怎么做到让调用方系统不出问题呢?

当服务提供方要上线的时候,一般是通过部署系统完成实例重启。在这个过程中,服务提供方的团队并不会事先告诉调用方他们需要操作哪些机器,从而让调用方去事先切走流量。而对调用方来说,它也无法预测到服务提供方要对哪些机器重启上线,因此负载均衡就有可能把要正在重启的机器选出来,这样就会导致把请求发送到正在重启中的机器里面,从而导致调用方不能拿到正确的响应结果。

这时会想到不可以在服务关闭前,通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除吗?

注册中心通知服务调用方都是异步的,服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方。所以这么来看,通过服务发现并不能做到应用无损关闭。

那么如何优雅的关闭呢?

当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)。这个异常就是告诉调用方「我已经收到这个请求了,但是我正在关闭,并没有处理这个请求」,然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点,这样就可以实现对业务无损。

启动预热

运行了一段时间后的应用,执行速度会比刚启动的应用更快。这是因为在 Java 里面,在运行过程中,JVM 虚拟机会把高频的代码编译成机器码, 被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得「热点」代码的执行不用每次都通过解释,从而提升执行速度。

但是这些「临时数据」,都在我们应用重启后就消失了。重启后的这些「红利」没有了之 后,如果让我们刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高 负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行 为。

就是就要进行启动预热,让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。

可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用?对于刚启动的应用,可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变 大,从而实现一个动态增加流量的过程。

熔断限流

1. 服务端的限流机制

服务端主要是通过限流来进行自我保护,在实现限流时要考虑到应用和 IP 级别,方便在服务治理的时候,对部分访问量特别大的应用进行合理的限流;服务端的限流阈值配置都是作用于单机的,而在有些场景下,例如对整个服务设置限流阈值,服务进行扩容时, 限流的配置并不方便,我们可以在注册中心或配置中心下发限流阈值配置的时候,将总服务节点数也下发给服务节点,让 RPC 框架自己去计算限流阈值;我们还可以让 RPC 框架的 限流模块依赖一个专门的限流服务,对服务设置限流阈值进行精准地控制,但是这种方式依 赖了限流服务,相比单机的限流方式,在性能和耗时上有劣势。

2. 调用端的熔断机制

调用端可以通过熔断机制进行自我保护,防止调用下游服务出现异常,或者耗时过长影响调用端的业务逻辑,RPC 框架可以在动态代理的逻辑中去整合熔断器,实现 RPC 框架的熔断功能。

业务分组

在业务早期,我们会把服务实例统一管理,所有的请求都共用一个大池子。但是等到后期业务丰富了,调用方会越来越多,流量也会越来越多,可能某一天,其中一个调用方流量激增,让整个集群瞬间处于高负载运行,进而影响到其它调用方,导致整体可用率降低。

这时,可以尝试把服务根据调用方划分出来隔离带,实现流量隔离,将核心应用与非核心引用隔离开,保障核心业务不受非核心业务的干扰。

可以通过改造服务发现的逻辑实现,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。