1 可扩展RPC框架设计图
RPC 本质上是一个远程调用,那肯定就需要通过网络来传输数据,所以采用TCP协议和HTTP协议,这两个模块共同构成了传输层。
请求是调用了远程方法,方法出入参数都是对象数据,我们需要提前把对象转成可传输的二进制,即序列化过程。我们还需要在二进制数据里适当位置增加分隔符号来分隔出不同的请求,所以我们可以把这两个处理过程放在架构中的同一个模块,统称为协议模块。
除此之外,还要在协议模块中加入压缩功能,因为在实际的网络传输过程中,请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题。
RPC架构的目的是让开发人员像调用本地方法一样来调用,所以需要我们在 RPC 里面把这些细节对研发人员进行屏蔽,让他们感觉不到本地调用和远程调用的区别,整体对应上面图里的入口层。
我们还要让架构支持集群功能,在 RPC 里面我们需要在 RPC 里面维护好接口跟服务提供者地址的关系,这样调用方在发起请求的时候才能快速地找到对应的接收地址,即服务发现。
此外 TCP 是有状态协议,所以我们的 RPC框架里面要有连接管理器去维护 TCP 连接的状态。
有了集群之后,提供方需要管理好这些服务了, RPC 就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些治理手段。
为了使RPC架构更灵活,便于以后功能扩展,我们需要考虑插件化架构,我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。
这样一来,我们的设计实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。
2 分模块解析
2.1 服务发现
为了高可用,服务提供方都是以集群的方式对外提供服务,集群里面的这些 IP 随时可能变化,我们也需要用一本通讯录及时获取到对应的服务节点。
对于服务调用方和服务提供方来说,其契约就是接口,相当于“通信录”中的姓名,服务 IP 集合作为通讯录中的地址,从而可以通过接口获取服务 IP 的集合来完成服务的发现。
服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。
服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用。
2.1.1 基于DNS的服务发现
如果基于 DNS 来做服务发现,所有的服务提供者都配置在了同一个域名下,调用方的确可以通过 DNS 拿到随机的一个服务提供者的 IP并与之建立长连接,但由于以下两个原因,导致其并不适用于RPC架构:
如果某个 IP 端口下线了,服务调用者不能及时剔除掉服务节点;
如果对某个服务进行节点的扩容,新上线的服务节点无法及时接收到流量。
因为为了提升性能和减少 DNS 服务的压力,DNS采取了多级缓存机制,一般配置的缓存时间较长,特别是 JVM 的默认缓存是永久有效的,所以服务调用者不能及时感知到服务节点的变化。
2.1.2 基于VIP的服务发现
我们还可以在上面的加一个负载均衡设备,通过 DNS 拿到负载均衡的 IP。这样服务调用的时候,服务调用方就可以直接跟 VIP建立连接,然后由 VIP 机器完成 TCP 转发:
但也由于以下四点导致其并不适用于RPC框架:
搭建负载均衡设备需求额外成本;
请求流量都经过负载均衡设备,多一次网络传输会额外性能;
负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作和延迟;
服务治理需要更灵活的负载均衡策略,目前的负载均衡设备的算法无法满足。
2.1.3 基于 ZooKeeper 的服务发现
搭建一个 ZooKeeper 集群作为注册中心,服务注册的时候只需要服务节点向 ZooKeeper 写入注册信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能
服务端管理平台先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径下再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别存储服务端和调用方的信息。
当服务端发起注册时,会在服务端目录中创建一个临时节点,节点中存储该服务端的注册信息。
当调用端发起订阅时,在调用端目录中创建一个临时节点,节点中存储调用端信息,同时调用端 watch 该服务的服务端目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
当服务端目录下有节点数据发生变更时,ZooKeeper 就会通知给发起订阅的调用端。
2.1.4 基于消息总线的最终一致性的注册中心
ZooKeeper 的一个特点就是强一致性,ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同步更新,这也导致了 ZooKeeper 集群性能上的下降。
而在服务节点刚上线时,调用端是可以容忍在一段时间之后才发现这个新上线的节点的,所以我们可以牺牲掉强制一致性(CP),而选择最终一致性 (AP)来换取整个注册中心集群的性能和稳定性。
所以可以考虑采用消息总线机制。注册数据全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性。
当有服务上线,就会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到的消息在消息回放模块里面回放,只接受大于本地版本号的消息,实现最终一致性。
采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。
如果目标节点已经下线或停止服务,可以在目标节点里面进行校验,如果指定接口服务不存在或正在下线,则会拒绝该请求并安全重试到其它节点。
2.2 健康监测
2.2.1 健康检测的逻辑
业内常用的检测方法就是用心跳机制,调用端每隔一段时间就询问一下服务端的健康状态,一般会有三种情况:
健康状态:建立连接成功,并且心跳探活也一直成功;
亚健康状态:建立连接成功,但是心跳请求连续失败;
死亡状态:建立连接失败。
上面的三种状态会随着心跳的结果来动态变化
初始化时,如果建立连接成功,那就是健康状态,否则就是死亡状态;
如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态;
亚健康状态时,如果连续几次都能正常响应心跳请求,那就可以转回健康状态;
死亡的节点能够重连成功,那它就可以重新被标记为健康状态。
调用端每次发请求的时候,就可以优先从健康列表里面选择一个节点,如果健康列表为空,为了提高可用性,也可以尝试从亚健康列表里面选择一个。
2.2.2 可用率
调用方每个接口的调用频次不一样,有的接口可能 1 秒内调用上百次,有的接口可能半个小时才会调用一次,所以我们不能把简单的把总失败的次数当作判断条件。
服务的接口响应时间也是不一样的,有的接口可能 1ms,有的接口可能是 10s,所以我们也不能把 TPS 至来当作判断条件。
于是我们可以采用可用率这个参数来做为健康监测的指标,计算方式是某一个时间窗口内接口调用成功次数的百分比。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了不同低频的调用接口,也兼顾了接口响应时间不同的问题。
2.2.3 分布式部署
因为检测程序所在的机器和目标机器之间的网络可能还会出现故障,就会产生误判。
解决方法就是把检测程序部署在多个机器里面,分布在不同的机器上。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。
2.3 路由策略
2.3.1 路由策略的意义
在真实环境中的服务端是以一个集群的方式提供服务,每次上线应用的时候都不止一台服务器会运行实例,那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,为了减少这种风险,我们一般会选择灰度发布我们的应用实例。
但线上一旦出现问题,影响范围还是挺大的。因为对于服务提供方来说,服务会同时提供给很多调用方来调用,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损。
但路由策略就可以减少上线变更导致的风险。
2.3.2 路由策略的实现
当我们选择要灰度验证功能的时候,注册中心只会把刚上线的服务 IP 地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个 IP 地址的。
在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(负载均衡),可以在负载均衡前加上筛选逻辑把符合要求的节点筛选出来。
例如:我们要求新上线的节点只允许某个 IP 可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合,最后会过滤出新上线的节点。
上面例子里面的路由策略是我们常见的 IP 路由策略,用于限制可以调用服务提供方的 IP。
2.3.3 参数路由
有些场景下,我们可能还需要更细粒度的路由方式。
例如:在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用。
为了保证整个流程的完整性,我们必须保证某个主题对象的所有请求都使用同一种应用来承接。假设我们改造的是商品应用,主题对象是商品 ID,在切流量的过程中,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。
我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,可以很容易地拿到商品 ID,我们可以根据注册中心下发的规则来判断当前请求是用新应用还是旧应用。
相比 IP 路由,参数路由支持的灰度粒度更小,他为服务提供方应用提供了另外一个服务治理的手段。
2.4 负载均衡
2.4.1 RPC中的负载均衡
RPC 实现的负载均衡所采用的策略与传统的 Web 服务实现负载均衡有所不同。
RPC 的服务调用者会与“注册中心”下发的所有服务节点建立长连接,在每次发起 RPC 调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起 RPC 调用请求。
由于负载均衡机制完全是由 RPC 框架自身实现的,所以它不再需要依赖任何负载均衡设备,自然也不会发生负载均衡设备的单点问题
2.4.2 自适应负载均衡实现
只要调用端知道每个服务节点处理请求的能力,再根据此来判断要打给它多少流量就可以了。
调用端如何判定一个服务节点的处理能力?
可以采用一种打分的策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标、服务节点的状态指标。通过这些指标,计算出一个分数,比如总分 10 分,如果 CPU 负载达到 70%,就减它 3 分。
如何根据这些指标来打分?
可以为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。
如何根据分数去控制给每个服务节点发送多少流量?
可以配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权重。例如给一个服务节点综合打分是 8 分,满分 10 分,服务节点的权重是 100,那么计算后最终权重就是 80(100*80%)。
关键步骤:
添加服务指标收集器插件,默认有运行时状态指标收集器、请求耗时指标收集器。
运行时状态指标收集器收集服务节点 CPU 核数、CPU 负载以及内存等指标,在调用端与服务端的心跳数据中获取。
请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999 等。
可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后调用端会根据策略来选择服务节点。
3 增强架构鲁棒性的手段
3.1 重试机制
3.1.1 RPC 框架的重试机制
调用端在发起 RPC调用时,会经过负载均衡选择一个节点,发送请求。当消息发送失败或收到异常消息时,就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到阈值时,就返回给调用端动态代理一个失败异常。
我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。
注意:我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启RPC 框架的异常重试功能。
3.2 优雅启动
3.2.1 程序启动可能带来的问题
运行了一段时间后的应用,执行速度会比刚启动的应用更快。
因为在 Java 程序运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,从而提升执行速度。
但是这些临时数据在应用重启后就消失了。如果让刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,造成损害。
3.2.2 启动预热
在微服务架构里面,上线肯定是频繁发生的,可以通过某些方法,让应用一开始只承接少许流量,这样低功率运行一段时间后,再逐渐提升至最佳状态。
RPC中实现思路:
要控制调用方发送到服务提供方的流量,可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用,对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。
具体实现:
首先对于调用方来说,我们要知道服务提供方启动的时间,这个问题有两种总解决方案:
服务提供方在启动的时候,把自己启动的时间告诉注册中心;
注册中心收到的服务提供方的请求注册时间。
我们需要把这个时间作用在负载均衡上。基于权重的负载均衡,但是这个权重是由服务提供方设置的,属于一个固定状态。现在我们要让这个权重变成动态的,并且是随着时间的推移慢慢增加到服务提供方设定的固定值,整个过程如下图所示:
这样一来,当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处于高负载状态,从而实现服务提供方在启动后有一个预热的过程。
3.2.3 延迟暴露
启动预热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常水平,从而实现平滑上线。服务端的相关方案就是延迟暴露。
以 Spring 应用启动为例,在加载的过程中,Spring 容器会顺序加载 Spring Bean,如果某个 Bean 是RPC 服务的话,我们不光要把它注册到 SpringBeanFactory 里面去,还要把这个 Bean对应的接口注册到注册中心。注册中心在收到新上线的服务提供方地址的时候,会把这个地址推送到调用方应用内存中;当调用方收到这个服务提供方地址的时候,就会去建立连接发请求。
这时可能存在服务端并未启动完成的情况。
解决方案:
在应用启动加载、解析 Bean 的时候,如果遇到了 RPC 服务的 Bean,只先把这个Bean 注册到 SpringBeanFactory 里面去,而并不把这个 Bean 对应的接口注册到注册中心,只有等应用启动完成后,才把接口注册到注册中心用于服务发现。如果我们能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后才去加载,我们就可以进一步降低重启后第一次请求出错的概率。
具体实现:
可以在服务端启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。
3.3 优雅关闭
3.3.1 硬关闭的问题
快速迭代业务,就需要时不时的重启服务器。
上线的过程中:当服务端要上线的时候,一般是通过部署系统完成实例重启。在这个过程中服务端并不会事先告诉调用方他们需要操作哪些机器。对客户端来说,它也无法预测到服务提供方要对哪些机器重启上线,因此负载均衡就有可能把要正在重启的机器选出来,出现问题。
在服务重启的时候,对于客户端来说,可能两种情况:
调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。
调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。
3.3.2 关闭流程
当出现第二种情况的时候,在RPC 里面应该进行点操作避免客户方业务受损。
思路就是:在重启服务机器前,先通过“某种方式”把要下线的机器从调用方维护的“健康列表”里面删除。当服务端关闭前,可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除。
上图整个关闭过程中发起了两次RPC 调用。注册中心通知服务调用方都是异步的,在大规模集群里面,服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能保证及时的把这次要下线的节点推送到所有的调用方。所以并不能做到应用无损关闭。
3.3.3 优雅关闭
因为服务提供方已经开始进入关闭流程,之后再收到的请求肯定是没法保证能处理的。所以可以在关闭的时候,设置一个请求“挡板”,告诉调用方已经开始进入关闭流程了。
**解决方案:**当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如ShutdownException)。然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点。可以再加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。
怎么捕获到关闭事件?
可以通过捕获操作系统的进程信号来获取,Java 里面对应的是Runtime.addShutdownHook 方法,可以注册关闭的钩子。在RPC 启动的时候,我们提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。
关闭过程中已经在处理的请求会不会受到影响?
可以避免,可以在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器我们就可以快速判断是否有正在处理的请求。
服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。但考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,可以在整个ShutdownHook 里面,加上超时时间控制,当超过了指定时间没有结束,则强制退出应用。
PS:Tomcat 关闭的时候也是先从外层到里层逐层进行关闭,先保证不接收新请求,然后再处理关闭前收到的请求。
3.4 熔断限流
3.4.1 服务端自我保护
服务端的某个节点负载压力过高了,该如何保护这个节点?
在 RPC 调用中服务端的自我保护策略就是限流,既然负载压力高,那就不让它再接收太多的请求就好了,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了。
服务端的限流逻辑如何实现?
方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等。
假设一个场景:发布了一个服务给多个应用的调用方去调用,这时有一个应用的调用方发送过来的请求流量要比其它的应用大很多,这时我们就应该对这个应用进行限流。所以说我们在做限流的时候要考虑应用级别的维度,甚至是 IP 级别的维度。
RPC 框架真正强大的地方在于它的治理功能,我们可以通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。
又一个场景:假如在 MySQL 处理业务逻辑中,SQL 语句的能力是每秒 10000 次,那么我们提供的服务处理的访问量就不能超过每秒 10000 次,而我们的服务有 10 个节点,这时我们配置的限流阈值应该是每秒 1000次。如果之后我们对这个服务扩容到 20 个节点,就要把限流阈值调整到每秒 500 次,这样操作每次都要自己去计算,重新配置,显然太麻烦了。
可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。
这种限流方式可以让整个服务集群的限流变得更加精确,但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有劣势的。
3.4.2 调用端的自我保护
假如我要发布一个服务 B,而服务 B 又依赖服务 C,当一个服务 A 来调用服务B 时,服务 B 的业务逻辑调用服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 在频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机。
在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。最有效的自我保护方式就是熔断。
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。在正常情况下,熔断器是关闭的;当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。
RPC框架中如何整合熔断器?
可以在动态代理处实现,因为在 RPC 调用的流程中,动态代理是 RPC 调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。