RPC 架构设计方法论(完结)
===========================================================================
RPC 本质上是一个远程调用,那肯定就需要通过网络来传输数据,所以采用 TCP 协议和 HTTP 协议,这两个模块共同构成了传输层。
请求是调用了远程方法,方法出入参数都是对象数据,我们需要提前把对象转成可传输的二进制,即序列化过程。我们还需要在二进制数据里适当位置增加分隔符号来分隔出不同的请求,所以我们可以把这两个处理过程放在架构中的同一个模块,统称为协议模块。
除此之外,还要在协议模块中加入压缩功能,因为在实际的网络传输过程中,请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题。
RPC 架构的目的是让开发人员像调用本地方法一样来调用,所以需要我们在 RPC 里面把这些细节对研发人员进行屏蔽,让他们感觉不到本地调用和远程调用的区别,整体对应上面图里的入口层。
我们还要让架构支持集群功能,在 RPC 里面我们需要在 RPC 里面维护好接口跟服务提供者地址的关系,这样调用方在发起请求的时候才能快速地找到对应的接收地址,即服务发现。
此外 TCP 是有状态协议,所以我们的 RPC 框架里面要有连接管理器去维护 TCP 连接的状态。
有了集群之后,提供方需要管理好这些服务了, RPC 就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些治理手段。
为了使 RPC 架构更灵活,便于以后功能扩展,我们需要考虑插件化架构,我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。
这样一来,我们的设计实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。
=====================================================================
为了高可用,服务提供方都是以集群的方式对外提供服务,集群里面的这些 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)来换取整个注册中心集群的性能和稳定性。
所以可以考虑采用消息总线机制。注册数据全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性。
![在这里插入图片描述](h 《一线大厂 Java 面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》无偿开源 威信搜索公众号【编程进阶路】 ttps://img-blog.csdnimg.cn/20210605211741132.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hOVV9Dc2VlX3dqdw==,size_16,color_FFFFFF,t_70#pic_center)
当有服务上线,就会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到的消息在消息回放模块里面回放,只接受大于本地版本号的消息,实现最终一致性。
采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。
如果目标节点已经下线或停止服务,可以在目标节点里面进行校验,如果指定接口服务不存在或正在下线,则会拒绝该请求并安全重试到其它节点。
2.2.1 健康检测的逻辑
业内常用的检测方法就是用心跳机制,调用端每隔一段时间就询问一下服务端的健康状态,一般会有三种情况:
健康状态:建立连接成功,并且心跳探活也一直成功;
亚健康状态:建立连接成功,但是心跳请求连续失败;
死亡状态:建立连接失败。
上面的三种状态会随着心跳的结果来动态变化
初始化时,如果建立连接成功,那就是健康状态,否则就是死亡状态;
如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态;
亚健康状态时,如果连续几次都能正常响应心跳请求,那就可以转回健康状态;
死亡的节点能够重连成功,那它就可以重新被标记为健康状态。
调用端每次发请求的时候,就可以优先从健康列表里面选择一个节点,如果健康列表为空,为了提高可用性,也可以尝试从亚健康列表里面选择一个。
2.2.2 可用率
调用方每个接口的调用频次不一样,有的接口可能 1 秒内调用上百次,有的接口可能半个小时才会调用一次,所以我们不能把简单的把总失败的次数当作判断条件。
服务的接口响应时间也是不一样的,有的接口可能 1ms,有的接口可能是 10s,所以我们也不能把 TPS 至来当作判断条件。
于是我们可以采用可用率这个参数来做为健康监测的指标,计算方式是某一个时间窗口内接口调用成功次数的百分比。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了不同低频的调用接口,也兼顾了接口响应时间不同的问题。
2.2.3 分布式部署
因为检测程序所在的机器和目标机器之间的网络可能还会出现故障,就会产生误判。
解决方法就是把检测程序部署在多个机器里面,分布在不同的机器上。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。
2.3.1 路由策略的意义
在真实环境中的服务端是以一个集群的方式提供服务,每次上线应用的时候都不止一台服务器会运行实例,那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,为了减少这种风险,我们一般会选择灰度发布我们的应用实例。
但线上一旦出现问题,影响范围还是挺大的。因为对于服务提供方来说,服务会同时提供给很多调用方来调用,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损。
但路由策略就可以减少上线变更导致的风险。
2.3.2 路由策略的实现
当我们选择要灰度验证功能的时候,注册中心只会把刚上线的服务 IP 地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个 IP 地址的。
在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(负载均衡),可以在负载均衡前加上筛选逻辑把符合要求的节点筛选出来。
例如:我们要求新上线的节点只允许某个 IP 可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合,最后会过滤出新上线的节点。
上面例子里面的路由策略是我们常见的 IP 路由策略,用于限制可以调用服务提供方的 IP。
2.3.3 参数路由
有些场景下,我们可能还需要更细粒度的路由方式。
例如:在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用。
为了保证整个流程的完整性,我们必须保证某个主题对象的所有请求都使用同一种应用来承接。假设我们改造的是商品应用,主题对象是商品 ID
,在切流量的过程中,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。
我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,可以很容易地拿到商品 ID
,我们可以根据注册中心下发的规则来判断当前请求是用新应用还是旧应用。
相比 IP 路由,参数路由支持的灰度粒度更小,他为服务提供方应用提供了另外一个服务治理的手段。
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.1.1 RPC 框架的重试机制
调用端在发起 RPC 调用时,会经过负载均衡选择一个节点,发送请求。当消息发送失败或收到异常消息时,就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到阈值时,就返回给调用端动态代理一个失败异常。
我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。
注意:我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。
评论