其实 RPC 就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证在服务提供方能正确地还原出语义,最终实现像调用本地一样地调用远程的目的。
RPC框架一般包括的功能:传输模块、协议模块、集群模块、Bootstrap模块
以上两个不同的过程,其目的都是为了保证数据在网络中可以正确传输,把这两个处理过程放在架构中的同一个模块,统称为协议模块。
因为在实际的网络传输中,请求数据包可能会因为太大而在数据链路层被拆分为多个数据包进行传输,减少拆分次数可以降低传输时长。
在RPC调用中,当方法调用的参数或者返回值的二进制数据大于某个阈值,就通过压缩框架进行无损压缩,在另一端进行解压缩,保证数据可还原。
有了上述两个模块,屏蔽RPC细节,实现远程调用透明化,就实现了一个单机版(点对点)的RPC框架,还需要增加集群能力,即针对同一个接口有多个服务提供者,它们对于调用方都是透明的。
但服务发现只是解决了接口和服务提供方地址映射关系的查找问题,这更多是一种“静态数据”。
有了集群之后,提供方就需要管理好这些服务,因此 RPC 框架就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些常规治理手段。
而服务调用方只需要在每次调用前,根据服务提供方设置的规则,从集群中选择可用的连接用于发送请求。
至此,一个比较完善的 RPC 框架基本就完成了,功能也差不多了。
按照分层设计的原则,将这些功能模块分为了四层,具体内容见图示:
在 RPC 框架里面,支持插件化架构,可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。
加上了插件功能之后,RPC 框架就包含了两大核心体系——核心功能体系与插件体系,如下图所示:
这时,整个架构就变成了一个微内核架构,相比之前的架构,有很多优势。
软件开发的过程复杂,不仅因为业务需求经常变化,更难的是在开发过程中要保证团队成员的目标统一。需要用一种可沟通的话语(领域驱动设计中的概念就是通用语言)、可“触摸”的愿景达成目标,这就是软件架构设计的意义。
仅从功能角度设计出的软件架构并不够健壮,系统不仅要能正确地运行,还要以最低的成本进行可持续的维护,因此十分有必要关注系统的可扩展性。只有这样,才能满足业务变化的需求,让系统的生命力不断延伸。
为了高可用,在生产环境中服务提供方以集群的方式对外提供服务,集群里面的 IP 随时可能变化,需要用一本“通信录”及时获取到对应的服务节点,这个过程叫作“服务发现”。
服务发现的本质,完成接口跟服务提供者 IP 的映射。
用DNS实现服务发现,所有的服务提供者节点都配置在同一个域名下,调用方可以通过DNS拿到随机的一个服务提供者IP,并建立长连接。但是对于服务提供者的摘除和扩容不够及时,为了提升性能和减少DNS服务器的压力,DNS采用多级缓存机制,且缓存时间较长,所以服务调用者不能及时感知到服务节点的变化。
用负载均衡设备实现服务发现,将域名绑定到负载均衡设备上,通过 DNS 拿到负载均衡的 IP。服务调用方直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发。但是,负载均衡设备或四层代理需要额外成本,请求流量多经过一次网络传输有性能损失,负载均衡设备增删需要手动操作有较大延迟,所以这个方式不够灵活。
基于Zookeeper
、etcd
、consul
实现服务发现,能够完成实时变更推送。
在出现集中大规模服务上线的情况时,
Zookeeper
集群的CPU飙升,导致集群不能正常工作。因为,当连接到Zookeeper
的节点数很多时,读写特别频繁,当存储目录达到一定数量时,集群就不稳定,CPU飙升导致宕机。
基于消息总线的最终一致性实现服务发现。
Zookeeper的问题主要是强一致性(CP)导致的,换成最终一致性(AP),来获得服务发现的稳定性和性能。
服务上线的注册数据全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图所示:
服务发现的特性是允许在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。
集群中,每次发请求前,RPC 框架会根据路由和负载均衡算法选择一个具体的 IP 地址。为了保证请求成功,就需要确保每次选择出来的 IP 对应的连接是健康的。
怎么保证选择出来的连接一定是可用,终极的解决方案是让调用方实时感知到节点的状态变化。
服务健康状况不仅包括TCP连接状况,还包括服务自身是否存活,很多时候TCP连接没有断开,但服务可能已经僵死。
服务方的状态一般三种情况:
节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化。
一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个阈值,比如 3 次。
注意点:
因此,除了心跳检测还可以加上业务请求的维度。比如,增加可用率,即一个时间窗口内接口调用成功次数的百分比(成功次数/总调用次数)。
当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。
在设计健康检测方案的时候,不能简单地从 TCP 连接是否健康、心跳是否正常等简单维度考虑,因为健康检测的目的就是要保证“业务无损”。正常情况下,我们大概 30S 会发一次心跳请求。
让每个应用实例提供一个“健康检测”的 URL,检测程序定时通过构造 HTTP 请求访问该 URL,然后根据响应结果来进行存活判断,这样就可以防止僵死状态的误判。
服务提供方以集群的方式对外提供服务,可以认为集群中的节点都是同质的,即请求无论发送到集合中的哪个节点上,返回的结果都是一样的。
在上线新功能时,一般会采用灰度发布,但这种方式不好的一点就是,一旦出现问题,影响范围较大。对于服务提供方来说,同时提供给很多调用方来调用,尤其是一些基础服务的调用方会更复杂,一旦刚上线的实例有问题,将会导致所有的调用方业务都会受损。
在 RPC 发起真实请求时,从服务提供方节点集合里面选择一个合适的节点(负载均衡),在选择节点前加上“筛选逻辑”,把符合要求的节点筛选出来。
所以RPC调用的流程就变成了这样:
有了 IP 路由之后,上线过程中可以做到只让部分调用方请求调用到新上线的实例,相对传统的灰度发布,这样可以把试错成本降到最低。但在有些场景下,如百分比流量切换,需要更细粒度的路由方式。
在流量切换的过程中,为了保证整个流程的完整性,必须保证某个主题对象的所有请求都使用同一种应用来承接。
假设改造的是商品应用,那主题对象肯定是商品 ID,在切流量的过程中,必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。
IP路由并不能满足这个需求,它只能限制调用方来源,并不会更具请求参数请求到预设的服务提供方。
可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,根据请求参数,来判断当前请求是过滤掉新应用还是老应用的节点。
规则对所有的调用方都是一样的,从而保证对应同主题的请求要么是新应用的节点,要么是老应用的节点。
相比 IP 路由,参数路由支持的灰度粒度更小,为服务提供方应用提供了另外一个服务治理的手段。
灰度发布功能是 RPC 路由功能的一个典型应用场景,通过 RPC 路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。
负载均衡主要分为软负载和硬负载,
负载均衡的算法主要有随机法、轮询法、最小连接法等。
以上负载均衡主要还是应用在 Web 服务上,Web 服务的域名绑定负载均衡的地址,通过负载均衡将用户的请求分发到一个个后端服务上。
在RPC中不是依赖一个负载均衡设备或者负载均衡服务器,具体原因看上面服务发现一节,而是由 RPC 框架本身实现。
而且在服务治理时,针对不同接口服务、服务的不同分组,负载均衡策略是需要可配的,经过一个负载均衡设备,就不容易根据不同的场景来配置不同的负载均衡策略。
RPC 的负载均衡完全由 RPC 框架自身实现,RPC 的服务调用者会与“注册中心”下发的所有服务节点建立长连接,在每次发起 RPC 调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起 RPC 调用请求。
RPC 负载均衡策略一般包括随机权重、Hash、轮询。随机权重是最常用的一种,基本可以保证每个节点接收到的请求流量是均匀的;同时可以通过控制节点权重的方式,来进行流量控制。
RPC 的负载均衡完全由 RPC 框架自身实现,服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。只要调用者知道每个服务节点处理请求的能力,以此来判断要打给它多少流量。
采用打分策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如:
为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。
服务调用者给每个服务节点都打完分之后,配合随机权重的负载均衡策略去控制发送的流量,通过最终的指标分数修改服务节点最终的权重。
在某些场景下,希望这些请求能够尽可能地执行成功,当调用端发起的请求失败时,RPC 框架自身进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。
当消息发送失败或收到异常消息时,可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数时,就返回给调用端动态代理一个失败异常,否则就一直重试下去。
RPC 框架的重试机制就是调用端发现请求失败时捕获异常,之后触发重试,但并不是所有的异常都要触发重试,因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等。
在使用 RPC 框架的时候,要确保被调用的服务的业务逻辑是幂等的,这样才能考虑根据事件情况开启 RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区。
连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间,因此,如果发送请求发生异常并触发了异常重试,先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。
同时,在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。
RPC 框架的异常重试机制,是调用端发送请求之后,如果发送失败会捕获异常,触发重试,而像服务端业务逻辑中抛回给调用端的异常是不能重试的。
RPC 框架不知道哪些业务异常能够去进行重试,可以加个重试异常的白名单,用户将允许重试的异常加入到白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,就可以采用这样的异常处理策略。
如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,就允许对这个请求进行重试。
业务逻辑幂等时,异常重试过程:
上述重试过程中要移除失败的节点,同时重试的时间间隔要不断调整,如以2的幂次方增加重试的等待时间。
在服务重启的时候,对于调用方来说,会存在以下几种情况:
为了避免第二种情况对调用方业务产生影响,先通过如下几种方式把要下线的机器从调用方维护的“健康列表”里面删除。
第二种方式,整个关闭过程中依赖了两次 RPC,一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。注册中心通知服务调用方都是异步的,在大规模集群里面,服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方。所以,通过服务发现并不能做到应用无损关闭。
第三种方式,在 RPC 里面调用方跟服务提供方之间是长连接,可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个通知调用方下线这个服务。整个调用链路就一次 RPC,可以确保调用的成功率很高。但是通知时间与关闭服务时间之间的间隔过短,加上网络延迟,调用方还没有处理完,服务已经开始关闭流程了,后续的请求就没有正确被处理。
服务提供方开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的。可以在关闭时,设置一个请求“挡板(一个特定的异常)”告诉调用方已经进入关闭流程,不能再处理这个请求,调用方收到这个异常后,把节点移除,并进行重试,因为这个请求没有被提供方处理过,所以不需要考虑幂等性。
被动响应特定异常的方式,可能会使得关闭过程缓慢,可以增加主动通知机制,这样即保证实时性,又避免通知失败。
在提供方通过捕获操作系统的信号来获取关闭通知。在 RPC 启动时,提前注册关闭钩子(
ShutdownHook
),并在里面添加两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时在调用链里加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。
如果进程结束过快造成正在被处理的请求得不到应答,因此在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器可以快速判断是否有正在处理的请求。
服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。
考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,可以在整个 关闭钩子里面,加上超时时间控制,当超过了指定时间没有结束,则强制退出应用。超时时间建议设定成 10s,基本可以确保请求都处理完了。
整个流程如下图所示。
运行了一段时间后的应用,执行速度会比刚启动的应用更快。如果让刚启动的应用就承担像停机前一样的流量,会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。让应用一开始只接少许流量,低功率运行一段时间后,再逐渐提升至最佳状态。
在 Java 里面,在运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度。
让刚启动的服务提供方应用不承担全部的流量,让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。
通过控制调用方发送到服务提供方的流量来实现,让负载均衡在选择连接的时候,根据服务启动时间进行区分,刚启动的服务被选择的概率特别低,同时,这个概率会随着时间的推移慢慢变大,实现一个动态增加流量的过程。
获取服务提供方的启动时间,有以下两个选择:
集群启动NTP时间同步服务,保证相互之间的时间差不大。
服务调用方通过服务发现获取IP列表和对应的启动时间,把这个时间作用在负载均衡上。
这样可以保证当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处于高负载状态,从而实现服务提供方在启动后有一个预热的过程。
大批量服务重启,可能导致未重启服务受到大流量冲击,因此自适应负载均衡很重要,分批重启更重要,如Kubernetes中服务的重启就是分批次进行的。
服务启动的时候都是通过 main 入口,然后顺序加载各种相关依赖的类。类加载的过程中或触发服务注册的操作,此时调用方获取到服务地址后发起RPC连接,但是类加载可能还没有完成(正在加载其他类),那么调用会失败导致业务受损。
解决方法是把服务注册放在服务启动完成后,从而实现服务调用方延迟获取服务提供方地址。利用服务提供方把接口注册到注册中心的那段时间。
在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。
整个应用启动过程如下图所示:
虽然启停机流程看起来不属于 RPC 主流程,但是如果在 RPC 里面把这些“微小”的工作做好,能感受到更多的微服务带来的好处。
RPC 是解决分布式系统通信问题的利器,而分布式系统的特点就是高并发,所以说 RPC 也会面临高并发的场景。
在这样的情况下,提供服务的每个服务节点都可能由于访问量过大而引起一系列的问题,比如:
但是在生产环境中,要保证服务的稳定性和高可用性,这时就需要业务进行自我保护,从而保证在高访问量、高并发的场景下,应用系统依然稳定,服务依然高可用。
在 RPC 调用中服务端的自我保护策略就是限流,限流是一个通用的功能,可以在 RPC 框架中集成,让使用方自己去配置限流阈值。
在提供方添加限流逻辑,当调用方发送请求时,提供方在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,提供方直接抛回给调用方一个限流异常,否则就执行正常的业务逻辑。
限流的实现方式有很多,比如:
在做限流时要考虑应用级维度,甚至是 IP级维度,这样做不仅可以对一个应用下的调用方发送过来的请求流量做限流,还可以对一个 IP 发送过来的请求流量做限流。通常不在代码中配置,而是通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。
在服务提供方实现限流,配置的限流阈值是作用在每个服务节点上的。
当注册中心或配置中心将限流阈值配置下发时,将总服务节点数也下发给服务节点,之后由服务节点自己计算限流阈值。
这种限流方式存在不精确的问题,因为限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。
可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。
甚至可以将限流逻辑放在调用端,在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求不发送,直接返回给动态代理一个限流异常。
这种限流方式可以让整个服务集群的限流变得更加精确,但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。
在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机。
所以,在一个服务作为调用方调用另外一个服务时,为了防止提供方出现问题而影响到调用方,调用方自己也需要进行自我保护。而最有效的自我保护方式就是熔断。
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。
熔断机制主要是保护调用方,调用方在发出请求的时候会先经过熔断器。在下面的RPC调用流程中,动态代理是第一个关口在这里增加熔断器比较好。
面对复杂的业务以及高并发场景时,还有别的手段,可以最大限度地保障业务无损,那就是隔离流量。
为了实现分组隔离逻辑,需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要加一个分组参数,相应的服务提供方在注册时也要带上分组参数。
通过改造后的分组逻辑,可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。
这个分组并没有可衡量的标准,可以按照应用重要级别(核心应用与非核心应用)划分。
通过分组方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是日常治理服务过程中一个高频使用的手段。
调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,因此,允许调用方可以配置多个分组,同时,把配置的分组区分为主次分组: