深度解密 RPC

楔子

下面我们来聊一聊 RPC,不过做任何事情都应该 Start with Why,那我们就先来说说为什么要学习 RPC。要回答这个问题,我们就得先考虑下 RPC 的实际应用场景。

说到 RPC,可能你的第一反应就是「微服务」,RPC 最大的特点就是可以让我们像调用本地一样发起远程调用,这一特点常常会让人感觉 RPC 就是为「微服务」或「SOA」而生的。现在的大多数应用系统发展到一定规模之后,都会向「微服务化」演进,演进后的大型应用系统也的确是由一个个「微服务」组成的。

我们可以说 RPC 是「微服务」的基础,这一点是毋庸置疑的。现在我们就可以反过来想这样一个问题:RPC 是不是只应用在「微服务」中呢?

当然不是,只要涉及到网络通信,我们就可能用到 RPC。来看两个栗子:

  • 例 1:大型分布式应用系统可能会依赖消息队列、分布式缓存、分布式数据库以及统一配置中心等,应用程序与依赖的这些中间件之间都可以通过 RPC 进行通信。比如 etcd,它作为一个统一的配置服务,客户端就是通过 gRPC 框架与服务端进行通信的。
  • 例 2:我们经常会谈到的容器编排引擎 Kubernetes,它本身就是分布式的,Kubernetes 的 kube-apiserver 与整个分布式集群中的每个组件间的通讯,都是通过 gRPC 框架进行的。

所以说 RPC 的应用场景还是非常广泛的,既然应用如此广泛,那它的核心价值又在哪里呢?在我看来,RPC 是解决分布式系统通信问题的一大利器。

分布式系统中的网络通信一般都会采用四层的 TCP 协议或七层的 HTTP 协议,目前前者占大多数,这主要得益于 TCP 协议的稳定性和高效性。网络通信说起来简单,但实际上是一个非常复杂的过程,这个过程主要包括:对端节点的查找、网络连接的建立、传输数据的编码解码以及网络连接的管理等等,每一项都很复杂。你可以想象一下,在搭建一个复杂的分布式系统过程中,如果开发人员在编码时要对每个涉及到网络通信的逻辑都进行一系列的复杂编码,这将是件多么恐怖的事情。所以说网络通信是搭建分布式系统的一个大难题,是一点不为过的,我们必须给予足够的重视。

而 RPC 对网络通信的整个过程做了完整包装,在搭建分布式系统时,它会使网络通信逻辑的开发变得更加简单,同时也会让网络通信变得更加安全可靠。使用 RPC 就可以像调用本地一样发起远程调用,用它可以解决通信问题,这时候就需要我们去学序列化、编解码以及网络传输这些内容。但是把这些内容掌握后,你就会发现,原来这些只是 RPC 的基础,RPC 还有更吸引人的点,它真正强大的地方是它的治理功能,比如连接管理、健康检测、负载均衡、优雅启停机、异常重试、业务分组以及熔断限流等等,这些内容也会成为学习 RPC 的重点和难点。

总结一下,学习 RPC 时,我们先要了解其基本原理以及关键的网络通信部分,不要一味依赖现成的框架;之后我们再学习 RPC 的重点和难点,了解 RPC 框架中的治理功能以及集群管理功能等;这个时候你已经很厉害了,但这还不是终点,我们要对 RPC 活学活用,学会提升 RPC 的性能以及它在分布式环境下如何定位问题等等。

那么下面就开始吧。

核心原理:能否画张图解释下 RPC 的通信流程?

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

RPC 还应该能帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地方法(同一个项目中的方法)一样,我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。这就好比建在小河上的桥一样连接着河的两岸,如果没有小桥,我们需要通过划船、绕道等其他方式才能到达对面,但是有了小桥之后,我们就能像在路面上一样行走到达对面,并且跟在路面上行走的体验没有区别。所以 RPC 的作用就是体现在这样两个方面:

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

RPC 通信流程

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

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

我们已经知道 RPC 是一个远程调用,那肯定就需要通过网络来传输数据,并且 RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输,我们常用的 HTTP 协议也是建立在 TCP 之上的;网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象,对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做「序列化」。

调用方持续地把请求参数序列化成二进制后(再经过一层编码),经过 TCP 传输给了服务提供方。服务提供方从 TCP 通道里面收到二进制数据进行解码,然后进行反序列化,得到对象之后进行调用。由于 RPC 并不强制要求调用方和服务提供方是同一种语言,所以序列化之后的结构,调用方和服务放必须都能识别,都可以自由地进行序列化和反序列化,比如 JSON。但 RPC 显然不会使用 JSON 这种数据结构,因为性能太差,需要换一种方式。而对于 RPC 而言,不管用什么方式,服务端必须要能知道一个请求的数据到哪里结束,以及是一个什么类型的请求。

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

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

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

其实还缺点东西,因为对于研发人员来说,这样做要掌握太多的 RPC 底层细节,需要手动写代码去构造请求、调用序列化,并进行网络调用,整个 API 非常不友好。那我们有什么办法来简化 API,屏蔽掉 RPC 细节,让使用方只需要关注业务接口,像调用本地一样来调用远程呢?

如果你了解 Spring,一定对其 AOP 技术很佩服,其核心是采用动态代理的技术,通过字节码增强对方法进行拦截增强,以便于增加需要的额外处理逻辑。其实这个技术也可以应用到 RPC 场景来解决我们刚才面临的问题。由服务提供者给出业务接口声明,在调用方的程序里面,RPC 框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。

没有具体代码实现,上面这段话可能不是很直观,我们后面再细说。

RPC 在架构中的位置

围绕 RPC 我们讲了这么多,那 RPC 在架构中究竟处于什么位置呢?

如刚才所讲,RPC 是解决应用间通信的一种方式,而无论是在一个大型的分布式应用系统还是中小型系统中,应用架构最终都会从「单体」演进成「微服务化」,整个应用系统会被拆分为多个不同功能的应用,并将它们部署在不同的服务器中,而应用之间会通过 RPC 进行通信,可以说 RPC 对应的是整个分布式应用系统,就像是「经络」一样的存在。

那么如果没有 RPC,我们现实中的开发过程是怎样的一个体验呢?

所有的功能代码都会被我们堆砌在一个大项目中,开发过程中你可能要改一行代码,但改完后编译会花掉你 2 分钟,编译完想运行起来验证下结果可能要 5 分钟,是不是很酸爽?更难受的是在人数比较多的团队里面,多人协同开发的时候,如果团队其他人把接口定义改了,你连编译通过的机会都没有,系统直接报错,从而导致整个团队的开发效率都会非常低下。而且当我们准备要上线发版本的时候,QA 也很难评估这次的测试范围,为了保险起见我们只能把所有的功能进行回归测试,这样会导致我们上线新功能的整体周期都特别长。

无论你是研发还是架构师,我相信这种系统架构我们肯定都不能接受,那怎么才能解决这个问题呢?我们首先都会想到可以采用「分而治之」的思想来进行拆分,但是拆分完的系统怎么保持跟未拆分前的调用方式一样呢?我们总不能因为架构升级,就把所有的代码都推倒重写一遍吧。

而 RPC 框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用远程方法。利用 RPC 我们不仅可以很方便地将应用架构从「单体」演进成「微服务化」,而且还能解决实际开发过程中的效率低下、系统耦合等问题,这样可以使得我们的系统架构整体清晰、健壮,应用可运维度增强。

当然 RPC 不仅可以用来解决通信问题,它还被用在了很多其他场景,比如:发 MQ、分布式缓存、数据库等。由此可见,RPC 确实是我们日常开发中经常接触的东西,只是被包装成了各种框架,导致我们很少意识到这就是 RPC,让 RPC 变成了我们最「熟悉的陌生人」。

目前们介绍了下 RPC 的原理,RPC 就是提供一种透明调用机制,让使用者不必显式地区分本地调用和远程调用。RPC 虽然可以帮助开发者屏蔽远程调用跟本地调用的区别,但毕竟涉及到远程网络通信,所以这里还是有很多使用上的区别,比如:

  • 调用过程中超时了怎么处理业务 ?
  • 什么场景下最适合使用 RPC ?
  • 什么时候才需要考虑开启压缩 ?

这些我们接下来慢慢说。

协议:怎么设计可扩展且向后兼容的协议?

我们说 RPC 的核心是让我们像调用本地一样调用远程,帮助我们的应用层屏蔽远程调用的复杂性,使得我们可以更加方便地构建分布式系统。总结起来,其实就一个关键字:透明化。而这背后离不开 RPC 协议,那么下面我们就来说一说。

一提到协议,你最先想到的可能是 TCP 协议、UDP 协议等等,这些网络传输协议的实现在我看来有点晦涩难懂。虽然在 RPC 中我们也会用到这些协议,但这些协议更多的是对我们上层应用是透明的,我们 RPC 在使用过程中并不太需要关注他们的细节。那我今天要讲的 RPC 协议到底是什么呢?

可能我举个例子,你立马就明白了。HTTP 协议是不是很熟悉,这应该是我们日常工作中用得最频繁的协议了,每天打开浏览器浏览的网页就是使用的 HTTP 协议。那 HTTP 协议跟 RPC 协议又有什么关系呢?看起来他俩好像不搭边,但他们有一个共性就是都属于应用层协议,所以我们今天要讲的 RPC 协议就是围绕应用层协议展开的。

协议的作用

你可能会有一个疑问,我们为什么需要协议这个东西呢?没有协议就不能通信吗?我们知道只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。

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

这就好比让你读一篇没有标点符号的文章,你要怎么识别出每一句话到哪里结束呢?很简单啊,我们加上标点,完成断句就好了。同理在 RPC 传输数据的时候,为了能准确地「断句」,我们也必须在应用发送请求的数据包里面加入「句号」,这样才能帮我们的接收方应用从数据流里面分割出正确的数据。这个数据包里面的句号就是消息的边界,用于标示请求数据的结束位置。举个具体例子,调用方发送 AB、CD、EF 3 个消息,如果没有边界的话,接收端就可能收到 ABCDEF 或者 ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。

所以呢,为了避免语义不一致的事情发生,我们就需要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所说的协议。

如何设计协议?

理解了协议的作用,我们再来看看在 RPC 里面是怎么设计协议的。可能你会问:"前面不是说 HTTP 协议跟 RPC 都属于应用层协议,那有了现成的 HTTP 协议,为啥不直接用,还要为 RPC 设计私有协议呢?"

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

**那怎么设计一个私有 RPC 协议呢? **

在设计协议前,我们先梳理下要完成 RPC 通信的时候,在协议里面需要放哪些内容。首先要想到的就是我们前面说的消息边界了,但 RPC 每次发请求发的大小都是不固定的,所以我们的协议必须能让接收方正确地读出不定长的内容。我们可以先固定一个长度(比如 4 个字节)用来保存整个请求数据大小,这样收到数据的时候,我们先读取固定长度的位置里面的值,值的大小就代表协议体的长度,接着再根据值的大小来读取协议体的数据,整个协议可以设计成这样:

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

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

可扩展的协议

刚才讲的协议属于定长协议头,那也就是说往后就不能再往协议头里加新参数了,如果加参数就会导致线上兼容问题。举个具体例子,假设你设计了一个 88 bit 的协议头,然后你为了加入新功能,在协议头里面加了 2 bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照 88 bit 读取协议头,新加的 2 个 bit 会当作协议体前 2 个 bit 数据读出来,但原本协议体最后的 2 个 bit 就被丢弃了,这样就会导致协议体的数据是错的。

可能你会想:"那我把参数加在不定长的协议体里面行不行?而且刚才我们也说了,协议体里面会放一些扩展属性。"

没错,协议体里面是可以加新的参数,但这里有一个关键点,就是协议体里面的内容都是经过序列化出来的,也就是说你要获取到你参数的值,就必须把整个协议体里面的数据经过反序列化出来。但在某些场景下,这样做的代价有点高啊!

比如说,服务提供方收到一个过期请求,这个过期是说服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间,既然已经过期,就没有必要接着处理,直接返回一个超时就好了。那要实现这个功能,就要在协议里面传递这个配置的超时时间,那如果之前协议里面没有加超时时间参数的话,我们现在就必须把这个超时时间加到协议体里面了。但问题是协议体传输的的数据可能会非常的大,我们为了判断是否超时还要先进性反序列化将数据读取出来,这样无疑会加重 CPU 的负担。

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

因此设计一个简单的 RPC 协议并不难,难的就是怎么去设计一个可「升级」的协议。不仅要让我们在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以我们协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展,上述这种设计方法对于做好扩展性可以说是至关重要的。

可能你觉得奇怪,里面多了一个消息 ID,这是做什么用的?调用方发送请求,服务端返回响应,那 RPC 调用怎么保证请求和响应是对应的呢,也就是不会出现将返回给 A 请求的响应返回给 B 了。没错,靠的就是消息 ID。因为 RPC 为了提高吞吐量,会通过异步并发的方式发送请求,所以必须要借助消息 ID 来确认哪个响应对应哪个请求。

我们人类区别于其他动物的一个很大原因,就是我们能够通过语言去沟通,用文字去沉淀文明,从而让我们能站在巨人的肩膀上成长,但为了保证我们记录的文字能够被其他人理解,我们必须通过符号去实现断句,否则就可能导致文字的意义被曲解,甚至闹出笑话。在 RPC 里面,协议的作用就类似于文字中的符号,作为应用拆解请求消息的边界,保证二进制数据经过网络传输后,还能被正确地还原语义,避免调用方跟被调用方之间的「鸡同鸭讲」。但我们在设计协议的时候,也不能只单纯考虑满足目前功能,还应该从更高的层次出发。就好比我们设计系统架构一样,我们需要保证设计出来的系统能够能很好地扩展,支持新增功能。

序列化:对象怎么在网络中传输?

我们说在 RPC 框架中,如何设计可扩展的、向后兼容的协议,其关键点就是利用好 Header 中的扩展字段以及 Payload 中的扩展字段,通过扩展字段向后兼容。那么接下来就来说一下 RPC 框架中的序列化,要知道在不同的场景下合理地选择序列化方式,对提升 RPC 框架整体的稳定性和性能是至关重要的。

为什么需要序列化?

首先,我们得知道什么是序列化与反序列化。上面在描述 RPC 通信流程的时候我们说过:网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象,对象是不能直接在网络中传输的,所以我们需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做「序列化」。 这时,服务提供方就可以正确地从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这个过程我们称之为「反序列化」。

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

但序列化之后还不能直接传输,还要进行一次编码,至于为什么编码后面再详细说。

有哪些常用的序列化?

那么常用的序列化都有哪些呢?首先每种语言基本上都有自己原生的序列化方式,但这种序列化之后的结构也只有该语言才能解析,所以它的性能会高一些。但它不具备普适性,因为语言不会收敛于一种,所以这里我们只介绍多种语言都支持的序列化。

JSON

JSON 可能是我们最熟悉的一种序列化格式了,JSON 是典型的 Key-Value 方式,没有数据类型,是一种文本型序列化框架,JSON 的具体格式和特性,网上相关的资料非常多,这里就不再介绍了。它在应用上还是很广泛的,无论是前台 Web 用 Ajax 调用、用磁盘存储文本类型的数据,还是基于 HTTP 协议的 RPC 框架通信,都会选择 JSON 格式。

但使用 JSON 进行序列化有这样两个问题,需要格外注意:

  • JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;
  • JSON 没有特别强的类型,因此像 Go 这种强类型语言,需要通过反射统一解决,所以性能不会太好;

所以如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者之间传输的数据量需要相对较小才行,否则将严重影响性能。

Hessian

Hessian 是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian 协议要比 JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。因此有非常好的兼容性和稳定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。

