影响RPC吞吐量的根本原因就是由于处理 RPC 请求比较耗时,并且 CPU 大部分的时间都在等待而没有去计算,从而导致 CPU 的利用率不够。
除非在网络比较慢或者调用方使用不当的情况下,否则,在大多数情况下,刨除业务逻辑处理的耗时时间,RPC 本身处理请求的效率就算在比较差的情况下也不过是毫秒级的。
RPC 请求的耗时大部分都是业务耗时,比如业务逻辑中有访问数据库执行慢 SQL 的操作。
在大多数情况下,影响到 RPC 调用的吞吐量的原因也就是业务逻辑处理慢了,CPU大部分时间都在等待资源。要提升吞吐量,其实关键就两个字:“异步”。
一次 RPC 调用的本质就是:
对于调用方来说,向提供方发送请求消息与接收提供方发送过来的响应消息,这两个处理过程是两个完全独立的过程,这两个过程甚至在大多数情况下都不在一个线程中进行。
所以,对于 RPC 框架,无论是同步调用还是异步调用,调用方的内部实现都是异步的。
RPC提供方接收到请求的二进制消息之后:
对二进制消息数据包拆解包的处理是在处理网络 IO 的线程中处理,而解码与反序列化的过程也往往在 IO 线程中处理,业务逻辑交给专门的业务线程池处理,以防止由于业务逻辑处理得过慢而影响到网络 IO 的处理。
业务线程池的线程数一般只会配置到 200,因为在大多数情况下线程数配置到 200 还不够用就说明业务逻辑该优化了。
提供方支持业务逻辑异步是个比较难处理的问题,因为提供方执行完业务逻辑之后,要对返回值进行序列化并且编码,将消息响应给调用方,如果是异步处理,业务逻辑触发异步之后方法就执行完了,来不及将真正的结果进行序列化并编码之后响应给调用方。
因此,需要 RPC 框架提供一种回调方式,让业务逻辑可以异步处理,处理完之后调用 RPC 框架的回调接口,将最终的结果通过回调的方式响应给调用方。
RPC 一般用于解决内部局域网应用之间的通信,相对于公网环境,局域网的隔离性更好,相对更安全,所以在 RPC 里面很少考虑像数据包篡改、请求伪造等恶意行为。
服务提供方收到请求后,不知道这次请求是哪个调用方发起的,没法判断这次请求是属于之前打过招呼的调用方还是没有打过招呼的调用方,所以也就没法选择拒绝这次请求还是继续执行。
解决方案:给每个调用方设定一个唯一的身份,每个调用方在调用之前都先来服务提供方登记身份,只有登记过的调用方才能继续放行,没有登记过的调用方一律拒绝。
调用方进行调用接口登记的地方,称为“授权平台”,调用方在授权平台上申请自己应用里面要调用的接口,服务提供方在授权平台上进行审批,只有服务提供方审批后调用方才能调用。这就解决了调用数据收集的问题,还需要授权认证功能。
调用方每次发起业务请求先发一条认证请求到授权平台上,整个流程如下图所示:
授权平台承担所有 RPC 请求,就会成为瓶颈点,而且必须保证高可用,一旦授权平台出现问题,影响所有的 RPC 请求。
服务提供方存放 HMAC(Hash-based Message Authentication Code)
签名的私钥,在授权平台上用私钥为调用方进行签名,生成的串就成了调用方唯一的身份。服务提供方在收到调用方的授权请求之后,只需验证签名跟调用方应用信息是否对应,这样集中式授权的瓶颈也就不存在了。
在调用方启动初始化接口的时候,带上授权平台上颁发的身份去服务提供方认证,当认证通过后就可以调用这个接口。
服务提供方将服务发布后,调用方调用服务后同样发布一个服务,导致调用方通过服务发现拿到的服务提供方 IP 地址集合里面会有那个伪造的提供方。
解决方案:把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其它应用也能发布这个接口。
服务提供方启动时,把接口实例在注册中心进行注册登记。注册中心在收到服务提供方注册请求时,验证请求的应用是否跟接口绑定的应用一样,只有相同才允许注册,否则就返回错误信息给启动的应用,从而避免假冒的服务提供者对外提供错误服务。
在 RPC 框架打印的异常信息中,包括定位异常所需要的异常信息的,比如:
具体如下图所示:
优秀的 RPC 框架要对异常进行详细地封装,还要对各类异常进行分类,每类异常都要有明确的异常标识码,并整理成一份简明的文档。
使用方可以快速地通过异常标识码在文档中查阅,从而快速定位问题,找到原因;并且异常信息中要包含排查问题时所需要的重要信息,比如服务接口名、服务分组、调用端与服务端的 IP,以及产生异常的原因。总之就是,要让使用方在复杂的分布式应用系统中,根据异常信息快速地定位到问题。
以上是对于 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。
RPC 在整合分布式链路跟踪需要做的最核心的两件事就是“埋点”和“传递”。
“埋点”就是分布式链路跟踪系统要想获得一次分布式调用的完整的链路信息,就必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过 RPC 框架对分布式链路跟踪进行埋点。
RPC 调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记录一个完整的
Span
,而这个链路的源头会记录一个完整的Trace
,最终Trace
信息会被上报给分布式链路跟踪系统。
“传递”就是上游调用端将 Trace
信息与父 Span
信息传递给下游服务的服务端,由下游触发埋点,对这些信息进行处理,在分布式链路跟踪系统中,每个子 Span
都存有父 Span
的相关信息以及 Trace
的相关信息。
在使用定时任务时,它所带来的问题,就是让 CPU 做了很多额外的轮询遍历操作,浪费了 CPU,这种现象在定时任务非常多的情况下,尤其明显。
我们要找到一种方式,减少额外的扫描操作。
时钟轮的实现原理就是参考了生活中的时钟跳动的原理。在时钟轮机制中,有时间槽和时钟轮的概念:
时钟轮的运行机制和生活中的时钟也是一样的:
我们每发一次请求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的 CPU。
这个机制很好地解决了定时任务中,因每个任务都创建一个线程,导致的创建过多线程的问题,以及一个线程扫描所有的定时任务,让 CPU 做了很多额外的轮询遍历操作而浪费 CPU 的问题。
在时间轮的使用中,有些问题需要你额外注意:
所谓的流量就是某个时间段内的所有请求,通过某种手段把发送到 A 应用的所有请求录制下来,然后把这些请求统一转发到 B 应用,让 B 应用接收到的请求参数跟 A 应用保持一致,从而实现 A 接收到的请求在 B 应用里面重新请求了一遍。整个过程我们称之为“流量回放”。
RPC 是用来完成应用之间通信的,即应用之间的所有请求响应都会经过 RPC。所以在 RPC 里面可以很方便地拿到每次请求的出入参数,把这些出入参数旁录下来,并把这些旁录结果用异步的方式发送到一个固定的地方保存起来,这样就完成了流量回放里面的录制功能。
有了真实的请求入参之后,剩下的就是怎么把这些请求参数转发到要回归测试的应用里面。
在 RPC 中,只要模拟一个应用调用方,把刚才收到的请求参数重新发送一遍到要回归测试的应用里面,然后比对录制拿到的请求结果和新请求的结果,就可以完成请求回放的效果。整个过程如下图所示:
相对其它现成的流量回放方案,在 RPC 中内置流量回放功能,使用起来会更加方便,还可以做更多定制,比如在线启停、方法级别录制等个性化需求。
某个分组的调用方流量突增,而这个分组所预留的空间也不能满足当前流量的需求,但是其它分组的服务提供方有足够的富余能力,但这些富余的能力,又被分组进行了强制的隔离。这样的话,就只能在出问题的时候临时去借用其它分组的部分能力,但通过改分组进行重启应用的方式,不仅操作过程慢,事后还得恢复。
因此这种生硬的方式显然并不是很合适。
让出问题的服务调用方能通过服务发现找到更多的服务提供方机器,而服务发现的数据来自注册中心,只要把注册中心里面的部分实例的别名改成想要的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合。
通过直接修改注册中心数据,可以让任何一个分组瞬间拥有不同规模的集群能力。
不仅可以实现把某个实例的分组名改成另外一个分组名,还可以让某个实例分组名变成多个分组名,这就是动态分组里面最常见的两种动作——追加和替换。
我们没有必要把所有冗余的机器都分配到分组里面,可以把这些预留的机器做成一个共享池,从而减少整体预留的实例数量。
让调用方在没有服务提供方提供接口 API 的情况下仍然可以发起 RPC 调用的功能,在 RPC 框架中是非常有价值的。 使用泛化调用可以实现这个功能。
所谓的 RPC 调用,本质上就是调用端向服务端发送一条请求消息,服务端接收并处理,之后向调用端发送一条响应消息,调用端处理完响应消息之后,一次 RPC 调用就完成了。
只要调用端将服务端需要知道的信息,如接口名、业务分组名、方法名以及参数信息等封装成请求消息发送给服务端,服务端就能够解析并处理这条请求消息。过程如下图所示:
可以定义一个统一的接口(GenericService),调用端在创建 GenericService 代理时指定真正需要调用的接口的接口名以及分组名,而 GenericService 接口的 $invoke
方法的入参就是方法名以及参数信息。
这个通过统一的 GenericService 接口类生成的动态代理,来实现在没有接口的情况下进行 RPC 调用的功能,称之为泛化调用。
我们可以用泛化调用的方式实现 RPC 调用,但是如果没有服务提供方提供接口 API,就没法得到入参以及返回值的 Class 类,也就不能对入参对象进行正常的序列化。这时面临两个问题:
应用之间的通信都是通过 RPC 来完成的,而能够完成 RPC 通信的工具有很多,比如像 Web Service、Hessian、gRPC 等都可以用来充当 RPC 使用。这些不同的 RPC 框架都是随着互联网技术的发展而慢慢涌现出来的,它们在不同时期会被引入到不同的项目中解决当时应用之间的通信问题,这就导致线上的生成环境中存在各种各样的 RPC 框架。
让应用上线一次就可以完成新老 RPC 的切换,关键就在于要让新的 RPC 能同时支持多种 RPC 调用:
对于服务提供方来说,所要处理的请求关系如下图所示:
要让新的 RPC 同时支持多种 RPC 调用,关键就在于要让新的 RPC 能够原地支持多种协议的请求。
协议的作用是分割二进制数据流。每种协议约定的数据包格式是不一样的,而且每种协议开头都有一个协议编码,一般叫做 magic number
。
当 RPC 收到了数据包后,先解析出 magic number
,就很容易地找到对应协议的数据格式,然后用对应协议的数据格式去解析收到的二进制数据包。
协议解析过程是把一连串的二进制数据变成一个 RPC 内部对象,但这个对象一般是跟协议相关的,所以为了能让 RPC 内部处理起来更加方便,一般都会把这个协议相关的对象转成一个跟协议无关的 RPC 对象。
因为在 RPC 流程中,当服务提供方收到反序列化后的请求时,根据当前请求的参数找到对应接口的实现类去完成真正的方法调用。如果这个请求参数是跟协议相关的话,那后续 RPC 的整个处理逻辑就会变得很复杂。
当完成了真正的方法调用以后,RPC 返回的也是一个跟协议无关的通用对象,所以在真正往调用方写回数据的时候,同样需要完成一个对象转换的逻辑,只不过这时候是把通用对象转成协议相关的对象。
在收发数据包的时候,通过两次转换实现 RPC 内部的处理逻辑跟协议无关,同时保证调用方收到的数据格式跟调用请求过来的数据格式是一样的。整个流程如下图所示: