05 RPC高级

0.1. 异步RPC

  • 调用方利用异步化机制实现并行调用多个服务,以缩短整个调用时间;
  • 提供方利用异步化把业务逻辑放到自定义线程池里面去执行,以提升单机的 OPS。

0.1.1. 吞吐量

影响RPC吞吐量的根本原因就是由于处理 RPC 请求比较耗时,并且 CPU 大部分的时间都在等待而没有去计算,从而导致 CPU 的利用率不够。

除非在网络比较慢或者调用方使用不当的情况下,否则,在大多数情况下,刨除业务逻辑处理的耗时时间,RPC 本身处理请求的效率就算在比较差的情况下也不过是毫秒级的。

RPC 请求的耗时大部分都是业务耗时,比如业务逻辑中有访问数据库执行慢 SQL 的操作。

在大多数情况下,影响到 RPC 调用的吞吐量的原因也就是业务逻辑处理慢了CPU大部分时间都在等待资源。要提升吞吐量,其实关键就两个字:“异步”。

0.1.2. 异步化

0.1.2.1. 调用方

一次 RPC 调用的本质就是:

  1. 调用方向提供方发送一条请求消息
  2. 提供方收到消息后进行处理
  3. 处理之后响应给调用方一条响应消息
  4. 调用方收到响应消息之后再进行处理
  5. 最后将最终的返回值返回给动态代理

对于调用方来说,向提供方发送请求消息与接收提供方发送过来的响应消息,这两个处理过程是两个完全独立的过程,这两个过程甚至在大多数情况下都不在一个线程中进行。

所以,对于 RPC 框架,无论是同步调用还是异步调用,调用方的内部实现都是异步的。

0.1.2.2. 提供方

RPC提供方接收到请求的二进制消息之后:

  1. 根据协议进行拆包解包
  2. 将完整的消息进行解码并反序列化
  3. 获得到入参参数之后再通过反射执行业务逻辑

对二进制消息数据包拆解包的处理是在处理网络 IO 的线程中处理,而解码与反序列化的过程也往往在 IO 线程中处理,业务逻辑交给专门的业务线程池处理,以防止由于业务逻辑处理得过慢而影响到网络 IO 的处理。

业务线程池的线程数一般只会配置到 200,因为在大多数情况下线程数配置到 200 还不够用就说明业务逻辑该优化了。

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

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

0.2. 安全

RPC 一般用于解决内部局域网应用之间的通信,相对于公网环境,局域网的隔离性更好,相对更安全,所以在 RPC 里面很少考虑像数据包篡改、请求伪造等恶意行为。

0.2.1. 调用方之间的安全保证

服务提供方收到请求后,不知道这次请求是哪个调用方发起的,没法判断这次请求是属于之前打过招呼的调用方还是没有打过招呼的调用方,所以也就没法选择拒绝这次请求还是继续执行。

解决方案:给每个调用方设定一个唯一的身份,每个调用方在调用之前都先来服务提供方登记身份,只有登记过的调用方才能继续放行,没有登记过的调用方一律拒绝。

调用方进行调用接口登记的地方,称为“授权平台”,调用方在授权平台上申请自己应用里面要调用的接口,服务提供方在授权平台上进行审批,只有服务提供方审批后调用方才能调用。这就解决了调用数据收集的问题,还需要授权认证功能。

调用方每次发起业务请求先发一条认证请求到授权平台上,整个流程如下图所示:

image

授权平台承担所有 RPC 请求,就会成为瓶颈点,而且必须保证高可用,一旦授权平台出现问题,影响所有的 RPC 请求

服务提供方存放 HMAC(Hash-based Message Authentication Code) 签名的私钥,在授权平台上用私钥为调用方进行签名,生成的串就成了调用方唯一的身份。服务提供方在收到调用方的授权请求之后,只需验证签名跟调用方应用信息是否对应,这样集中式授权的瓶颈也就不存在了。

在调用方启动初始化接口的时候,带上授权平台上颁发的身份去服务提供方认证,当认证通过后就可以调用这个接口。

0.2.2. 提供方之间的安全保证

服务提供方将服务发布后,调用方调用服务后同样发布一个服务,导致调用方通过服务发现拿到的服务提供方 IP 地址集合里面会有那个伪造的提供方。

解决方案:把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其它应用也能发布这个接口。

服务提供方启动时,把接口实例在注册中心进行注册登记。注册中心在收到服务提供方注册请求时,验证请求的应用是否跟接口绑定的应用一样,只有相同才允许注册,否则就返回错误信息给启动的应用,从而避免假冒的服务提供者对外提供错误服务。

0.3. 问题定位

0.3.1. 合理封装异常信息

在 RPC 框架打印的异常信息中,包括定位异常所需要的异常信息的,比如:

  • 是哪类异常引起的问题(如序列化问题或网络超时问题),
  • 是调用端还是服务端出现的异常,调用端与服务端的 IP 是什么,
  • 服务接口与服务分组都是什么等。

具体如下图所示:

image

优秀的 RPC 框架要对异常进行详细地封装,还要对各类异常进行分类,每类异常都要有明确的异常标识码,并整理成一份简明的文档

使用方可以快速地通过异常标识码在文档中查阅,从而快速定位问题,找到原因;并且异常信息中要包含排查问题时所需要的重要信息,比如服务接口名、服务分组、调用端与服务端的 IP,以及产生异常的原因。总之就是,要让使用方在复杂的分布式应用系统中,根据异常信息快速地定位到问题。

以上是对于 RPC 框架本身的异常来说的,比如:

  • 序列化异常、
  • 响应超时异常、
  • 连接异常等等。

服务提供方提供的服务的业务逻辑也要封装自己的业务异常信息,从而让服务调用方也可以通过异常信息快速地定位到问题。

0.3.2. 分布式链路追踪

用“分布式链路跟踪”知道分布式环境下服务调用的整个链路。

分布式链路跟踪就是将一次分布式请求还原为一个完整的调用链路,可以在整个调用链路中跟踪到这一次分布式请求的每一个环节的调用情况,比如调用是否成功,返回什么异常,调用的哪个服务节点以及请求耗时等。

分布式链路跟踪有 TraceSpan 的概念:

  • Trace 代表整个链路,每次分布式调用都会产生一个 Trace,每个 Trace 都有它的唯一标识即 TraceId,在分布式链路跟踪系统中,就是通过 TraceId 来区分每个 Trace 的。
  • Span 代表整个链路中的一段链路,即 Trace 由多个 Span 组成的。在一个 Trace 下,每个 Span 都有它的唯一标识 SpanId,而 Span 是存在父子关系的。

A->B->C->D 的情况下,在整个调用链中,正常情况下会产生 3 个 Span,分别是:

  1. Span1(A->B)
  2. Span2(B->C)
  3. Span3(C->D)

这时 Span3 的父 Span 就是 Span2,而 Span2 的父 Span 就是 Span1。

image

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

“埋点”就是分布式链路跟踪系统要想获得一次分布式调用的完整的链路信息,就必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过 RPC 框架对分布式链路跟踪进行埋点。

RPC 调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记录一个完整的 Span,而这个链路的源头会记录一个完整的 Trace,最终 Trace 信息会被上报给分布式链路跟踪系统。

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

0.4. 时钟轮

0.4.1. 定时任务

  1. 每发生一个请求就启动一个线程,之后sleep,到达超时时间就触发请求超时的处理逻辑。弊端是面临高并发的请求,单机每秒发送数万次请求,请求超时时间设置的是 5 秒,那要创建超过 10 万个线程。
  2. 用一个线程处理定时任务,这个线程每隔 100 毫秒会扫描一遍所有请求的超时任务,当发现一个超时了,就执行这个任务,对这个请求执行超时逻辑。它解决了第一种方式线程过多的问题,但也有明显的弊端,同样是高并发的请求,描任务的线程每隔 100 毫秒要扫描多少个定时任务呢?如果调用端刚好在 1 秒内发送了 1 万次请求,这 1 万次请求要在 5 秒后才会超时,那么那个扫描的线程在这个 5 秒内就会不停地对这 1 万个任务进行扫描遍历,要额外扫描 40 多次(每 100 毫秒扫描一次,5 秒内要扫描近 50 次),很浪费 CPU。

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

0.4.2. 时钟轮

我们要找到一种方式,减少额外的扫描操作。

时钟轮的实现原理就是参考了生活中的时钟跳动的原理。在时钟轮机制中,有时间槽时钟轮的概念:

  • 时间槽就相当于时钟的刻度,
  • 时钟轮就相当于秒针与分针等跳动的一个周期,我们将每个任务放到对应的时间槽位上。

时钟轮的运行机制和生活中的时钟也是一样的:

  1. 每隔固定的单位时间从一个时间槽位跳到下一个时间槽位,相当于秒针跳动一次;
  2. 时钟轮分为多层,下一层时钟轮中每个槽位的单位时间是当前时间轮整个周期的时间,相当于 1 分钟等于 60 秒钟;
  3. 当时钟轮将一个周期的所有槽位都跳动完后,会从下一层时钟轮中取出一个槽位的任务,重新分布到当前的时钟轮中,当前时钟轮则从第 0 槽位从新开始跳动,相当于下一分钟的第 1 秒。

image

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

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

在时间轮的使用中,有些问题需要你额外注意:

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

0.5. 流量回放

所谓的流量就是某个时间段内的所有请求,通过某种手段把发送到 A 应用的所有请求录制下来,然后把这些请求统一转发到 B 应用,让 B 应用接收到的请求参数跟 A 应用保持一致,从而实现 A 接收到的请求在 B 应用里面重新请求了一遍。整个过程我们称之为“流量回放”。

RPC 是用来完成应用之间通信的,即应用之间的所有请求响应都会经过 RPC。所以在 RPC 里面可以很方便地拿到每次请求的出入参数,把这些出入参数旁录下来,并把这些旁录结果用异步的方式发送到一个固定的地方保存起来,这样就完成了流量回放里面的录制功能。

有了真实的请求入参之后,剩下的就是怎么把这些请求参数转发到要回归测试的应用里面。

在 RPC 中,只要模拟一个应用调用方,把刚才收到的请求参数重新发送一遍到要回归测试的应用里面,然后比对录制拿到的请求结果和新请求的结果,就可以完成请求回放的效果。整个过程如下图所示:

image

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

0.6. 动态分组

某个分组的调用方流量突增,而这个分组所预留的空间也不能满足当前流量的需求,但是其它分组的服务提供方有足够的富余能力,但这些富余的能力,又被分组进行了强制的隔离。这样的话,就只能在出问题的时候临时去借用其它分组的部分能力,但通过改分组进行重启应用的方式,不仅操作过程慢,事后还得恢复。

因此这种生硬的方式显然并不是很合适。

让出问题的服务调用方能通过服务发现找到更多的服务提供方机器,而服务发现的数据来自注册中心,只要把注册中心里面的部分实例的别名改成想要的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合。

通过直接修改注册中心数据,可以让任何一个分组瞬间拥有不同规模的集群能力。

不仅可以实现把某个实例的分组名改成另外一个分组名,还可以让某个实例分组名变成多个分组名,这就是动态分组里面最常见的两种动作——追加替换

我们没有必要把所有冗余的机器都分配到分组里面,可以把这些预留的机器做成一个共享池,从而减少整体预留的实例数量。

0.7. 无接口调用RPC

  • 场景一:一个统一的测试平台,让各个业务方在测试平台中通过输入接口、分组名、方法名以及参数值,在线测试自己发布的 RPC 服务。统一的测试平台实际上是作为各个 RPC 服务的调用端,而在 RPC 框架的使用中,调用端是需要依赖服务提供方提供的接口 API 的,而统一测试平台不可能依赖所有服务提供方的接口 API。不能因为每有一个新的服务发布,就去修改平台的代码以及重新上线。这时就需要让调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起 RPC 调用。
  • 场景二:一个轻量级的服务网关,让各个业务方用 HTTP 的方式,通过服务网关调用其它服务。服务网关要作为所有 RPC 服务的调用端,是不能依赖所有服务提供方的接口 API 的,也需要调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起 RPC 调用。