Protobuf

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

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

关于 Protobuf 我在另一篇博客中介绍过,它是主流的序列化方式。

RPC 框架中如何选择序列化?

我们刚刚简单地介绍了几种最常见的序列化协议,其实远不止这几种,还有 Message pack、kryo 等。那么面对这么多的序列化协议,在 RPC 框架中我们该如何选择呢?

首先你可能想到的是性能和效率,不错,这的确是一个非常值得参考的因素。我们刚才说过,序列化与反序列化过程是 RPC 调用的一个必须过程,那么序列化与反序列化的性能和效率势必将直接关系到 RPC 框架整体的性能和效率。

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

除了上面两点,还有最最重要的一点,相信你也猜到了,就是序列化协议的通用性和兼容性。该协议一定要能很好地兼容多语言,不然就会出现像某个入参服务调用者不能解析了,服务提供方将入参类加一个属性之后服务调用方不能正常调用,升级了 RPC 版本后发起调用时报序列化异常了…,等等。

因此在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为他是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。我们更加看重这种序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次我们才会去考虑性能、效率和空间开销。

当然还有一点要特别强调,除了序列化协议的通用性和兼容性,序列化协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵。

综合上面几个参考因素,现在我们再来总结一下这几个序列化协议。

我们首选的还是 Hessian 与 Protobuf,因为它们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求。其中 Hessian 在使用上更加方便,在对象的兼容性上更好;Protobuf 则更加高效,通用性上更有优势。

RPC 框架在使用时要注意哪些问题?

了解了在 RPC 框架中如何选择序列化,那么我们在使用过程中需要注意哪些序列化上的问题呢?序列化问题是非常常见的,除了早期 RPC 框架本身出现的问题以外,大多数问题都是使用方使用不正确导致的,接下来我们就盘点下这些高频出现的人为问题。

  • 对象构造得过于复杂:属性很多,并且存在多层的嵌套,比如 A 对象关联 B 对象,B 对象又聚合 C 对象,C 对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。
  • 对象过于庞大:很多的 RPC 请求超时,都是因为入参对象非常的大,比如为一个大 List 或者大 Map,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
  • 使用序列化框架不支持的类作为入参类::比如 Hessian 框架,它天然是不支持很多语言的第三方集合类,因此大多数情况下最好不要使用,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类。
  • 对象具有复杂的继承关系:大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题 1 一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。

在 RPC 框架的使用过程中,我们要尽量构建简单的对象作为入参和返回值对象,避免上述问题。注意事项有以下几点:

  • 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚;
  • 入参对象与返回值对象体积不要太大,更不要传太大的集合;
  • 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类;
  • 对象不要有复杂的继承关系,最好不要有父子类的情况。

实际上,虽然 RPC 框架可以让我们发起全程调用就像调用本地,但在 RPC 框架的传输过程中,入参与返回值的根本作用就是用来传递信息的,为了提高 RPC 调用整体的性能和稳定性,我们的入参与返回值对象要构造得尽量简单,这很重要。

网络通信:RPC框架在网络通信上更倾向于哪种网络IO模型?

我们知道由于网络传输的数据都是二进制数据,所以我们要传递对象,就必须将对象进行序列化,而 RPC 框架在序列化的选择上,我们更关注序列化协议的安全性、通用性、兼容性,其次才关注序列化协议的性能、效率、空间开销。那么接下来我们的重点就要落在网络通信上面,这也是我们在一开始就强调过的重要内容。

那么网络通信在 RPC 调用中起到什么作用呢?

我们说过,RPC 是解决进程间通信的一种方式。一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络 IO 发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次 RPC 调用便结束了。可以说,网络通信是整个 RPC 调用流程的基础。

常见的网络 IO 模型

那说到网络通信,就不得不提一下网络 IO 模型。为什么要讲网络 IO 模型呢?因为所谓的两台 PC 机之间的网络通信,实际上就是两台 PC 机对网络 IO 的操作。常见的网络 IO 模型分为四种:同步阻塞 IO(BIO)、同步非阻塞 IO(NIO)、IO 多路复用和异步非阻塞 IO(AIO)。在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO。

其中,最常用的就是同步阻塞 IO 和 IO 多路复用,这一点通过了解它们的机制,我们会 get 到。至于其他两种 IO 模型,因为不常用,则不细说了。

阻塞 IO(blocking IO)

同步阻塞 IO 是最简单、最常见的 IO 模型,在 Linux 中,默认情况下所有的 socket 都是 blocking 的,先看下操作流程。

首先应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理,因为内核才有权限操作磁盘、网卡等设备。之后内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个 IO 处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。

这里我们可以看到,系统内核处理 IO 操作分为两个阶段:等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态,如果是基于多线程开发,那么每一个 IO 操作都要占用线程,直至 IO 操作结束。这个流程就好比我们去吃饭,我们到达餐厅,向服务员点餐,之后要一直在餐厅等待后厨将菜做好,然后服务员会将菜端给我们,我们才能享用。

IO 多路复用(IO multiplexing)

多路复用 IO 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO 模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。那么什么是 IO 多路复用呢?通过字面上的理解,多路就是指多个通道,也就是多个网络连接的 IO,而复用就是指多个通道复用在一个复用器上。

多个网络连接的 IO 可以注册到一个复用器(epoll)上,当用户进程调用了 epoll,那么整个进程会被阻塞。同时,内核会「监视」所有 epoll 负责的 socket,当任何一个 socket 中的数据准备好了,epoll 就会返回。这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。

因此这里我们可以看到,当用户进程发起了 epoll 调用,进程会被阻塞,当发现该 epoll 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求,用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。同样好比我们去餐厅吃饭,但餐厅有一个机器人,我们点完餐再把手机号告诉机器人就可以走了,等菜做好了,机器人会发短信通知我们,收到短信直接过来吃即可。

为什么说阻塞 IO 和 IO 多路复用最为常用?

了解完二者的机制,我们就可以回到起初的问题了:为什么我们说阻塞 IO 和 IO 多路复用最为常用。对比这四种网络 IO 模型:阻塞 IO、非阻塞 IO、IO 多路复用、异步 IO,实际在网络 IO 的应用上,需要的是系统内核的支持以及编程语言的支持。在系统内核的支持上,现在大多数系统内核都会支持阻塞 IO、非阻塞 IO 和 IO 多路复用,但像信号驱动 IO、异步 IO,只有高版本的 Linux 系统内核才会支持。在编程语言上,无论 C++ 还是 Java,在高性能的网络编程框架的编写上,大多数都是基于 Reactor 模式,其中最为典型的便是 Java 的 Netty 框架、Python 的 Twisted,而 Reactor 模式是基于 IO 多路复用的。当然,在非高并发场景下,同步阻塞 IO 是最为常见的。

综合来讲,在这四种常用的 IO 模型中,应用最多的、系统内核与编程语言支持最为完善的,便是阻塞 IO 和 IO 多路复用。这两种 IO 模型,已经可以满足绝大多数网络 IO 的应用场景。

RPC 框架在网络通信上倾向选择哪种网络 IO 模型?

讲完了这两种最常用的网络 IO 模型,我们可以看看它们都适合什么样的场景。

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

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

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

了解完以上内容,我们可以继续看这样一个关键问题:零拷贝。在我们应用的过程中,它也是非常重要的。

什么是零拷贝?

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

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

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU 进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样是不是很浪费 CPU 和性能呢?那有没有什么方式,可以减少进程间的数据拷贝,提高数据传输的效率呢?

这时我们就需要零拷贝(Zero-copy)技术,所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,可以通过一种方式,直接将数据写入内核或从内核中读取数据,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。那怎么做到零拷贝?你想一下是不是用户空间与内核空间都将数据写到一个地方,就不需要拷贝了?此时你有没有想到虚拟内存?

零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,其核心原理都是通过虚拟内存来解决的。这两种实现方式都不难,市面上可查阅的资料也很多,在此就不详述了。

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

下面聊聊 RPC 里面的「服务发现」在超大规模集群的场景下所面临的挑战,首先我们要先说明一下为什么需要服务发现。

先举个例子,假如你要给一位以前从未合作过的同事发邮件请求帮助,但你却没有他的邮箱地址。这个时候你会怎么办呢?如果是我,我会选择去看公司的企业「通信录」。同理为了高可用,在生产环境中服务提供方都是以集群的方式对外提供服务,集群里面的这些 IP 随时可能变化,我们也需要用一本「通信录」及时获取到对应的服务节点,这个获取的过程我们一般叫作「服务发现」。

对于服务调用方和服务提供方来说,其契约就是接口,相当于「通信录」中的姓名,服务节点就是提供该契约的一个具体实例。服务 IP 集合作为「通信录」中的地址,从而可以通过接口获取服务 IP 的集合来完成服务的发现。这就是我要说的 PRC 框架的服务发现机制,如下图所示:

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

为什么不使用 DNS?

既然服务发现这么厉害,那是不是很难实现啊?其实类似机制一直在我们身边,我们回想下服务发现的本质,就是完成了接口跟服务提供者 IP 的映射。那我们能不能把服务提供者 IP 统一换成一个域名啊,利用已经成熟的 DNS 机制来实现?

好,先带着这个问题,简单地看下 DNS 的流程:

如果我们用 DNS 来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过 DNS 拿到随机的一个服务提供者的 IP,并与之建立长连接,这看上去并没有太大问题,但在我们业界为什么很少用到这种方案呢?不知道你想过这个问题没有,如果没有,现在可以停下来想想这样两个问题:

  • 如果这个 IP 端口下线了,服务调用者能否及时摘除服务节点呢?
  • 如果在之前已经上线了一部分服务节点,这时我突然对这个服务进行扩容,那么新上线的服务节点能否及时接收到流量呢?

这两个问题的答案都是:不能,这是因为为了提升性能和减少 DNS 服务的压力,DNS 采取了多级缓存机制,一般配置的缓存时间较长,所以说服务调用者不能及时感知到服务节点的变化。这时你可能会想,我是不是可以加一个负载均衡设备呢?将域名绑定到这台负载均衡设备上,通过 DNS 拿到负载均衡的 IP。这样服务调用的时候,服务调用方就可以直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发,如下图所示:

这个方案确实能解决 DNS 遇到的一些问题,但在 RPC 场景里面也并不是很合适,原因有以下几点:

  • 搭建负载均衡设备或 TCP/IP 四层代理,需求额外成本;
  • 请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费些性能;
  • 负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作和生效延迟;
  • 我们在服务治理的时候,需要更灵活的负载均衡策略,目前的负载均衡设备的算法还满足不了灵活的需求。

由此可见,DNS 或者 VIP 方案虽然可以充当服务发现的角色,但在 RPC 场景里面直接用还是很难的。

基于 ZooKeeper 的服务发现

那么在 RPC 里面我们该如何实现呢?我们还是要回到服务发现的本质,就是完成接口跟服务提供者 IP 之间的映射。这个映射是不是就是一种命名服务?当然,我们还希望注册中心能完成实时变更推送,是不是像开源的 ZooKeeper、etcd 就可以实现?这里可以肯定地说,确实可以。下面我就来介绍下一种基于 ZooKeeper 的服务发现方式。

整体的思路很简单,就是搭建一个 ZooKeeper 集群作为注册中心集群,服务注册的时候只需要服务节点向 ZooKeeper 节点写入注册信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能,整体流程如下图:

  • 服务平台管理端先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息
  • 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息
  • 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方 watch 该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
  • 当服务提供方目录下有节点数据发生变更时,ZooKeeper 就会通知给发起订阅的服务调用方。

但 ZooKeeper 本身有性能问题,当连接到 ZooKeeper 的节点数量特别多,对 ZooKeeper 读写特别频繁,且 ZooKeeper 存储的目录达到一定数量的时候,ZooKeeper 将不再稳定,CPU 持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper 就因无法承受瞬间的读写压力,马上宕机。

基于消息总线的最终一致性的注册中心

我们知道,ZooKeeper 的一大特点就是强一致性,ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了 ZooKeeper 集群性能上的下降。这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。

而 RPC 框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,所以我们可以牺牲掉 CP(强制一致性),而选择 AP(可用性,意味着最终一致),来换取整个注册中心集群的性能和稳定性。

那么是否有一种简单、高效,并且最终一致的更新机制,能代替 ZooKeeper 那种数据强一致的数据更新机制呢?因为要求最终一致性,我们可以考虑采用消息总线机制。注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图所示:

  • 当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
  • 消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。
  • 消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里面。
  • 采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。

为了性能,这里采用了两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性。另外,你也可能会想到,服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?这个问题我们放到了 RPC 框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点。

通过消息总线的方式,我们就可以完成注册中心集群间数据变更的通知,保证数据的最终一致性,并能及时地触发注册中心的服务下发操作。在 RPC 领域精耕细作后,你会发现,服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性,因为最终一致性才是分布式系统设计中更为常用的策略。

虽然通常我们可以使用 ZooKeeper、etcd 或者分布式缓存(如 Hazelcast)来解决事件通知问题,但当集群达到一定规模之后,依赖的 ZooKeeper 集群、etcd 集群可能就不稳定了,无法满足我们的需求。在超大规模的服务集群下,注册中心所面临的挑战就是超大批量服务节点同时上下线,注册中心集群接受到大量服务变更请求,集群间各节点间需要同步大量服务节点数据,最终导致如下问题:

  • 注册中心负载过高;
  • 各节点数据不一致;
  • 服务下发不及时或下发错误的服务节点列表;

RPC 框架依赖的注册中心的服务数据的一致性其实并不需要满足 CP,只要满足 AP 即可。采用「消息总线」的通知机制,来保证注册中心数据的最终一致性,来解决这些问题的。

健康检测:这个节点都挂了,为啥还要疯狂发请求?

我们知道服务发现的作用就是实时感知集群 IP 的变化,实现接口跟服务集群节点 IP 的映射,并且在超大规模集群实战中,我们更多需要考虑的是保证最终一致性。其实总结来说,就一关键词:「推拉结合,以拉为准」。下面我们再来聊聊 RPC 中的健康检测。

因为有了集群,所以每次发请求前,RPC 框架会根据路由和负载均衡算法选择一个具体的 IP 地址。为了保证请求成功,我们就需要确保每次选择出来的 IP 对应的连接是健康的,这个逻辑你应该理解。但问题是调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况,那怎么保证选择出来的连接一定是可用的呢?

显然终极的解决方案是让调用方实时感知到节点的状态变化,这样他们才能做出正确的选择。这个道理像我们开车一样,车有各种各样的零件,我们不可能在开车之前先去挨个检查下他们的健康情况,转而是应该有一套反馈机制,比如今天我的大灯坏了,那中控台就可以给我提示;明天我的胎压不够了,中控台也能够收到提示。汽车中大部分关键零件的状态变化,我作为调用方,都能够第一时间了解。

那回到 RPC 框架里,我们应该怎么设计这套机制呢?你可以先停下来想想汽车的例子,看看他们是怎么做的。当然回到我们 RPC 的框架里,这事用专业一点的词来说就是服务的健康检测,下面我们就来详细聊聊这个话题。

遇到的问题

在进一步讲解服务健康检测之前,先分享一个曾经遇到过的线上问题。有一天,我们公司某个业务研发团队的负责人急匆匆跑过来,让我帮他解决个问题。仔细听完他的描述后,我才明白,原来是他们发现线上业务的某个接口可用性并不高,基本上十次调用里总会有几次失败。查看了具体的监控数据之后,我们发现只有请求具体打到某台机器的时候才会有这个问题,也就是说,集群中有某台机器出了问题。于是快刀斩乱麻,我建议他们先把这台「问题机器」下线,以快速解决目前的问题。

但对于我来说,问题并没有结束,我开始进一步琢磨:接口调用某台机器的时候已经出现不能及时响应了,那为什么 RPC 框架还会继续把请求发到这台有问题的机器上呢?RPC 框架还会把请求发到这台机器上,也就是说从调用方的角度看,它没有觉得这台服务器有问题。

就像警察破案一样,为了进一步了解事情的真相,我查看了问题时间点的监控和日志,在案发现场发现了这样几个线索:

  • 通过日志发现请求确实会一直打到这台有问题的机器上,因为我看到日志里有很多超时的异常信息。
  • 从监控上看,这台机器还是有一些成功的请求,这说明当时调用方跟服务之间的网络连接没有断开。因为如果连接断开之后,RPC 框架会把这个节点标识为「不健康」,不会被选出来用于发业务请求。
  • 深入进去看异常日志,我发现调用方到目标机器的定时心跳会有间歇性失败。
  • 从目标机器的监控上可以看到该机器的网络指标有异常,出问题时间点 TCP 重传数比正常高 10 倍以上。

有了对这四个线索的分析,我基本上可以得出这样的结论:那台问题服务器在某些时间段出现了网络故障,但也还能处理部分请求。换句话说,它处于半死不活的状态,但是(是转折,也是关键点)还没彻底「死」,还有心跳。这样,调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。

到这里,你应该也明白了,一开始,我们为了快速解决问题,手动把那台问题机器下线了。刨根问底之后,我们发现,其实更大的问题是我们的服务检测机制有问题,有的服务本来都已经病危了,但我们还以为人家只是个感冒。接下来,我们就来看看服务检测的核心逻辑。

健康检测的逻辑

刚刚我们提到了心跳机制,我估计你会想,搞什么心跳,是不是我们把问题复杂化了。当服务方下线,正常情况下我们肯定会收到连接断开的通知事件,在这个事件里面直接加处理逻辑不就可以了?是的,我们前面汽车的例子里检测都是这样做的。但咱们这里不行,因为应用健康状况不仅包括 TCP 连接状况,还包括应用本身是否存活,很多情况下 TCP 连接没有断开,但应用可能已经「僵死了」。

所以业内常用的检测方法就是用心跳机制,心跳机制说起来也不复杂,其实就是服务调用方每隔一段时间就问一下服务提供方,「兄弟,你还好吧?」,然后服务提供方很诚实地告诉调用方它目前的状态。

结合前面的内部不难发现,服务方的状态一般会有三种情况,一个是我很好,一个是我生病了,一个是没回复。用专业的词来对应这三个状态就是:

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

节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化,具体状态间转换图如下:

这里你可以关注下几个状态之间的转换剪头,我们再解释下。首先,一开始初始化的时候,如果建立连接成功,那就是健康状态,否则就是死亡状态。这里没有亚健康这样的中间态。紧接着,如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态,也就是说,服务调用方会觉得它生病了。

生病之后(亚健康状态),如果连续几次都能正常响应心跳请求,那就可以转回健康状态,证明病好了。如果病一直好不了,那就会被断定为是死亡节点,死亡之后还需要善后,比如关闭连接。当然,死亡并不是真正死亡,它还有复活的机会,如果某个时间点里,死亡的节点能够重连成功,那它就可以重新被标记为健康状态。

这就是整个节点的状态转换思路,不用死记,它很简单,除了不能复活,其他都和我们人的状态一样。当服务调用方通过心跳机制了解了节点的状态之后,每次发请求的时候,就可以优先从健康列表里面选择一个节点。当然,如果健康列表为空,为了提高可用性,也可以尝试从亚健康列表里面选择一个,这就是具体的策略了。

具体的解决方案

理解了服务健康检测的逻辑,我们再回到开头我描述的场景里,看看怎么优化。现在你理解了,一个节点从健康状态过渡到亚健康状态的前提是「连续」心跳失败次数必须到达某一个阈值,比如 3 次(具体看你怎么配置了)。而我们的场景里,节点的心跳日志只是间歇性失败,也就是时好时坏,这样,失败次数根本没到阈值,调用方会觉得它只是「生病」了,并且很快就好了。那怎么解决呢?可以先停下来想想。

你是不是会脱口而出,说改下配置,调低阈值呗。是的,这是最快的解决方法,但是我想说,它治标不治本。第一,像前面说的那样,调用方跟服务节点之间网络状况瞬息万变,出现网络波动的时候会导致误判。第二,在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接。

我们回到问题的本源,核心是服务节点网络有问题,心跳间歇性失败。我们现在判断节点状态只有一个维度,那就是心跳检测,那是不是可以再加上业务请求的维度呢?起码我当时是顺着这个方向解决问题的。但紧接着,我又发现了新的麻烦:

  • 调用方每个接口的调用频次不一样,有的接口可能 1 秒内调用上百次,有的接口可能半个小时才会调用一次,所以我们不能把简单的把总失败的次数当作判断条件。
  • 服务的接口响应时间也是不一样的,有的接口可能 1ms,有的接口可能是 10s,所以我们也不能把 TPS 至来当作判断条件。

在和同事讨论之后,我们找到了「可用率」这个突破口,应该相对完美了。可用率的计算方式是某一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。

总结

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

正常情况下,我们大概 30S 会发一次心跳请求,这个间隔一般不会太短,如果太短会给服务节点造成很大的压力。但是如果太长的话,又不能及时摘除有问题的节点。

除了在 RPC 框架里面我们会有采用定时「健康检测」,其实在其它分布式系统设计的时候也会用到「心跳探活」机制。比如在应用监控系统设计的时候,需要对不健康的应用实例进行报警,好让运维人员及时处理。和咱们 RPC 的例子一样,在这个场景里,你也不能简单地依赖端口的连通性来判断应用是否存活,因为在端口连通正常的情况下,应用也可能僵死了。

那有啥其他办法能处理应用僵死的情况吗?我们可以让每个应用实例提供一个「健康检测」的 URL,检测程序定时通过构造 HTTP 请求访问该 URL,然后根据响应结果来进行存活判断,这样就可以防止僵死状态的误判。你想想,这不就是咱们前面讲到的心跳机制吗?

但是根据经验,有一个办法可以减少误判的几率,那就是把检测程序部署在多个机器里面,分布在不同的机架,甚至不同的机房。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。

路由策略:怎么让请求按照设定的规则发到不同的节点上?

我们介绍了健康检测在 RPC 中的作用,简单来讲就是帮助调用方应用来管理所有服务提供方的连接,并动态维护每个连接的状态,方便服务调用方在每次发起请求的时候都可以拿到一个可用的连接。那么下面我们来聊一聊 RPC 中的路由策略。

为什么选择路由策略?

在前面我们提到过,在真实环境中我们的服务提供方是以一个集群的方式提供服务,这对于服务调用方来说,就是一个接口会有多个服务提供方同时提供服务,所以我们的 RPC 在每次发起请求的时候,都需要从多个服务提供方节点里面选择一个用于发请求的节点。既然这些节点都可以用来完成这次请求,那么我们就可以简单地认为这些节点是同质的。这里的同质怎么理解呢?就是这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的。

既然服务提供方是以集群的方式对外提供服务,那就要考虑一些实际问题。要知道我们每次上线应用的时候都不止一台服务器会运行实例,那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,尤其是发生重大变动的时候,导致我们应用不稳定的因素就变得很多。

为了减少这种风险,我们一般会选择灰度发布我们的应用实例,比如我们可以先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例。但这种方式不好的一点就是,线上一旦出现问题,影响范围还是挺大的。因为对于我们的服务提供方来说,我们的服务会同时提供给很多调用方来调用,尤其是像一些基础服务的调用方会更复杂,比如商品、价格等等,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损。

那对于我们的 RPC 框架来说,有什么的办法可以减少上线变更导致的风险吗?这就不得不提路由在 RPC 中的应用。具体好在哪里,怎么实现,我们接着往下看。

如何实现路由策略

可能你会说,我们可以在上线前把所有的场景都重新测试一遍啊?这也是一种方法,而且测试肯定是上线前的一个重要环节。但以我个人的经验来看,由于线上环境太复杂了,单纯从测试角度出发只能降低风险出现的概率,想要彻底验证所有场景基本是不可能的。那如果没法 100% 规避风险,我们还能怎么办?我认为只有一条路可以尝试了,就是尽量减小上线出问题导致业务受损的范围。基于这个思路,我们是不是可以在上线完成后,先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果。那在 RPC 框架里面我们具体该怎么实现呢?

我们在介绍服务发现的时候说过,在 RPC 里面服务调用方是通过服务发现的方式拿到了所有服务提供方的 IP 地址,那我们是不是就可以利用这个特点?当我们选择要灰度验证功能的时候,是不是就可以让注册中心在推送的时候区别对待,而不是一股脑的把服务提供方的 IP 地址推送到所有调用方。换句话说就是,注册中心只会把刚上线的服务 IP 地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个 IP 地址的。

通过服务发现的方式来隔离调用方请求,从逻辑上来看确实可行,但注册中心在 RPC 里面的定位是用来存储数据并保证数据一致性的。如果把这种复杂的计算逻辑放到注册中心里面,当集群节点变多之后,就会导致注册中心压力很大,而且大部分情况下我们一般都是采用开源软件来搭建注册中心,要满足这种需求还需要进行二次开发。所以从实际的角度出发,通过影响服务发现来实现请求隔离并不划算。

那还有其它方案吗?我们可以重新回到调用方发起 RPC 调用的流程,在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(就是我们常说的负载均衡),那我们是不是可以在选择节点前加上「筛选逻辑」,把符合我们要求的节点筛选出来。那这个筛选的规则是什么呢?就是我们前面说的灰度过程中要验证的规则。

举个具体例子你可能就明白了,比如我们要求新上线的节点只允许某个 IP 可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合,按照这个例子的逻辑,最后会过滤出一个节点,这个节点就是我们刚才新上线的节点。通过这样的改造,RPC 调用流程就变成了这样:

这个筛选过程在我们的 RPC 里面有一个专业名词,就是「路由策略」,而上面例子里面的路由策略是我们常见的 IP 路由策略,用于限制可以调用服务提供方的 IP。使用了 IP 路由策略后,整个集群的调用拓扑如下图所示:

参数路由

有了 IP 路由之后,上线过程中我们就可以做到只让部分调用方请求调用到新上线的实例,相对传统的灰度发布功能来说,这样做我们可以把试错成本降到最低。但在有些场景下,我们可能还需要更细粒度的路由方式。比如在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用。

在流量切换的过程中,为了保证整个流程的完整性,我们必须保证某个主题对象的所有请求都使用同一种应用来承接。假设我们改造的是商品应用,那主题对象肯定是商品 ID,在切流量的过程中,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。

很显然,上面的 IP 路由并不能满足我们这个需求,因为 IP 路由只是限制调用方来源,并不会根据请求参数请求到我们预设的服务提供方节点上去。那我们怎么利用路由策略实现这个需求呢?其实你只要明白路由策略的本质,就不难明白这种参数路由的实现。

我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,我们可以很容易地拿到请求参数,也就是我们例子中的商品 ID,我们可以根据注册中心下发的规则来判断当前商品 ID 的请求是过滤掉新应用还是老应用的节点。因为规则对所有的调用方都是一样的,从而保证对应同一个商品 ID 的请求要么是新应用的节点,要么是老应用的节点。使用了参数路由策略后,整个集群的调用拓扑如下图所示:

相比 IP 路由,参数路由支持的灰度粒度更小,它为服务提供方应用提供了另外一个服务治理的手段。灰度发布功能是 RPC 路由功能的一个典型应用场景,通过 RPC 路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。

所以在日常工作中,我们几乎每天都在做线上变更,每次变更都有可能带来一次事故,为了降低事故发生的概率,我们不光要从流程上优化操作步骤,还要使我们的基础设施能支持更低的试错成本。灰度发布功能作为 RPC 路由功能的一个典型应用场景,我们可以通过路由功能完成像定点调用、黑白名单等一些高级服务治理功能。在 RPC 里面,不管是哪种路由策略,其核心思想都是一样的,就是让请求按照我们设定的规则发送到目标节点上,从而实现流量隔离的效果。

负载均衡:节点负载差距这么大,为什么收到的流量还一样?

介绍完「多场景的路由选择」,其核心就是「如何根据不同的场景控制选择合适的目标机器」。那么接下来我们来聊一个新的话题,看看在 RPC 中如何实现负载均衡。

一个问题

在介绍负载均衡之前,我们先提出一个问题:"当碰上流量高峰时,线上服务的可用率会降低,经过排查发现,是因为其中有几台机器比较旧了。当时最早申请的一批容器配置比较低,缩容的时候留下了几台,当流量达到高峰时,这几台容器由于负载太高,就扛不住压力了。那么问题来了,有没有好的服务治理策略呢?"

这个问题其实挺好解决的,这里直接可以给出一个方案:在治理平台上调低这几台机器的权重,这样的话,访问的流量自然就减少了。

但问题又来了:"当服务可用率降低的时候,业务请求已经受到影响了,这时再如此解决需要时间啊,那这段时间里业务可能已经有损失了。虽说我们的服务治理平台能够动态地控制线上服务节点接收的访问量,但当业务方发现部分机器负载过高或者响应变慢的时候再去调整节点权重,真的很可能已经影响到线上服务的可用率了。"

所以我们要探究的主题就出现了,RPC 框架有没有什么智能负载的机制,能否及时地自动控制服务节点接收到的访问量呢?带着这个问题,我们来看看 RPC 框架的负载均衡。

什么是负载均衡?

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

负载均衡主要分为软负载和硬负载,软负载就是在一台或多台服务器上安装负载均衡的软件,如 LVS、Nginx 等,硬负载就是通过硬件设备来实现的负载均衡,如 F5 服务器等。负载均衡的算法主要有随机法、轮询法、最小连接法等。我们刚才介绍的负载均衡主要还是应用在 Web 服务上,Web 服务的域名绑定负载均衡的地址,通过负载均衡将用户的请求分发到一个个后端服务上。

RPC 框架中的负载均衡

那 RPC 框架中的负载均衡是不是也是如此呢?和上面讲的负载均衡,你觉得会有区别吗?我们可以回想一下,之前我们说过为什么不通过 DNS 来实现「服务发现」,之后我又讲解了为什么不采用添加负载均衡设备或者 TCP/IP 四层代理,域名绑定负载均衡设备的 IP 或者四层代理 IP 的方式。当是我们的回答是这样的:

  • 搭建负载均衡设备或 TCP/IP 四层代理,需要额外成本;
  • 请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费一些性能;
  • 负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作,「服务发现」在操作上是个问题;
  • 我们在服务治理的时候,针对不同接口服务、服务的不同分组,我们的负载均衡策略是需要可配的,如果大家都经过这一个负载均衡设备,就不容易根据不同的场景来配置不同的负载均衡策略了;

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

RPC 负载均衡策略一般包括随机权重、Hash、轮询。当然,这还是主要看 RPC 框架自身的实现。其中的随机权重策略应该是我们最常用的一种了,通过随机算法,我们基本可以保证每个节点接收到的请求流量是均匀的;同时我们还可以通过控制节点权重的方式,来进行流量控制。比如我们默认每个节点的权重都是 100,但当我们把其中的一个节点的权重设置成 50 时,它接收到的流量就是其他节点的 1/2。

由于负载均衡机制完全是由 RPC 框架自身实现的,所以它不再需要依赖任何负载均衡设备,自然也不会发生负载均衡设备的单点问题,服务调用方的负载均衡策略也完全可配,同时我们可以通过控制权重的方式,对负载均衡进行治理。