让调用方在没有服务提供方提供接口 API 的情况下仍然可以发起 RPC 调用的功能,在 RPC 框架中是非常有价值的。 使用泛化调用可以实现这个功能。

所谓的 RPC 调用,本质上就是调用端向服务端发送一条请求消息,服务端接收并处理,之后向调用端发送一条响应消息,调用端处理完响应消息之后,一次 RPC 调用就完成了。

只要调用端将服务端需要知道的信息,如接口名、业务分组名、方法名以及参数信息等封装成请求消息发送给服务端,服务端就能够解析并处理这条请求消息。过程如下图所示:

image

可以定义一个统一的接口(GenericService),调用端在创建 GenericService 代理时指定真正需要调用的接口的接口名以及分组名,而 GenericService 接口的 $invoke 方法的入参就是方法名以及参数信息。

这个通过统一的 GenericService 接口类生成的动态代理,来实现在没有接口的情况下进行 RPC 调用的功能,称之为泛化调用

我们可以用泛化调用的方式实现 RPC 调用,但是如果没有服务提供方提供接口 API,就没法得到入参以及返回值的 Class 类,也就不能对入参对象进行正常的序列化。这时面临两个问题:

  1. 调用端不能对入参对象进行正常的序列化,那调用端、服务端在接收到请求消息后,入参对象又该如何序列化与反序列化呢?为泛化调用提供专属的序列化插件,通过这个插件,解决泛化调用中的序列化与反序列化问题。
  2. 调用端的入参对象(params)与返回值应该是什么类型呢?使用 Map 类型的对象,再通过泛化调用专属的序列化方式对这个 Map 对象进行序列化,服务端收到消息后,再通过泛化调用专属的序列化方式将其反序列成对象。

0.8. 兼容多种RPC协议

应用之间的通信都是通过 RPC 来完成的,而能够完成 RPC 通信的工具有很多,比如像 Web Service、Hessian、gRPC 等都可以用来充当 RPC 使用。这些不同的 RPC 框架都是随着互联网技术的发展而慢慢涌现出来的,它们在不同时期会被引入到不同的项目中解决当时应用之间的通信问题,这就导致线上的生成环境中存在各种各样的 RPC 框架。

让应用上线一次就可以完成新老 RPC 的切换,关键就在于要让新的 RPC 能同时支持多种 RPC 调用:

  • 当调用方切换到新的 RPC 之后,调用方和服务提供方之间就可以用新的协议完成调用;
  • 当调用方还是用老的 RPC 调用,调用方和服务提供方之间就继续用老的协议完成调用。

对于服务提供方来说,所要处理的请求关系如下图所示:

image

0.8.1. 优雅处理多协议

要让新的 RPC 同时支持多种 RPC 调用,关键就在于要让新的 RPC 能够原地支持多种协议的请求。

协议的作用是分割二进制数据流。每种协议约定的数据包格式是不一样的,而且每种协议开头都有一个协议编码,一般叫做 magic number

当 RPC 收到了数据包后,先解析出 magic number,就很容易地找到对应协议的数据格式,然后用对应协议的数据格式去解析收到的二进制数据包。

协议解析过程是把一连串的二进制数据变成一个 RPC 内部对象,但这个对象一般是跟协议相关的,所以为了能让 RPC 内部处理起来更加方便,一般都会把这个协议相关的对象转成一个跟协议无关的 RPC 对象。

因为在 RPC 流程中,当服务提供方收到反序列化后的请求时,根据当前请求的参数找到对应接口的实现类去完成真正的方法调用。如果这个请求参数是跟协议相关的话,那后续 RPC 的整个处理逻辑就会变得很复杂。

当完成了真正的方法调用以后,RPC 返回的也是一个跟协议无关的通用对象,所以在真正往调用方写回数据的时候,同样需要完成一个对象转换的逻辑,只不过这时候是把通用对象转成协议相关的对象。

在收发数据包的时候,通过两次转换实现 RPC 内部的处理逻辑跟协议无关,同时保证调用方收到的数据格式跟调用请求过来的数据格式是一样的。整个流程如下图所示:

image

上次修改: 28 May 2020