了解完 RPC 框架的负载均衡,现在我们就可以回到这讲最开头业务提的那个需求:有没有什么办法可以动态地、智能地控制线上服务节点所接收到的请求流量?现在答案是不是就显而易见了,解决问题的关键就在于 RPC 框架的负载均衡上。对于这个问题,解决方案是设计一种自适应的负载均衡策略。

如何设计自适应的负载均衡?

刚才说过,RPC 的负载均衡完全由 RPC 框架自身实现,服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。那是不是只要调用者知道每个服务节点处理请求的能力,再根据服务处理节点处理请求的能力来判断要打给它多少流量就可以了?当一个服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。

这就有点像日常工作中的分配任务,要多考虑实际情况。当一位下属身体欠佳,就少给他些工作;若刚好另一位下属状态很好,手头工作又不是很多,就多分给他一点。

那服务调用者节点又该如何判定一个服务节点的处理能力呢?

这里我们可以采用一种打分的策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一个分数,比如总分 10 分,如果 CPU 负载达到 70%,就减它 3 分,当然了,减 3 分只是个类比,需要减多少分是需要一个计算策略的。

我们又该如果根据这些指标来打分呢?

这就有点像公司对员工进行年终考核。假设我是老板,我要考核专业能力、沟通能力和工作态度,这三项的占比分别是 30%、30%、40%,我给一个员工的评分是 10、8、8,那他的综合分数就是这样计算的:10*30%+8*30%+8*40%=8.6 分。

给服务节点打分也一样,我们可以为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。

服务调用者给每个服务节点都打完分之后,会发送请求,那这时候我们又该如何根据分数去控制给每个服务节点发送多少流量呢?

我们可以配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权重。例如给一个服务节点综合打分是 8 分(满分 10 分),服务节点的权重是 100,那么计算后最终权重就是 80(100*80%)。服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的 80%(这里假设其他节点默认权重都是 100,且指标正常,打分为 10 分的情况)。

到这儿,一个自适应的负载均衡我们就完成了,整体的设计方案如下图所示:

关键步骤我来解释下:

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

总结

这里我们讲解了 RPC 框架的负载均衡,它与 Web 服务的负载均衡的不同之处在于:RPC 框架并不是依赖一个负载均衡设备或者负载均衡服务器来实现负载均衡的,而是由 RPC 框架本身实现的,服务调用者可以自主选择服务节点,发起服务调用。这样的好处是,RPC 框架不再需要依赖专门的负载均衡设备,可以节约成本;还减少了与负载均衡设备间额外的网络传输,提升了传输效率;并且均衡策略可配,便于服务治理。

除此之外,我们还涉及到「如何设计一个自适应的负载均衡」,通过它,我们可以就能根据服务调用者依赖的服务集群中每个节点的自身状态,智能地控制发送给每个服务节点的请求流量,防止因某个服务节点负载过高、请求处理过慢而影响到整个服务集群的可用率。这个自适应负载均衡的实现方案,其实不只是应用于 RPC 框架中的负载均衡,它本身便是一个智能负载的解决方案,如果你在工作中需要设计一个智能的负载均衡服务,那么完全可以参考。

异常重试:在约定时间内安全可靠地重试

我们可以考虑这样一个场景,我们发起一次 RPC 调用,去调用远程的一个服务,比如用户的登录操作,我们会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。当我们通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题,比如网络突然抖了一下,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功,那这时我们要怎么做呢?

我们需要重新发起一次 RPC 调用,那我们在代码中该如何处理呢?是在代码逻辑里 catch 一下,失败了就再发起一次调用吗?这样做显然不够优雅吧,这时我们就可以考虑使用 RPC 框架的重试机制。

RPC 框架的重试机制

那什么是 RPC 框架的重试机制呢?这其实很好理解,就是当调用端发起的请求失败时,RPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。那这个机制是如何实现的呢?

我们之前说过,调用端在发起 RPC 调用时,会经过负载均衡,选择一个节点,之后它会向这个节点发送请求信息。当消息发送失败或收到异常消息时,我们就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数的时候,就返回给调用端动态代理一个失败异常,否则就一直重试下去。

RPC 框架的重试机制就是调用端发现请求失败时捕获异常,之后触发重试,那是不是所有的异常都要触发重试呢?当然不是了,因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。

了解了 RPC 框架的重试机制,那用户在使用异常重试时需要注意哪些问题呢?比如我们刚才提的那个调用场景,当网络突然抖动了一下导致请求超时了,但这个时候调用方的请求信息可能已经发送到服务提供方的节点上,也可能已经发送到服务提供方的服务节点上,那如果请求信息成功地发送到了服务节点上,那这个节点是不是就要执行业务逻辑了呢?是的。那如果这个时候发起了重试,业务逻辑是否会被执行呢?会的。那如果这个服务业务逻辑不是幂等的,比如插入数据操作,那触发重试的话会不会引发问题呢?会的。

综上,我们可以总结出:在使用 RPC 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了。

通过上述讲解,我相信你已经非常清楚 RPC 框架的重试机制了,这也是现在大多数 RPC 框架所采用的重试机制。那看到这儿,你觉得这个机制完善了吗?有没有想到连续重试对请求超时时间的影响?继续考虑这样一个场景:我把调用端的请求超时时间设置为 5s,结果连续重试 3 次,每次都耗时 2s,那最终这个请求的耗时是 6s,那这样的话,调用端设置的超时时间是不是就不准确了呢?

如何在约定时间内安全可靠地重试?

我们说过,连续的异常重试可能会出现一种不可靠的情况,那就是连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。解决这个问题最直接的方式就是,在每次重试后都重置一下请求的超时时间。

当调用端发起 RPC 请求时,如果发送请求发生异常并触发了异常重试,我们可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。那么解决了因多次异常重试引发的超时时间失效的问题,这个重试机制是不是就完全可靠了呢?

我们接着考虑,当调用端设置了异常重试策略,发起了一次 RPC 调用,通过负载均衡选择了节点,将请求消息发送到这个节点,这时这个节点由于负载压力较大,导致这个请求处理失败了,调用端触发了重试,再次通过负载均衡选择了一个节点,结果恰好仍选择了这个节点,那么在这种情况下,重试的效果是否受影响了呢?当然有影响,因此我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。

现在再重新完整地回顾一下,除了考虑业务逻辑必须是幂等的、超时时间需要重置以及去掉有问题的服务节点后,这样的异常重试机制还有没有可优化的地方呢?

我们刚才说过,RPC 框架的异常重试机制,是调用端发送请求之后,如果发送失败会捕获异常,触发重试,但并不是所有的异常都会触发重试的,只有 RPC 框架中特定的异常才会如此,比如连接异常、超时异常,而像服务端业务逻辑中抛回给调用端的异常是不能重试的。但可以想一下这种情况:服务端的业务逻辑抛给调用端一个异常信息,而服务端抛出这个异常是允许调用端重新发起一次调用的。比如这个场景:服务端的业务逻辑是对数据库某个数据的更新操作,更新失败则抛出个更新失败的异常,调用端可以再次调用,来触发服务端重新执行更新操作。那这个时候对于调用端来说,它接收到了更新失败异常,虽然是服务端抛回来的业务异常,但也是可以进行重试的。

那么在这种情况下,RPC 框架的重试机制需要怎么优化呢?

RPC 框架是不会知道哪些业务异常能够去进行异常重试的,我们可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,我们就可以采用这样的异常处理策略。如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,我们就允许对这个请求进行重试。

所有可能出现的问题,我们排查了一圈下来之后,一个可靠的重试机制就诞生了,如下图所示:

总结

本节我们讲解了 RPC 框架的重试机制,还有如何在约定时间内进行安全可靠地重试。这个机制是当调用端发起的请求失败时,如果配置了异常重试策略,RPC 框架会捕捉异常,对异常进行判定,符合条件则进行重试,重新发送请求。

在重试的过程中,为了能够在约定的时间内进行安全可靠地重试,在每次触发重试之前,我们需要先判定下这个请求是否已经超时,如果超时了会直接返回超时异常,否则我们需要重置下这个请求的超时时间,防止因多次重试导致这个请求的处理时间超过用户配置的超时时间,从而影响到业务处理的耗时;在重试的过程中,为了能够在约定的时间内进行安全可靠地重试,在每次触发重试之前,我们需要先判定下这个请求是否已经超时,如果超时了会直接返回超时异常,否则我们需要重置下这个请求的超时时间,防止因多次重试导致这个请求的处理时间超过用户配置的超时时间,从而影响到业务处理的耗时;另外,在使用 RPC 框架的重试机制时,我们要确保被调用的服务的业务逻辑是幂等的,这样才能考虑是否使用重试,这一点至关重要。

优雅关闭:如何避免服务停机带来的业务损失?

我们知道,在「单体应用」复杂到一定程度后,我们一般会进行系统拆分,也就是时下流行的微服务架构。服务拆分之后,自然就需要协同,于是 RPC 框架就出来了,它用来解决各个子系统之间的通信问题。

那我再倒回来问你一个非常基础的问题?你觉得系统为啥非要拆分呢?如果只说一个原因,显然是拆分之后我们可以更方便、更快速地迭代业务。那么问题来了,更快速地迭代业务,说人话不就是我会经常更新应用系统,时不时还老要重启服务器吗?那具体到我们的 RPC 体系里,你就要考虑,在重启服务的过程中,RPC 怎么做到让调用方系统不出问题呢?

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

所以在服务重启的时候,对于调用方来说,这时候可能会存在以下几种情况:

  • 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。
  • 调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。

关闭流程

当然还存在目标服务正在启动的情况,如何优雅地启动我们后续会详细地讲,这也是重点。今天我们要聚焦讨论的就是当出现第二种情况的时候,在 RPC 里面怎么避免调用方业务受损。

这时候你可能会想到,我是不是在重启服务机器前,先通过「某种方式」把要下线的机器从调用方维护的「健康列表」里面删除就可以了,这样负载均衡就选不到这个节点了?你说得一点都没错,但这个具体的「某种方式」是怎么完成呢?

首先最没有效率的办法就是人工通知调用方,让他们手动摘除要下线的机器,这种方式很原始也很直接。但这样对于提供方上线的过程来说太繁琐了,每次上线都要通知到所有调用我接口的团队,整个过程既浪费时间又没有意义,显然不能被正常接受。这时候,可能你还会想到,RPC 里面不是有服务发现吗?它的作用不就是用来实时感知服务提供方的状态吗?当服务提供方关闭前,是不是可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除?关闭流程如下图所示:

这样不就可以实现不通过人工的方式,从而达到一种自动化方式,但这么做就能完全保证实现无损上下线吗?

如上图所示,整个关闭过程中依赖了两次 RPC 调用,一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。注册中心通知服务调用方都是异步的,我们在介绍服务发现的时候说过,在大规模集群里面,服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方。所以这么来看,通过服务发现并不能做到应用无损关闭。

不能强依赖「服务发现」来通知调用方要下线的机器,那服务提供方自己来通知行不行?因为在 RPC 里面调用方跟服务提供方之间是长连接,我们可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个去通知调用方去下线这台机器。这样整个调用链路就变短了,对于每个调用方来说就一次 RPC,可以确保调用的成功率很高。大部分场景下,这么做确实没有问题,但线上还是会偶尔会出现,因为服务提供方上线而导致调用失败的问题。

那到底哪里出问题了呢?我后面分析了调用方请求日志跟收到关闭通知的日志,并且发现了一个线索如下:出问题请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭通知的时间早不到 1ms,如果再加上网络传输时间的话,那服务提供方收到请求的时候,它应该正在处理关闭逻辑。这就说明服务提供方关闭的时候,并没有正确处理关闭后接收到的新请求。

优雅关闭

知道了根本原因,问题就很好解决了。因为服务提供方已经开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的。所以我们可以在关闭的时候,设置一个请求「挡板」,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。

如果大家经常去银行办理业务,就会很熟悉这个流程。在交接班或者有其他要事情处理的时候,银行柜台工作人员会拿出一个纸板,放在窗口前,上面写到「该窗口已关闭」。在该窗口排队的人虽然有一万个不愿意,也只能换到其它窗口办理业务,因为柜台工作人员会把当前正在办理的业务处理完后正式关闭窗口。

基于这个思路,我们可以这么处理:当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)。这个异常就是告诉调用方:我已经收到这个请求了,但是我正在关闭,并没有处理这个请求。然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点,这样就可以实现对业务无损。但如果只是靠等待被动调用,就会让这个关闭过程整体有点漫长。因为有的服务方那个时刻没有业务请求,就不能及时地通知调用方了,所以我们可以加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。

说到这里,我知道你肯定会问,那要怎么捕获到关闭事件呢?可以通过捕获操作系统的进程信号来获取,在 RPC 启动的时候,我们提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。

看到这里,感觉问题已经比较好地被解决了。但细心的同学可能还会提出问题,关闭过程中已经在处理的请求会不会受到影响呢?如果进程结束过快会造成这些请求还没有来得及应答,同时调用方会也会抛出异常。为了尽可能地完成正在处理的请求,首先我们要把这些请求识别出来。这就好比日常生活中,我们经常看见停车场指示牌上提示还有多少剩余车位,这个是如何做到的呢?如果仔细观察一下,你就会发现它是每进入一辆车,剩余车位就减一,每出来一辆车,剩余车位就加一。我们也可以利用这个原理在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器我们就可以快速判断是否有正在处理的请求。

服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。但考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,我们可以在整个 ShutdownHook 里面,加上超时时间控制,当超过了指定时间没有结束,则强制退出应用。超时时间我建议可以设定成 10s,基本可以确保请求都处理完了。整个流程如下图所示:

所以在 RPC 里面,关闭虽然看似不属于 RPC 主流程,但如果我们不能处理得很好的话,可能就会导致调用方业务异常,从而需要我们加入很多额外的运维工作。一个好的关闭流程,可以确保使用我们框架的业务实现平滑的上下线,而不用担心重启导致的问题。其实「优雅关闭」这个概念除了在 RPC 里面有,在很多框架里面也都挺常见的,比如像我们经常用的应用容器框架 Tomcat。Tomcat 关闭的时候也是先从外层到里层逐层进行关闭,先保证不接收新请求,然后再处理关闭前收到的请求。

优雅启动:如何避免流量打到没有启动完成的节点?

我们介绍了优雅停机,就是为了让服务提供方在停机应用的时候,保证所有调用方都能「安全」地切走流量,不再调用自己,从而做到对业务无损。其中实现的关键点就在于,让正在停机的服务提供方应用有状态,让调用方感知到服务提供方正在停机。优雅停机介绍完了,该聊聊优雅启动了。

是不是很诧异?应用启动居然也要这么「讲究」吗?这就好比我们日常生活中的热车,行驶之前让发动机空跑一会,可以让汽车的各个部件都热起来,减小磨损。换到应用上来看,原理也是一样的。运行了一段时间后的应用,执行速度会比刚启动的应用更快,因为会将一些高频使用的临时数据放在缓存中。但是这些临时数据会在我们应用重启后就消失,重启后的这些「红利」没有了之后,如果让我们刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。

我们说过在微服务架构里面,上线肯定是频繁发生的,那我们总不能因为上线,就让过来的请求出现大面积超时吧?所以我们得想点办法。既然问题的关键是在于「刚重启的服务提供方因为没有预跑就承担了大流量」,那我们是不是可以通过某些方法,让应用一开始只接少许流量呢?这样低功率运行一段时间后,再逐渐提升至最佳状态。这就是接下来要分享的重点,RPC 里面的一个实用功能:启动预热。

启动预热

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

那在 RPC 里面,我们该怎么实现这个功能呢?

我们现在是要控制调用方发送到服务提供方的流量,我们可以先简单地回顾下调用方发起的 RPC 调用流程是怎样的,调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接。那这样的话,我们是不是就可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用?对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。

现在方案有了,我们就可以考虑具体实现了

首先对于调用方来说,我们要知道服务提供方启动的时间,这个怎么获取呢?这里给出两种方法,一种是服务提供方在启动的时候,把自己启动的时间告诉注册中心;另外一种就是注册中心收到的服务提供方的请求注册时间。这两个时间都可以,不过可能你会犹豫我们该怎么确保所有机器的日期时间是一样的?这其实不用太关心,因为整个预热过程的时间是一个粗略值,即使机器之间的日期时间存在 1 分钟的误差也不影响,并且在真实环境中机器都会默认开启 NTP 时间同步功能,来保证所有机器时间的一致性。

不管你是选择哪个时间,最终的结果就是,调用方通过服务发现,除了可以拿到 IP 列表,还可以拿到对应的启动时间。我们需要把这个时间作用在负载均衡上,之前我们介绍过一种基于权重的负载均衡,但是这个权重是由服务提供方设置的,属于一个固定状态。现在我们要让这个权重变成动态的,并且是随着时间的推移慢慢增加到服务提供方设定的固定值,整个过程如下图所示:

通过这个小逻辑的改动,我们就可以保证当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处于高负载状态,从而实现服务提供方在启动后有一个预热的过程。

看到这儿,你可能还会有另外一个疑问,就是当我在大批量重启服务提供方的时候,会不会导致没有重启的机器因为扛的流量太大而出现问题?

关于这个问题,我是这么考虑的。当你大批量重启服务提供方的时候,对于调用方来说,这些刚重启的机器权重基本是一样的,也就是说这些机器被选中的概率是一样的,大家都是一样得低,也就不存在权重区分的问题了。但是对于那些没有重启过的应用提供方来说,它们被负载均衡选中的概率是相对较高的,但是我们可以通过自适应负载的方法平缓地切换,所以也是没有问题的。

启动预热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常水平,从而实现平滑上线。但对于服务提供方本身来说,有没有相关方案可以实现这种效果呢?当然有,这也是要分享的另一个重点,和热启动息息相关,那就是延迟暴露。

延迟暴露

服务提供方应用在没有启动完成的时候,调用方的请求就过来了,而调用方请求过来的原因是,服务提供方应用在启动过程中把解析到的 RPC 服务注册到了注册中心,这就导致在后续加载没有完成的情况下服务提供方的地址就被服务调用方感知到了。这样的话,其实我们就可以把接口注册到注册中心的时间挪到应用启动完成后。这样是可以保证应用在启动完后才开始接入流量的,但其实这样做,我们还是没有实现最开始的目标。因为这时候应用虽然启动完成了,但并没有执行相关的业务代码,所以还没缓存相关数据。如果这时候大量请求过来,还是会导致整个应用在高负载模式下运行,从而导致不能及时地返回请求结果。而且在实际业务中,一个服务的内部业务逻辑一般会依赖其它资源的,比如缓存数据。如果我们能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后才去加载,我们就可以降低重启后第一次请求出错的概率。

我们还是需要利用服务提供方把接口注册到注册中心的那段时间,我们可以在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。整个应用启动过程如下图所示:

到此我们就已经把整个 RPC 里面的启停机流程都讲完了,就像前面说过的那样,虽然启停机流程看起来不属于 RPC 主流程,但是如果你能在 RPC 里面把这些「微小」的工作做好,就可以让你的技术团队感受到更多的微服务带来的好处。另外我们今天的两大重点:启动预热与延迟暴露,它们并不是 RPC 的专属功能,我们在开发其它系统时,也可以利用这两点来减少冷启动对业务的影响。

熔断限流:业务如何实现自我保护?

我们说过 RPC 是解决分布式系统通信问题的一大利器,而分布式系统的一大特点就是高并发,所以说 RPC 也会面临高并发的场景。在这样的情况下,我们提供服务的每个服务节点就都可能由于访问量过大而引起一系列的问题,比如业务处理耗时过长、CPU 飘高、频繁 Full GC 以及服务进程直接宕机等等。但是在生产环境中,我们要保证服务的稳定性和高可用性,这时我们就需要业务进行自我保护,从而保证在高访问量、高并发的场景下,应用系统依然稳定,服务依然高可用。

那么在使用 RPC 时,业务又如何实现自我保护呢?最常见的方式就是限流了,简单有效,但 RPC 框架的自我保护方式可不只有限流,并且 RPC 框架的限流方式可以是多种多样的。我们可以将 RPC 框架拆开来分析,RPC 调用包括服务端和调用端,调用端向服务端发起调用。下面我就分享一下服务端与调用端分别是如何进行自我保护的。

服务端的自我保护

我们先看服务端,举个例子,假如我们要发布一个 RPC 服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高了,我们该如何保护这个节点?

这个问题还是很好解决的,既然负载压力高,那就不让它再接收太多的请求就好了,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了,这就是限流。在 RPC 调用中服务端的自我保护策略就是限流,那你有没有想过我们是如何实现限流的呢?是在服务端的业务逻辑中做限流吗?有没有更优雅的方式?

限流是一个比较通用的功能,我们可以在 RPC 框架中集成限流的功能,让使用方自己去配置限流阈值;我们还可以在服务端添加限流逻辑,当调用端发送请求过来时,服务端在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,就让服务端直接抛回给调用端一个限流异常,否则就执行正常的业务逻辑。

那服务端的限流逻辑又该如何实现呢?其实方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最为常用。上述这几种限流算法我就不一一讲解了,资料很多,不太清楚的话自行查阅下就可以了。

我们可以假设下这样一个场景:我们发布了一个服务,提供给多个应用的调用方去调用,这时有一个应用的调用方发送过来的请求流量要比其它的应用大很多,这时我们就应该对这个应用下的调用端发送过来的请求流量进行限流。所以说我们在做限流的时候要考虑应用级别的维度,甚至是 IP 级别的维度,这样做不仅可以让我们对一个应用下的调用端发送过来的请求流量做限流,还可以对一个 IP 发送过来的请求流量做限流。

这时你可能会想,使用方该如何配置应用维度以及 IP 维度的限流呢?在代码中配置是不是不大方便?之前说过,RPC 框架真正强大的地方在于它的治理功能,而治理功能大多都需要依赖一个注册中心或者配置中心,我们可以通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。看到这儿,你有没有发现,在服务端实现限流,配置的限流阈值是作用在每个服务节点上的。比如说我配置的阈值是每秒 1000 次请求,那么就是指一台机器每秒处理 1000 次请求;如果我的服务集群拥有 10 个服务节点,那么我提供的服务限流阈值在最理想的情况下就是每秒 10000 次。

接着看这样一个场景:我提供了一个服务,而这个服务的业务逻辑依赖的是 MySQL 数据库,由于 MySQL 数据库的性能限制,我们是需要对其进行保护。假如在 MySQL 处理业务逻辑中,SQL 语句的能力是每秒 10000 次,那么我们提供的服务处理的访问量就不能超过每秒 10000 次,而我们的服务有 10 个节点,这时我们配置的限流阈值应该是每秒 1000 次。那如果之后因为某种需求我们对这个服务扩容了呢?扩容到 20 个节点,我们是不是就要把限流阈值调整到每秒 500 次呢?这样操作每次都要自己去计算,重新配置,显然太麻烦了。

我们可以让 RPC 框架自己去计算,当注册中心或配置中心将限流阈值配置下发的时候,我们可以将总服务节点数也下发给服务节点,之后由服务节点自己计算限流阈值,这样就解决问题了吧?其实只是解决了一部分,还有一个问题存在,那就是在实际情况下,一个服务节点所接收到的访问量并不是绝对均匀的,比如有 20 个节点,而每个节点限流的阈值是 500,其中有的节点访问量已经达到阈值了,但有的节点可能在这一秒内的访问量是 450,这时调用端发送过来的总调用量还没有达到 10000 次,但可能也会被限流,这样是不是就不精确了?那有没有比较精确的限流方式呢?

刚才讲解的限流方式之所以不精确,是因为限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。我们可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。我们甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。

这种限流方式可以让整个服务集群的限流变得更加精确,但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。至于要选择哪种限流方式,就要结合具体的应用场景进行选择了。

调用端的自我保护

刚才讲解了服务端如何进行自我保护,最简单有效的方式就是限流。那么调用端呢?调用端是否需要自我保护呢?

举个例子,假如我要发布一个服务 B,而服务 B 又依赖服务 C,当一个服务 A 来调用服务 B 时,服务 B 的业务逻辑调用服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 在频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机。

由此可见,服务 B 调用服务 C,服务 C 执行业务逻辑出现异常时,会影响到服务 B,甚至可能会引起服务 B 宕机。这还只是 A->B->C 的情况,试想一下 A->B->C->D->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的。所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断。

熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。在正常情况下,熔断器是关闭的;当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。

了解完熔断机制,你就会发现,在业务逻辑中加入熔断器其实是不够优雅的。那么在 RPC 框架中,我们该如何整合熔断器呢?

熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器,我们可以回想下 RPC 的调用流程:

你觉得在哪个步骤整合熔断器会比较合适呢?我的建议是动态代理,因为在 RPC 调用的流程中,动态代理是 RPC 调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。

总结

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

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

业务分组:如何隔离流量?

我们介绍了 RPC 中常用的保护手段「熔断限流」,熔断是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为;而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为。虽然这两种手段作用的对象不同,但出发点都是为了实现自我保护,所以一旦发生这种行为,业务都是有损的。

而说起突发流量,限流固然是一种手段,但其实面对复杂的业务以及高并发场景时,我们还有别的手段,可以最大限度地保障业务无损,那就是隔离流量。这也是接下来要分享的内容,下面就一起看看分组在 RPC 中的应用。

分什么需要分组?

在我们的日常开发中,我们不都提倡让用户使用起来越简单越好吗?如果在接口上再加一个分组维度去管理,不就让事情变复杂了吗?实则不然,举个例子。在没有汽车的年代,我们的道路很简单,就一条,行人、洋车都在上边走。那随着汽车的普及以及猛增,我们的道路越来越宽,慢慢地有了高速、辅路、人行道等等。很显然,交通网的建设与完善不仅提高了我们的出行效率,而且还更好地保障了我们行人的安全。

同样的道理,我们用在 RPC 治理上也是一样的。假设你是一个服务提供方应用的负责人,在早期业务量不大的情况下,应用之间的调用关系并不会复杂,请求量也不会很大,我们的应用有足够的能力扛住日常的所有流量。我们并不需要花太多的时间去治理调用请求过来的流量,我们通常会选择最简单的方法,就是把服务实例统一管理,把所有的请求都用一个共享的「大池子」来处理。这就类似于「简单道路时期」,服务调用方跟服务提供方之间的调用拓扑如下图所示:

后期因为业务发展丰富了,调用你接口的调用方就会越来越多,流量也会渐渐多起来。可能某一天,一个「爆炸式惊喜」就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降。而这时候作为应用负责人的你,那就得变身「救火队长」了,要想尽各种办法来保证应用的稳定。

在经过一系列的救火操作后,我们肯定要去想更好的应对办法。那回到问题的根本去看,关键就在于,早期为了管理方便,我们把接口都放到了同一个分组下面,所有的服务实例是以一个整体对外提供能力的。但后期因为业务发展,这种粗暴的管理模式已经不适用了,这就好比「汽车来了,我们的交通网也得抓紧建设」一样,让人车分流。此时,道路上的人和车就好比我们应用的调用方,我们可以尝试把应用提供方这个大池子划分出不同规格的小池子,再分配给不同的调用方,而不同小池子之间的隔离带,就是我们在 RPC 里面所说的分组,它可以实现流量隔离。

怎么实现分组?

现在分组是怎么回事我们搞清楚了,那放到 RPC 里我们该怎么实现呢?既然是要求不同的调用方应用能拿到的池子内容不同,那我们就要回想下服务发现了,因为在 RPC 流程里,能影响到调用方获取服务节点的逻辑就是它了。

我们说过服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,那换到这里的话,这样做其实并不合适,因为这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,我们需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。

通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。那怎么分组好呢,有没有统一的标准?坦白讲,这个分组并没有一个可衡量的标准,但这里总结了一个规则可以供你参考,就是按照应用重要级别划分。

非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。比如提供给电商下单过程中用的商品信息接口,我们肯定是需要独立出一个单独分组,避免受其它调用方污染的。有了分组之后,我们的服务调用方跟服务提供方之间的调用拓扑就如下图所示:

通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是我们日常治理服务过程中一个高频使用的手段,那通过这种分组进行流量隔离,对调用方应用会不会有影响呢?

如何实现高可用?

分组隔离后,单个调用方在发 RPC 请求的时候可选择的服务节点数相比没有分组前减少了,那对于单个调用方来说,出错的概率就升高了。比如一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,在这种情况下对于服务调用方来说,它的请求无论如何也到达不了服务提供方,从而导致这个调用方业务受损。

那有没有更高可用一点的方案呢?回到我们前面说的那个马路例子上,正常情况下我们是必须让车在车道行驶,人在人行道上行走。但当人行道或者车道出现抢修的时候,在条件允许的情况下,我们一般都是允许对方借道行驶一段时间,直到道路完全恢复。

我们同样可以把这个特性用到我们的 RPC 中,要怎么实现呢?

在前面我们也说了,调用方应用服务发现的时候,除了带上对应的接口名,还需要带上一个特定分组名,所以对于调用方来说,它是拿不到其它分组的服务节点的,那这样的话调用方就没法建立起连接发请求了。因此问题的核心就变成了调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,否则分组就没有意义了。一个最简单的办法就是,允许调用方可以配置多个分组。但这样的话,这些节点对于调用方来说就都是一样的了,调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义,并且还没有实现我们想要的「借道」的效果。

所以我们还需要把配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用。

总结

以上通过一个道路划分的案例,引出了在 RPC 里面我们可以通过分组的方式人为地给不同的调用方划分出不同的小集群,从而实现调用方流量隔离的效果,保障我们的核心业务不受非核心业务的干扰。但我们在考虑问题的时候,不能顾此失彼,不能因为新加一个的功能而影响到原有系统的稳定性。

其实我们不仅可以通过分组把服务提供方划分成不同规模的小集群,我们还可以利用分组完成一个接口多种实现的功能。正常情况下,为了方便我们自己管理服务,我一般都会建议每个接口完成的功能尽量保证唯一。但在有些特殊场景下,两个接口也会完全一样,只是具体实现上有那么一点不同,那么我们就可以在服务提供方应用里面同时暴露两个相同接口,但只是接口分组不一样罢了。

异步 RPC:压榨单机吞吐量

到目前为止,我们已经了解了 RPC 框架的基础架构和一系列治理功能,以及一些与集群管理相关的高级功能,如服务发现、健康检查、路由策略、负载均衡、优雅启停机等等。有了这些知识储备,你就已经对 RPC 框架有了较为充分的认识。但如果想要更深入地了解 RPC,更好地使用 RPC,我们就必须从 RPC 框架的整体性能上去考虑问题了。得知道如何去提升 RPC 框架的性能、稳定性、安全性、吞吐量,以及如何在分布式的场景下快速定位问题等等,这些都是我们接下来要重点要介绍的内容。

那么接下来就说一说,RPC 框架是如何压榨单机吞吐量的。

如何提升单机吞吐量?

记得之前业务团队反馈过这样一个问题:我们的 TPS 始终上不去,压测的时候 CPU 压到 40%~50% 就再也压不上去了,TPS 也不会提高,问我们这里有没有什么解决方案可以提升业务的吞吐量?之后我是看了下他们服务的业务逻辑,发现他们的业务逻辑在执行较为耗时的业务逻辑的基础上,又同步调用了好几个其它的服务。由于这几个服务的耗时较长,才导致这个服务的业务逻辑耗时也长,CPU 大部分的时间都在等待,并没有得到充分地利用,因此 CPU 的利用率和服务的吞吐量当然上不去了。

那是什么影响到了 RPC 调用的吞吐量呢?

在使用 RPC 的过程中,谈到性能和吞吐量,我们的第一反应就是选择一款高性能、高吞吐量的 RPC 框架,那影响到 RPC 调用的吞吐量的根本原因是什么呢?其实根本原因就是由于处理 RPC 请求比较耗时,并且 CPU 大部分的时间都在等待而没有去计算,从而导致 CPU 的利用率不够。这就好比一个人在干活,但他没有规划好时间,并且有很长一段时间都在闲着,当然也就完不成太多工作了。

那么导致 RPC 请求比较耗时的原因主要是在于 RPC 框架本身吗?事实上除非在网络比较慢或者使用方使用不当的情况下,否则大多数情况,刨除业务逻辑处理的耗时时间,RPC 本身处理请求的效率就算在比较差的情况下也不过是毫秒级的。可以说 RPC 请求的耗时大部分都是业务耗时,比如业务逻辑中有访问数据库执行慢 SQL 的操作。所以说在大多数情况下,影响到 RPC 调用的吞吐量的原因也就是业务逻辑处理慢了,CPU 大部分时间都在等待资源。

弄明白了原因,咱们就可以解决问题了,该如何去提升单机吞吐量?这并不是一个新话题,比如现在我们经常提到的响应式开发,就是为了能够提升业务处理的吞吐量。要提升吞吐量,其实关键就两个字:异步。我们的 RPC 框架要做到完全异步化,实现全异步 RPC。试想一下,如果我们每次发送一个异步请求,发送请求过后请求即刻就结束了,之后业务逻辑全部异步执行,结果异步通知,这样可以增加多么可观的吞吐量?

调用端如何异步?

说到异步,我们最常用的方式就是返回 Future 对象的 Future 方式,或者入参为 Callback 对象的回调方式,而 Future 方式可以说是最简单的一种异步方式了。我们发起一次异步请求并且从请求上下文中拿到一个 Future,之后我们就可以调用 Future 的 get 方法获取结果。

就比如刚才我提到的业务团队的那个问题,他们的业务逻辑中调用了好几个其它的服务,这时如果是同步调用,假设调用了 4 个服务,每个服务耗时 10 毫秒,那么业务逻辑执行完至少要耗时 40 毫秒。那如果采用 Future 方式呢?连续发送 4 次异步请求并且拿到 4 个 Future,由于是异步调用,这段时间的耗时几乎可以忽略不计,之后我们统一调用这几个 Future 的 get 方法。这样一来的话,业务逻辑执行完的时间在理想的情况下是多少毫秒呢?没错,10 毫秒,耗时整整缩短到了原来的四分之一,也就是说,我们的吞吐量有可能提升 4 倍。

那 RPC 框架的 Future 方式异步又该如何实现呢?首先我们知道一次 RPC 调用的本质就是调用端向服务端发送一条请求消息,服务端收到消息后进行处理,处理之后响应给调用端一条响应消息,调用端收到响应消息之后再进行处理,最后将最终的返回值返回给动态代理。

这里我们可以看到,对于调用端来说,向服务端发送请求消息与接收服务端发送过来的响应消息,这两个处理过程是两个完全独立的过程,这两个过程甚至在大多数情况下都不在一个线程中进行。那么是不是说 RPC 框架的调用端,对于 RPC 调用的处理逻辑,内部实现就是异步的呢?不错,对于 RPC 框架,无论是同步调用还是异步调用,调用端的内部实现都是异步的。

我们知道调用端发送的每条消息都一个唯一的消息标识,实际上调用端向服务端发送请求消息之前会先创建一个 Future,并会存储这个消息标识与这个 Future 的映射,动态代理所获得的返回值最终就是从这个 Future 中获取的;当收到服务端响应的消息时,调用端会根据响应消息的唯一标识,通过之前存储的映射找到对应的 Future,将结果注入给那个 Future,再进行一系列的处理逻辑,最后动态代理从 Future 中获得到正确的返回值。

所谓的同步调用,不过是 RPC 框架在调用端的处理逻辑中主动执行了这个 Future 的 get 方法,让动态代理等待返回值;而异步调用则是 RPC 框架没有主动执行这个 Future 的 get 方法,用户可以从请求上下文中得到这个 Future,自己决定什么时候执行这个 Future 的 get 方法。

现在你应该很清楚 RPC 框架是如何实现 Future 方式的异步了。

如何做到 RPC 调用全异步?

Future 方式异步可以说是调用端异步的一种方式,那么服务端呢?服务端是否需要异步,有什么实现方式?

Future 方式异步可以说是调用端异步的一种方式,那么服务端呢?服务端是否需要异步,有什么实现方式?我们知道 RPC 服务端接收到请求的二进制消息之后会根据协议进行拆包解包,之后将完整的消息进行解码并反序列化,获得到入参参数之后再通过反射执行业务逻辑。那你有没有想过,在生产环境中这些操作都在哪个线程中执行呢?是在一个线程中执行吗?

当然不会在一个,对二进制消息数据包拆解包的处理是一定要在处理网络 IO 的线程中,如果网络通信框架使用的是 Netty 框架,那么对二进制包的处理是在 IO 线程中,而解码与反序列化的过程也往往在 IO 线程中处理,那服务端的业务逻辑呢?也应该在 IO 线程中处理吗?原则上是不应该的,业务逻辑应该交给专门的业务线程池处理,以防止由于业务逻辑处理得过慢而影响到网络 IO 的处理。

这时问题就来了,我们配置的业务线程池的线程数都是有限制的,一般只会配置到 200,因为在大多数情况下线程数配置到 200 还不够用就说明业务逻辑该优化了。那么如果碰到特殊的业务场景呢?让配置的业务线程池完全打满了。

碰到这种情况,调大业务线程池的线程数,的确勉强可以解决这个问题,但是对于 RPC 框架来说,往往都会有多个服务共用一个线程池的情况,即使调大业务线程池,比较耗时的服务很可能还会影响到其它的服务。所以最佳的解决办法是能够让业务线程池尽快地释放,那么我们就需要 RPC 框架能够支持服务端业务逻辑异步处理,这对提高服务的吞吐量有很重要的意义。

那服务端如何支持业务逻辑异步呢?

这是个比较难处理的问题,因为服务端执行完业务逻辑之后,要对返回值进行序列化并且编码,将消息响应给调用端,但如果是异步处理,业务逻辑触发异步之后方法就执行完了,来不及将真正的结果进行序列化并编码之后响应给调用端。

这时我们就需要 RPC 框架提供一种回调方式,让业务逻辑可以异步处理,处理完之后调用 RPC 框架的回调接口,将最终的结果通过回调的方式响应给调用端。

总结

影响到 RPC 调用的吞吐量的主要原因就是服务端的业务逻辑比较耗时,并且 CPU 大部分时间都在等待而没有去计算,导致 CPU 利用率不够,而提升单机吞吐量的最好办法就是使用异步 RPC。

RPC 框架的异步策略主要是调用端异步与服务端异步,调用端的异步就是通过 Future 方式实现异步,调用端发起一次异步请求并且从请求上下文中拿到一个 Future,之后通过 Future 的 get 方法获取结果,如果业务逻辑中同时调用多个其它的服务,则可以通过 Future 的方式减少业务逻辑的耗时,提升吞吐量。服务端异步则需要一种回调方式,让业务逻辑可以异步处理,之后调用 RPC 框架提供的回调接口,将最终结果异步通知给调用端。

安全体系:如何建立可靠的安全体系?

说起安全问题,你可能会想到像 SQL 注入、XSS 攻击等恶意攻击行为,还有就是相对更广义的安全,像网络安全、信息安全等,那在 RPC 里面我们说的安全一般指什么呢?我们知道 RPC 是解决应用间互相通信的框架,而应用之间的远程调用过程一般不会暴露在公网,换句话讲就是说 RPC 一般用于解决内部应用之间的通信,而这个「内部」是指应用都部署在同一个大局域网内。相对于公网环境,局域网的隔离性更好,也就相对更安全,所以在 RPC 里面我们很少考虑像数据包篡改、请求伪造等恶意行为。

那在 RPC 里面我们应该关心什么样的安全问题呢?要搞清楚这个问题,我们可以先看一个完整的 RPC 应用流程。

我们一般是先由服务提供方定义好一个接口,并把这个接口的 Jar 包发布到私服上去,然后在项目中去实现这个接口,最后通过 RPC 提供的 API 把这个接口和其对应的实现类完成对外暴露,如果是 Spring 应用的话直接定义成一个 Bean 就好了。到这儿,服务提供方就完成了一个接口的对外发布了。

对于服务调用方来说就更简单了,只要拿到刚才上传到私服上的 Jar 的坐标,就可以把发布到私服的 Jar 引入到项目中来,然后借助 RPC 提供的动态代理功能,服务调用方直接就可以在项目完成 RPC 调用了。理论上确实是这样,当然我相信在公司内部这种不向服务提供方咨询就直接调用的行为很少发生,而且一般真实业务的接口出入参数都不会太简单,这样不经过咨询只靠调用方自己猜测完成调用的工作效率实在太低了。

虽然这种靠猜测调用的概率很小,但是当调用方在其它新业务场景里面要用之前项目中使用过的接口,就很有可能真的不跟服务提供方打招呼就直接调用了。这种行为对于服务提供方来说就很危险了,因为接入了新的调用方就意味着承担的调用量会变大,有时候很有可能新增加的调用量会成为压倒服务提供方的「最后一根稻草」,从而导致服务提供方无法正常提供服务,关键是服务提供方还不知道是被谁给压倒的。

当然你可能会说,这是一个流程问题,我们只要在公司内部规范好调用流程,就可以避免这种问题发生了。确实是这样,我们可以通过流程宣贯让我们所有的研发人员达成一个「君子约定」,就是在应用里面每次要用一个接口的时候必须先向服务提供方进行报备,这样确实能在很大程度上避免这种情况的发生。但就 RPC 本身来说,我们是不是可以提供某种功能来解决这种问题呢?毕竟对于人数众多的团队来说,光靠口头约定的流程并不能彻底杜绝这类问题,依然存在隐患,且不可控。

调用方之间的安全保证

那在 RPC 里面,我们该怎么解决这种问题呢?我们先总结下刚才的问题,根本原因就是服务提供方收到请求后,不知道这次请求是哪个调用方发起的,没法判断这次请求是属于之前打过招呼的调用方还是没有打过招呼的调用方,所以也就没法选择拒绝这次请求还是继续执行。

问题说明白了就好解决了,我们只需要给每个调用方设定一个唯一的身份,每个调用方在调用之前都先来服务提供方这登记下身份,只有登记过的调用方才能继续放行,没有登记过的调用方一律拒绝。这就好比我们平时坐火车,我们拿着身份证去购买火车票,买票成功就类似服务调用方去服务提供方这儿进行登记。当你进站准备上火车的时候,你必须同时出示你的身份证和火车票,这两个就是代表你能上这趟火车的「唯一身份」,只有验证了身份,负责检票的工作人员才会让你上车,否则会直接拒绝你乘车。

现在方案有了,那在 RPC 里面我们该怎么实现呢?

首先我们要有一个可以供调用方进行调用接口登记的地方,我们姑且称这个地方为「授权平台」,调用方可以在授权平台上申请自己应用里面要调用的接口,而服务提供方则可以在授权平台上进行审批,只有服务提供方审批后调用方才能调用。但这只是解决了调用数据收集的问题,并没有完成真正的授权认证功能,缺少一个检票的环节。

既然有了刚搭建的授权平台,而且接口的授权数据也在这个平台上,我们自然就很容易想到是不是可以把这个检票的环节放到这个授权平台上呢?调用方每次发起业务请求的时候先去发一条认证请求到授权平台上,就说:"哥们儿,我能调用这个接口吗?",只有授权平台返回「没问题」后才继续把业务请求发送到服务提供方那去。整个流程如下图所示:

从使用功能的角度来说,目前这种设计是没有问题的,而且整个认证过程对 RPC 使用者来说也是透明的。但有一个问题就是这个授权平台承担了公司内所有 RPC 请求的次数总和,当公司内部 RPC 使用程度高了之后,这个授权平台就会成为一个瓶颈点,而且必须保证超高可用,一旦这个授权平台出现问题,影响的可就是全公司的 RPC 请求了。

可能你会说我们可以改进下,我们是不是不需要把这个认证的逻辑放到业务请求过程中,而是可以把这个认证过程挪到初始化过程中呢?这样确实可以在很大程度上减少授权平台的压力,但本质并没有发生变化,还是一个集中式的授权平台。

我们可以想一个更优雅一点的方案。

其实调用方能不能调用相关接口,是由服务提供方说了算,我服务提供方认为你是可以的,你就肯定能调,那我们是不是就可以把这个检票过程放到服务提供方里面呢?在调用方启动初始化接口的时候,带上授权平台上颁发的身份去服务提供方认证下,当认证通过后就认为这个接口可以调用。

现在新的问题又来了,服务提供方验票的时候对照的数据来自哪儿,我总不能又去请求授权平台吧?否则就又会遇到和前面方案一样的问题。还记得我们加密算法里面有一种叫做不可逆加密算法吗?HMAC 就是其中一种具体实现。服务提供方应用里面放一个用于 HMAC 签名的私钥,在授权平台上用这个私钥为申请调用的调用方应用进行签名,这个签名生成的串就变成了调用方唯一的身份。服务提供方在收到调用方的授权请求之后,我们只要需要验证下这个签名跟调用方应用信息是否对应得上就行了,这样集中式授权的瓶颈也就不存在了。

服务发现也有安全问题?

好,现在我们已经解决了调用方之间的安全认证问题。那在 RPC 里面,我们还有其它的安全问题吗?回到我们上面说的那个完整的 RPC 应用流程里面,服务提供方会把接口 Jar 发布到私服上,以方便调用方能引入到项目中快速完成 RPC 调用,那有没有可能有人拿到你这个 Jar 后,发布出来一个服务提供方呢?这样的后果就是导致调用方通过服务发现拿到的服务提供方 IP 地址集合里面会有那个伪造的提供方。

当然,这种情况相对上面说的调用方未经过咨询就直接调用的概率会小很多,但为了让我们的系统整体更安全,我们也需要在 RPC 里面考虑这种情况。要解决这个问题的根本就是需要把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其它应用也能发布这个接口。那么怎么实现呢?我们之前提到过,服务提供方启动的时候,需要把接口实例在注册中心进行注册登记。我们就可以利用这个流程,注册中心可以在收到服务提供方注册请求的时候,验证下请求过来的应用是否跟接口绑定的应用一样,只有相同才允许注册,否则就返回错误信息给启动的应用,从而避免假冒的服务提供者对外提供错误服务。

所以安全问题在任何一个领域都很重要,但又经常被我们忽视,只有每次出安全事故后,我们才会意识到安全防护的重要性。所以在日常写代码的过程中,我们一定要保持一个严谨的态度,防止细小错误引入线上安全问题。虽然 RPC 经常用于解决内网应用之间的调用,内网环境相对公网也没有那么恶劣,但我们也有必要去建立一套可控的安全体系,去防止一些错误行为。对于 RPC 来说,我们所关心的安全问题不会有公网应用那么复杂,我们只要保证让服务调用方能拿到真实的服务提供方 IP 地址集合,且服务提供方可以管控调用自己的应用就够了。

分布式环境下如何快速定位问题?

我们了解了如何建立可靠的安全体系,关键点就是「鉴权」,我们可以通过统一的鉴权服务动态生成秘钥,提高 RPC 调用的安全性。接下来一起看看 RPC 在分布式环境下如何快速定位问题,只有准确的定位问题,我们才能更好的解决问题。

在此之前,我们先想想,在开发以及生产环境运行的过程中,如果遇见问题,我们是如何定位的?

在开发过程中遇见问题其实很好排查,我们可以用 IDE 在自己本地的开发环境中运行一遍代码,进行 debug,在这个过程中是很容易找到问题的。那换到生产环境,代码在线上运行业务,我们是不能进行 debug 的,这时我们就可以通过打印日志来查看当前的异常日志,这也是最简单有效的一种方式了。事实上,大部分问题的定位我们也是这样做的。

那么如果是在分布式的生产环境中呢?比如下面这个场景:

我们搭建了一个分布式的应用系统,在这个应用系统中,我启动了 4 个子服务,分别是服务 A、服务 B、服务 C 与服务 D,而这 4 个服务的依赖关系是 A->B->C->D,而这些服务又都部署在不同的机器上。在 RPC 调用中,如果服务端的业务逻辑出现了异常,就会把异常抛回给调用端,那么如果现在这个调用链中有一个服务出现了异常,我们该如何定位问题呢?

可能你的第一反应仍然是打印日志,好,那就打印日志吧。假如这时我们发现服务 A 出现了异常,那这个异常有没有可能是因为 B 或 C 或 D 出现了异常抛回来的呢?当然很有可能。那我们怎么确定在整个应用系统中,是哪一个调用步骤出现的问题,以及是在这个步骤中的哪台机器出现的问题呢?我们该在哪台机器上打印日志?而且为了排查问题,如果要打印日志,我们就必须要修改代码,这样的话我们就得重新对服务进行上线。如果这几个服务又恰好是跨团队跨部门的呢?想想我们要面临的沟通成本吧。

所以你看,分布式环境下定位问题的难点就在于,各子应用、子服务间有着复杂的依赖关系,我们有时很难确定是哪个服务的哪个环节出现的问题。简单地通过日志排查问题,就要对每个子应用、子服务逐一进行排查,很难一步到位;若恰好再赶上跨团队跨部门,那不死也得去半条命了。

如何做到快速定位问题?

明白了难点,我们其实就可以有针对性地去攻克它了。有关 RPC 在分布式环境下如何快速定位问题,我给出两个方法,很实用。

方法 1:借助合理封装的异常信息

我们前面说是因为各子应用、子服务间复杂的依赖关系,所以通过日志难定位问题。那我们就想办法通过日志定位到是哪个子应用的子服务出现问题就行了。其实,在 RPC 框架打印的异常信息中,是包括定位异常所需要的异常信息的,比如是哪类异常引起的问题(如序列化问题或网络超时问题),是调用端还是服务端出现的异常,调用端与服务端的 IP 是什么,以及服务接口与服务分组都是什么等等。具体如下图所示:

这样的话,在 A->B->C->D 这个过程中,我们就可以很快地定位到是 C 服务出现了问题,服务接口是 com.demo.CSerivce,调用端 IP 是 192.168.1.2,服务端 IP 是 192.168.1.3,而出现问题的原因就是业务线程池满了。

由此可见,一款优秀的 RPC 框架要对异常进行详细地封装,还要对各类异常进行分类,每类异常都要有明确的异常标识码,并整理成一份简明的文档。使用方可以快速地通过异常标识码在文档中查阅,从而快速定位问题,找到原因;并且异常信息中要包含排查问题时所需要的重要信息,比如服务接口名、服务分组、调用端与服务端的 IP,以及产生异常的原因。总之就是,要让使用方在复杂的分布式应用系统中,根据异常信息快速地定位到问题。

以上是对于 RPC 框架本身的异常来说的,比如序列化异常、响应超时异常、连接异常等等。那服务端业务逻辑的异常呢?服务提供方提供的服务的业务逻辑也要封装自己的业务异常信息,从而让服务调用方也可以通过异常信息快速地定位到问题。

方法 2:借助分布式链路跟踪

无论是 RPC 框架本身,还是服务提供方提供的服务,只要对异常信息进行合理地封装,就可以让我们在分布式环境下定位问题变得更加容易。那这样是不是就满足我们定位问题的需求了呢?我们还是回到前面提过的那个分布式场景:我们搭建了一个分布式的应用系统,它由 4 个子服务组成,4 个服务的依赖关系为 A->B->C->D。

假设这 4 个服务分别由来自不同部门的 4 个同事维护,在 A 调用 B 的时候,维护服务 A 的同事可能是不知道存在服务 C 和服务 D 的,对于服务 A 来说,它的下游服务只有 B 服务,那这时如果服务 C 或服务 D 出现异常,最终在整个链路中将异常抛给 A 了呢?

在这种情况下维护服务 A 的同事该如何定位问题呢?

因为对于 A 来说,它可能是不知道下游存在服务 C 和服务 D 的,所以维护服务 A 的同事会直接联系维护服务 B 的同事,之后维护服务 B 的同事会继续联系下游服务的服务提供方,直到找到问题,可这样做成本很高啊。

现在我们换个思路,其实我们只要知道整个请求的调用链路就可以了。服务 A 调用下游服务 B,服务 B 又调用了 B 依赖的下游服务,如果维护服务 A 的同事能清楚地知道整个调用链路,并且能准确地发现在整个调用链路中是哪个环节出现了问题,那就好了。这就好比我们收发快递,我们可以在平台上看到快递配送的轨迹,实时获知快递在何时到达了哪个站点,这样当我们没有准时地收到快递时,我们马上就能知道快递是在哪里延误了。

在分布式环境下,要想知道服务调用的整个链路,我们可以用「分布式链路跟踪」。先介绍下分布式链路跟踪系统,从字面上理解,分布式链路跟踪就是将一次分布式请求还原为一个完整的调用链路,我们可以在整个调用链路中跟踪到这一次分布式请求的每一个环节的调用情况,比如调用是否成功,返回什么异常,调用的哪个服务节点以及请求耗时等等。

这样如果我们发现服务调用出现问题,通过这个方法,我们就能快速定位问题,哪怕是多个部门合作,也可以一步到位。

紧接着,我们再看看在 RPC 框架中是如何整合分布式链路跟踪的?

分布式链路跟踪有 Trace 与 Span 的概念,什么意思呢,我逐一解释。Trace 代表整个链路,每次分布式都会产生一个 Trace,每个 Trace 都有它的唯一标识即 TraceId,在分布式链路跟踪系统中,就是通过 TraceId 来区分每个 Trace 的。Span 代表了整个链路中的一段链路,也就是说 Trace 是由多个 Span 组成的。在一个 Trace 下,每个 Span 也都有它的唯一标识 SpanId,而 Span 是存在父子关系的。还是以讲过的例子为例子,在 A->B->C->D 的情况下,在整个调用链中,正常情况下会产生 3 个 Span,分别是 Span1(A->B)、Span2(B->C)、Span3(C->D),这时 Span3 的父 Span 就是 Span2,而 Span2 的父 Span 就是 Span1。

Trace 与 Span 的关系如下图所示:

分布式链路跟踪系统的实现方式有很多,但它们都脱离不开我刚才说的 Trace 和 Span,这两点可以说非常重要,掌握了这两个概念,其实你就掌握了大部分实现方式的原理。接着我们看看在 RPC 框架中如何利用这两个概念去整合分布式链路跟踪。

RPC 在整合分布式链路跟踪需要做的最核心的两件事就是「埋点」和「传递」。

所谓「埋点」就是说,分布式链路跟踪系统要想获得一次分布式调用的完整的链路信息,就必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过 RPC 框架对分布式链路跟踪进行埋点。RPC 调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记录一个完整的 Span,而这个链路的源头会记录一个完整的 Trace,最终 Trace 信息会被上报给分布式链路跟踪系统。

那所谓「传递」就是指,上游调用端将 Trace 信息与父 Span 信息传递给下游服务的服务端,由下游触发埋点,对这些信息进行处理,在分布式链路跟踪系统中,每个子 Span 都存有父 Span 的相关信息以及 Trace 的相关信息。

总结

以上就是在分布式环境下如何快速定位问题相关的内容,这里面的难点就是分布式系统有着较为复杂的依赖关系,我们很难判断出是哪个环节出现的问题,而且在大型的分布式系统中,往往会有跨部门、跨团队合作的情况,在排查问题的时候会面临非常高的沟通成本。

为了在分布式环境下能够快速地定位问题,RPC 框架应该对框架自身的异常进行详细地封装,每类异常都要有明确的异常标识码,并将其整理成一份简明的文档,异常信息中要尽量包含服务接口名、服务分组、调用端与服务端的 IP,以及产生异常的原因等信息,这样对于使用方来说就非常便捷了。

另外,服务提供方在提供服务时也要对异常进行封装,以方便上游排查问题。在分布式环境下,我们可以通过分布式链路跟踪来快速定位问题,尤其是在多个部门的合作中,这样做可以一步到位,减少排查问题的时间,降低沟通成本,以最高的效率解决实际问题。

详解时钟轮在 RPC 中的应用

在讲解时钟轮之前,我们先来聊聊定时任务。相信你在开发的过程中,很多场景都会使用到定时任务,在 RPC 框架中也有很多地方会使用到它。就以调用端请求超时的处理逻辑为例,下面我们看一下 RPC 框架是如果处理超时请求的。

在介绍 Future 的时候说过:无论是同步调用还是异步调用,调用端内部实行的都是异步,而调用端在向服务端发送消息之前会创建一个 Future,并存储这个消息标识与这个 Future 的映射,当服务端收到消息并且处理完毕后向调用端发送响应消息,调用端在接收到消息后会根据消息的唯一标识找到这个 Future,并将结果注入给这个 Future。

那在这个过程中,如果服务端没有及时响应消息给调用端呢?调用端该如何处理超时的请求?没错,就是可以利用定时任务。每次创建一个 Future,我们都记录这个 Future 的创建时间与这个 Future 的超时时间,并且有一个定时任务进行检测,当这个 Future 到达超时时间并且没有被处理时,我们就对这个 Future 执行超时逻辑。

那定时任务该如何实现呢?

有种实现方式是这样的,也是最简单的一种。每创建一个 Future 我们都启动一个线程,之后 sleep,到达超时时间就触发请求超时的处理逻辑。这种方式吧,确实简单,在某些场景下也是可以使用的,但弊端也是显而易见的。就像刚才那个 Future 超时处理的例子,如果我们面临的是高并发的请求,单机每秒发送数万次请求,请求超时时间设置的是 5 秒,那我们要创建多少个线程用来执行超时任务呢?超过 10 万个线程,这个数字真的够吓人了。

别急,我们还有另一种实现方式。我们可以用一个线程来处理所有的定时任务,还以刚才那个 Future 超时处理的例子为例。假设我们要启动一个线程,这个线程每隔 100 毫秒会扫描一遍所有的处理 Future 超时的任务,当发现一个 Future 超时了,我们就执行这个任务,对这个 Future 执行超时逻辑。

第二种方式我们用得最多,它也解决了第一种方式线程过多的问题,但其实它也有明显的弊端。同样是高并发的请求,那么扫描任务的线程每隔 100 毫秒要扫描多少个定时任务呢?如果调用端刚好在 1 秒内发送了 1 万次请求,这 1 万次请求要在 5 秒后才会超时,那么那个扫描的线程在这个 5 秒内就会不停地对这 1 万个任务进行扫描遍历,要额外扫描 40 多次(每 100 毫秒扫描一次,5 秒内要扫描近 50 次),很浪费 CPU。

在我们使用定时任务时,它所带来的问题,就是让 CPU 做了很多额外的轮询遍历操作,浪费了 CPU,这种现象在定时任务非常多的情况下,尤其明显。

什么是时钟轮?

这个问题也不难解决,我们只要找到一种方式,减少额外的扫描操作就行了。比如我的一批定时任务是 5 秒之后执行,我在 4.9 秒之后才开始扫描这批定时任务,这样就大大地节省了 CPU。这时我们就可以利用时钟轮的机制了。

先来想想我们生活中的时钟,时钟有时针、分针和秒针,秒针跳动一周之后,也就是跳动 60 个刻度之后,分针跳动 1 次,分针跳动 60 个刻度,时针走动一步。而时钟轮的实现原理就是参考了生活中的时钟跳动的原理:

在时钟轮机制中,有时间槽和时钟轮的概念,时间槽就相当于时钟的刻度,而时钟轮就相当于秒针与分针等跳动的一个周期,我们会将每个任务放到对应的时间槽位上。

时钟轮的运行机制和生活中的时钟也是一样的,每隔固定的单位时间,就会从一个时间槽位跳到下一个时间槽位,这就相当于我们的秒针跳动了一次;时钟轮可以分为多层,下一层时钟轮中每个槽位的单位时间是当前时间轮整个周期的时间,这就相当于 1 分钟等于 60 秒钟;当时钟轮将一个周期的所有槽位都跳动完之后,就会从下一层时钟轮中取出一个槽位的任务,重新分布到当前的时钟轮中,当前时钟轮则从第 0 槽位从新开始跳动,这就相当于下一分钟的第 1 秒。

为了方便你了解时钟轮的运行机制,我们用一个场景例子来模拟下,一起看下这个场景。假设我们的时钟轮有 10 个槽位,而时钟轮一轮的周期是 1 秒,那么我们每个槽位的单位时间就是 100 毫秒,而下一层时间轮的周期就是 10 秒,每个槽位的单位时间也就是 1 秒,并且当前的时钟轮刚初始化完成,也就是第 0 跳,当前在第 0 个槽位。

好,现在我们有 3 个任务,分别是任务 A(90 毫秒之后执行)、任务 B(610 毫秒之后执行)与任务 C(1 秒 610 毫秒之后执行),我们将这 3 个任务添加到时钟轮中,任务 A 被放到第 0 槽位,任务 B 被放到第 6 槽位,任务 C 被放到下一层时间轮的第 1 槽位,如下面这张图所示。

当任务 A 刚被放到时钟轮,就被即刻执行了,因为它被放到了第 0 槽位,而当前时间轮正好跳到第 0 槽位(实际上还没开始跳动,状态为第 0 跳);600 毫秒之后,时间轮已经进行了 6 跳,当前槽位是第 6 槽位,第 6 槽位所有的任务都被取出执行;1 秒钟之后,当前时钟轮的第 9 跳已经跳完,从新开始了第 0 跳,这时下一层时钟轮从第 0 跳跳到了第 1 跳,将第 1 槽位的任务取出,分布到当前的时钟轮中,这时任务 C 从下一层时钟轮中取出并放到当前时钟轮的第 6 槽位;1 秒 600 毫秒之后,任务 C 被执行。

看完了这个场景,相信你对时钟轮的机制已经有所了解了。在这个例子中,时钟轮的扫描周期仍是 100 毫秒,但是其中的任务并没有被过多的重复扫描,它完美地解决了 CPU 浪费的问题。这个机制其实不难理解,但实现起来还是很有难度的,其中要注意的问题也很多。具体的代码实现我们这里不展示,这又是另外一个比较大的话题了。有兴趣的话你可以自行查阅下相关源码,动手实现一下。

时钟轮在 RPC 中的应用

通过刚才对时钟轮的讲解,相信你可以看出,它就是用来执行定时任务的,可以说在 RPC 框架中只要涉及到定时相关的操作,我们就可以使用时钟轮。那么 RPC 框架在哪些功能实现中会用到它呢?

刚才举例讲到的调用端请求超时处理,这里我们就可以应用到时钟轮,我们每发一次请求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的 CPU。

调用端与服务端启动超时也可以应用到时钟轮,以调用端为例,假设我们想要让应用可以快速地部署,例如 1 分钟内启动,如果超过 1 分钟则启动失败。我们可以在调用端启动时创建一个处理启动超时的定时任务,放到时钟轮里。

除此之外,你还能想到 RPC 框架在哪些地方可以应用到时钟轮吗?还有定时心跳。RPC 框架调用端定时向服务端发送心跳,来维护连接状态,我们可以将心跳的逻辑封装为一个心跳任务,放到时钟轮里。

这时你可能会有一个疑问,心跳是要定时重复执行的,而时钟轮中的任务执行一遍就被移除了,对于这种需要重复执行的定时任务我们该如何处理呢?在定时任务的执行逻辑的最后,我们可以重设这个任务的执行时间,把它重新丢回到时钟轮里。

总结

以上就是时钟轮的机制,以及时钟轮在 RPC 框架中的应用。这个机制很好地解决了定时任务中,因每个任务都创建一个线程,导致的创建过多线程的问题,以及一个线程扫描所有的定时任务,让 CPU 做了很多额外的轮询遍历操作而浪费 CPU 的问题。

时钟轮的实现机制就是模拟现实生活中的时钟,将每个定时任务放到对应的时间槽位上,这样可以减少扫描任务时对其它时间槽位定时任务的额外遍历操作。但在时间轮的使用中,有些问题需要你额外注意:

  • 时间槽位的单位时间越短,时间轮触发任务的时间就越精确。例如时间槽位的单位时间是 10 毫秒,那么执行定时任务的时间误差就在 10 毫秒内,如果是 100 毫秒,那么误差就在 100 毫秒内。
  • 时间轮的槽位越多,那么一个任务被重复扫描的概率就越小,因为只有在多层时钟轮中的任务才会被重复扫描。比如一个时间轮的槽位有 1000 个,一个槽位的单位时间是 10 毫秒,那么下一层时间轮的一个槽位的单位时间就是 10 秒,超过 10 秒的定时任务会被放到下一层时间轮中,也就是只有超过 10 秒的定时任务会被扫描遍历两次,但如果槽位是 10 个,那么超过 100 毫秒的任务,就会被扫描遍历两次。

结合这些特点,我们就可以视具体的业务场景而定,对时钟轮的周期和时间槽数进行设置。在 RPC 框架中,只要涉及到定时任务,我们都可以应用时钟轮,比较典型的就是调用端的超时处理、调用端与服务端的启动超时以及定时心跳等等。

流量回放:保障业务技术升级的神器

了解完时钟轮在 RPC 中的应用,核心原理就一个关键字「分而治之」,我们可以把它用在任何需要高效处理大量定时任务的场景中,最具有代表性的就是在高并发场景下的请求超时检测。那么接下来我们看一个新的主题:流量回放。

如果你经常翻阅一些技术文章的话,可能你会不止一次看到过「流量回放」这个词。这里简单地介绍一下,所谓的流量就是某个时间段内的所有请求,我们通过某种手段把发送到 A 应用的所有请求录制下来,然后把这些请求统一转发到 B 应用,让 B 应用接收到的请求参数跟 A 应用保持一致,从而实现 A 接收到的请求在 B 应用里面重新请求了一遍。整个过程我们称之为「流量回放」。

这就好比今晚有场球赛,但我没空看,但我可以利用视频录播技术把球赛录下来,我随时想看都可以拿出来看,画面是一模一样的。那在系统开发的过程中,回放功能可以用来做什么呢?

流量回放可以做什么?

我个人感觉,在我们日常开发过程中,可以专心致志地写代码、完成业务功能,是件很幸福的事儿,让我比较头疼的是代码开发完成后的测试环节。在团队中,我们经常是多个需求并行开发的,在开发新需求的过程中,我们还可能夹杂着应用的重构和拆分。每到这个时候,我们基本很难做到不改动老逻辑,那只要有改动就有可能会存在考虑不周全的情况。如果你比较严谨的话,那可能在开发完成后,你会把项目里面的 TestCase 都跑一遍,并同时补充新功能的 TestCase,只有所有的 TestCase 都跑通后才能安心。

在代码里面,算小改动的业务需求,这种做法一般不会出问题。但对于大改动的应用,比如应用中很多基础逻辑都被改动过,这时候如果你还是通过已有的 Case 去验证功能的正确性,就很难保证应用上线后不出故障了,毕竟我们靠自己维护的 Case 相对线上运行的真实环境来说还是少了很多。这时候我们会向更专业的 QA 测试人员求助,希望他们能从 QA 角度多加入一些 Case。但因为我们改动代码逻辑影响范围比较大,想要圈定一个比较确定的测试范围又很难,坦白讲这时候相对保险的方式就是 QA 把整个项目都回归测试一遍。这种方式已经是在最大程度上避免上线出问题了,但从概率角度上来讲也不是万无一失的,因为线上不仅环境复杂,而且使用场景也并不好评估,还有就是这种方式耗时也很长。

这就是我认为最让人头疼的原因,靠传统 QA 测试的方式,不仅过程费时,结果也不是完全可靠。那有没有更可靠、更廉价的方案呢?

传统 QA 测试出问题的根本原因就是,因为改造后的应用在上线后出现跟应用上线前不一致的行为。而我们测试的目的就是为了保证改造后的应用跟改造前应用的行为一致,我们测试 Case 也都是在尽力模拟应用在线上的运行行为,但仅通过我们自己的枚举方式维护的 Case 并不能代表线上应用的所有行为。因此最好的方式就是用线上流量来验证,但是直接把新应用上线肯定是不行的,因为一旦新改造的应用存在问题就可能会导致线上调用方业务受损。

我们可以换一种思路,我可以先把线上一段时间内的请求参数和响应结果保存下来,然后把这些请求参数在新改造的应用里重新请求一遍,最后比对一下改造前后的响应结果是否一致,这就间接达到了使用线上流量测试的效果。有了线上的请求参数和响应结果后,我们再结合持续集成过程,就可以让我们改动后的代码随时用线上流量进行验证,这就跟我录制球赛视频一样,只要我想看,我随时都可以拿出来重新看一遍。

RPC 怎么支持流量回放?

那在实际工作中,我们该怎么实现流量回放呢?常见的方案有很多,比如像 TcpCopy、Nginx 等。但在线上环境要使用这些工具的时候,我们还得需要找运维团队帮我们把应用安装到应用实例里面,然后再按照你的需求给配置好才能使用,整个过程繁琐而且总数重复做无用功,那有没有更好的办法呢?尤其是在应用使用了 RPC 的情况下。

在前面我们不止一次说过,RPC 是用来完成应用之间通信的,换句话就是说应用之间的所有请求响应都会经过 RPC。既然所有的请求都会经过 RPC,那么我们在 RPC 里面是不是就可以很方便地拿到每次请求的出入参数?拿到这些出入参数后,我们只要把这些出入参数旁录下来,并把这些旁录结果用异步的方式发送到一个固定的地方保存起来,这样就完成了流量回放里面的录制功能。

有了真实的请求入参之后,剩下的就是怎么把这些请求参数转发到我们要回归测试的应用里面。在 RPC 中,我们把能够接收请求的应用叫做服务提供方,那就是说我们只需要模拟一个应用调用方,把刚才收到的请求参数重新发送一遍到要回归测试的应用里面,然后比对录制拿到的请求结果和新请求的结果,就可以完成请求回放的效果。整个过程如下图所示:

相对其它现成的流量回放方案,我们在 RPC 里面内置流量回放功能,使用起来会更加方便,并且我们还可以做更多定制,比如在线启停、方法级别录制等个性化需求。

总结

保障线上应用的稳定,是我们研发同学每天都在努力耕耘的一件事,不管是通过应用架构升级,还是修复现有问题的方式。实际情况就是我们不仅要保障已有业务的稳定,还需要快速去完成各种新业务的需求,这期间我们的应用代码就会经常发生变化,而发生变化后就可能会引入新的不稳定因素,而且这个过程会一直持续不断发生。

为了保障应用升级后,我们的业务行为还能保持和升级前一样,我们在大多数情况下都是依靠已有的 TestCase 去验证,但这种方式在一定程度上并不是完全可靠的。最可靠的方式就是引入线上 Case 去验证改造后的应用,把线上的真实流量在改造后的应用里面进行回放,这样不仅节省整个上线时间,还能弥补手动维护 Case 存在的缺陷。

应用引入了 RPC 后,所有的请求流量都会被 RPC 接管,所以我们可以很自然地在 RPC 里面支持流量回放功能。虽然这个功能本身并不是 RPC 的核心功能,但对于使用 RPC 的人来说,他们有了这个功能之后,就可以更放心地升级自己的应用了。

动态分组:超高效实现秒级扩缩容

了解了在 RPC 里面怎么支持流量回放,应用在引入 RPC 后,所有的请求都会被 RPC 接管,而我们在 RPC 里面引入回放的原因也很简单,就是想通过线上流量来验证改造后应用的正确性,而线上流量相比手动维护 TestCase 的场景更丰富,所以用线上流量进行测试的覆盖率会更广。那么就切入接下来的主题,一起看看动态分组在 RPC 里面的应用。

我们说过在调用方复杂的情况下,如果还是让所有调用方都调用同一个集群的话,很有可能会因为非核心业务的调用量突然增长,而让整个集群变得不可用了,进而让核心业务的调用方受到影响。为了避免这种情况发生,我们需要把整个大集群根据不同的调用方划分出不同的小集群来,从而实现调用方流量隔离的效果,进而保障业务之间不会互相影响。

分组后容量评估

通过人为分组的方式确实能帮服务提供方硬隔离调用方的流量,让不同的调用方拥有自己独享的集群,从而保障各个调用方之间互不影响。但这对于我们服务提供方来说,又带来了一个新的问题,就是我们该给调用方分配多大的集群才合适呢?

其实之前我们也有聊到过这样的问题,就是该怎么划分集群的分组?当然,最理想的情况就是给每个调用方都分配一个独立的分组,但是如果在服务提供方的调用方相对比较多的情况下,对于服务提供方来说要维护这些关系还是比较困难的。因此实际在给集群划分分组的时候,我们一般会选择性地合并一些调用方到同一个分组里。这就需要我们服务提供方考虑该怎么合并,且合并哪些调用方?

因为这个问题并没有统一的标准,所以我当时给的建议就是我们可以按照应用的重要级别来划分,让非核心业务应用跟核心业务应用不要公用一个分组,核心应用之间也最好别用同一个分组。但这只是一个划分集群分组的建议,并没有具体告诉你该如何划分集群大小。换句话就是,你可以按照这个原则去规划设计自己的集群要分多少个组。

按照上面的原则,我们把整个集群从逻辑上分为不同的分组之后,接下来我们要做的事情就是给每个分组分配相应的机器数量。那每个分组对应的机器数量,我们该怎么计算呢?我相信这个问题肯定难不倒你。在这儿我先分享下我们团队常用的做法,我们一般会先通过压测去评估下服务提供方单台机器所能承受的 QPS,然后再计算出每个分组里面的所有调用方的调用总量。有了这两个值之后,我们就能很容易地计算出这个分组所需要的机器数。

通过计算分组内所有调用方 QPS 的方式来算出单个分组内所需的机器数,整体而言还是比较客观准确的。但因为每个调用方的调用量并不是一成不变的,比如商家找个网红做个直播卖货,那就很有可能会导致今天的下单量相对昨天有小幅度的上涨。就是因为这些不确定性因素的存在,所以服务提供方在给调用方做容量评估的时候,通常都会在现有调用量的基础上加一个百分比,而这个百分比多半来自历史经验总结。

总之,就是在我们算每个分组所需要的机器数的时候,需要额外给每个分组增加一些机器,从而让每个小集群有一定的抗压能力,而这个抗压能力取决于给这个集群预留的机器数量。作为服务提供方来说,肯定希望给每个集群预留的机器数越多越好,但现实情况又不允许预留太多,因为这样会增加团队的整体成本。

分组带来的问题

通过给分组预留少量机器的方式,以增加单个集群的抗压能力。一般情况下,这种机制能够运行得很好,但在应对大的突发流量时,就会显得有点捉襟见肘了。因为机器成本的原因,我们给每个分组预留的机器数量都不会太多,所以当突发流量超过预留机器的能力的时候,就会让这个分组的集群处于一个危险状态了。

这时候我们唯一能做的就是给这个分组去扩容新的机器,但临时扩容新机器通常需要一个比较长的时间,而且花的时间越长,业务受影响的范围就越大。

那有没有更便捷一点的方案呢?前面我们说过,我们在给分组做容量评估的时候,通常都会增加了一些富余。换句话就是,除了当前出问题的分组,其它分组的服务提供方在保障自己调用方质量的同时,还是可以额外承担一些流量的。我们可以想办法快速利用这部分已有的能力。

但因为我们实现了流量隔离功能,整个集群被我们划分成了不同的分组,所以当前出问题的调用方并不能把请求发送到其它分组的机器上。那可能你会说,既然临时去申请机器进行扩容时间长,那我能不能把上面说的那些富余的机器直接拿过来,把部署在机器上的应用改成出问题的分组,然后进行重启啊?这样出问题的那个分组的服务提供方机器数就会变多了。

从结果上来看,这样处理确实能够解决问题,但有一个问题就是这样处理的时间还是相对较长的,而且当这个分组的流量恢复后,你还得把临时借过来的机器还回原来的分组。问题分析到这儿,我想说,动态分组就可以派上用场了。

动态分组的应用

上面的问题,其根本原因就是某个分组的调用方流量突增,而这个分组所预留的空间也不能满足当前流量的需求,但是其它分组的服务提供方有足够的富余能力。但这些富余的能力,又被我们的分组进行了强制的隔离,我们又不能抛弃分组功能,否则老问题就要循环起来了。

那这样的话,我们就只能在出问题的时候临时去借用其它分组的部分能力,但通过改分组进行重启应用的方式,不仅操作过程慢,事后还得恢复。因此这种生硬的方式显然并不是很合适。想一下啊,我们改应用分组然后进行重启的目的,就是让出问题的服务调用方能通过服务发现找到更多的服务提供方机器,而服务发现的数据来自注册中心,那我们是不是可以通过修改注册中心的数据来解决呢?我们只要把注册中心里面的部分实例的别名改成我们想要的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合。

举个例子,服务提供方有 3 个服务实例,其中 A 分组有 2 个实例,B 分组有 1 个实例,调用方 1 调用 A 分组,调用方 2 调用 B 分组。我们把 A 分组里面的一个实例分组在注册中心由 A 分组改为 B 分组,经过服务发现影响后,整个调用拓扑就变成了这样:

通过直接修改注册中心数据,我们可以让任何一个分组瞬间拥有不同规模的集群能力。我们不仅可以实现把某个实例的分组名改成另外一个分组名,还可以让某个实例分组名变成多个分组名,这就是我们在动态分组里面最常见的两种动作:追加和替换。

以上就是分组后带来的收益,它可以帮助服务提供方实现调用方的隔离。但是因为调用方流量并不是一成不变的,而且还可能会因为突发事件导致某个分组的流量溢出,而在整个大集群还有富余能力的时候,又因为分组隔离不能为出问题的集群提供帮助。而为了解决这种突发流量的问题,我们提供了一种更高效的方案,可以实现分组的快速扩缩容。事实上我们还可以利用动态分组解决分组后给每个分组预留机器冗余的问题,我们没有必要把所有冗余的机器都分配到分组里面,我们可以把这些预留的机器做成一个共享的池子,从而减少整体预留的实例数量。

posted @ 2020-04-08 10:57  古明地盆  阅读(1383)  评论(0编辑  收藏  举报