Skip to content

Latest commit

 

History

History
558 lines (295 loc) · 41.8 KB

RPC实战与核心原理-进阶篇.md

File metadata and controls

558 lines (295 loc) · 41.8 KB

目录

07 | 架构设计:设计一个灵活的RPC框架

本节内容:各个组件之间如何完成数据交互

RPC 架构

我们先来看看 RPC 里面都有哪些功能模块。

  • 传输模块:考虑到可靠性,我们一般默认采用 TCP 协议。

  • 协议封装:用户请求的时候是基于方法调用,方法出入参数都是对象数据,需要提前把它转成可传输的二进制。

上面2个模块都是为了保证数据在网络中可以正确传输,我们可以统称为协议模块。

  • 数据压缩:在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,我们可以通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原。

  • Bootstrap 模块:这是 RPC 调用的入口。假设有用到 Spring 的话,我们把一个 RPC 接口定义成一个 Spring Bean,并且这个 Bean 也会统一被 Spring Bean Factory 管理,可以在项目中通过 Spring 依赖注入到方式引用。

到这里,一个单机版的 RPC 框架就完成了,这时它还没有集群能力。

  • 服务发现:在 RPC 里面我们还需要让调用方能快速找到所有的服务提供方,并需要在 RPC 里面维护好接口跟服务提供者地址的关系。

那到这儿,一个比较完善的 RPC 框架基本就完成了,功能也差不多就是这些了。按照分层设计的原则,我将这些功能模块分为了四层,具体内容见图示:

可扩展的架构

那 RPC 架构设计出来就完事了吗?当然不,技术迭代谁都躲不过。所以我们需要插件化架构来解决可扩展问题。

在 RPC 框架里面,我们是怎么支持插件化架构的呢?

我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。

在 Java 里面,JDK 有自带的 SPI(Service Provider Interface)服务发现机制,它可以动态地为某个接口寻找服务实现。使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。

加上了插件功能之后,我们的 RPC 框架就包含了两大核心体系——核心功能体系与插件体系,如下图所示:

这时,整个架构就变成了一个微内核架构。

思考

如何实现按需加载的 SPI ?

答:可参考 SpringBoot 的 Condition 实现。

08 | 服务发现:到底是要 CP 还是 AP?

本节内容:服务发现在超大规模集群的场景下所面临的挑战

CAP 定理,它指出对于一个分布式系统,不可能同时满足以下三点:

  • 一致性(Consistency):等同于所有节点访问同一份最新的数据副本。
  • 可用性(Availability):每次请求都能有响应,但是不保证响应数据最新。
  • 分区容错性(Partition tolerance):系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

为什么需要服务发现?

通过获取服务 IP 的集合来完成接口跟服务提供者 IP 的映射。这就是我要说的 PRC 框架的服务发现机制,如下图所示:

从图中,服务发现可以分为2步:

  1. 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。
  2. 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用。

为什么不使用 DNS?

我们回想下服务发现的本质,就是完成了接口跟服务提供者 IP 的映射。

那我们能不能把服务提供者 IP 统一换成一个域名啊,利用已经成熟的 DNS 机制来实现?

好,先带着这个问题,简单地看下 DNS 的流程:

image-20201120084913757

可以从下面2个问题思考一下:

  • 如果这个 IP 端口下线了,服务调用者能否及时摘除服务节点呢?
  • 如果在之前已经上线了一部分服务节点,这时我突然对这个服务进行扩容,那么新上线的服务节点能否及时接收到流量呢?

这两个问题的答案都是:“不能”。为了提升性能和减少 DNS 服务的压力,DNS 采取了多级缓存机制,一般配置的缓存时间较长,特别是 JVM 的默认缓存是永久有效的,所以说服务调用者不能及时感知到服务节点的变化。

这时你可能会想,我是不是可以加一个负载均衡设备呢?

将域名绑定到这台负载均衡设备上,通过 DNS 拿到负载均衡的 IP。这样服务调用的时候,服务调用方就可以直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发,如下图所示:

这个方案确实能解决 DNS 遇到的一些问题,但在 RPC 场景里面也并不是很合适,原因有以下几点:

  • 搭建负载均衡设备或 TCP/IP 四层代理,需要额外成本;
  • 请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费些性能;
  • 负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作和生效延迟;
  • 负载均衡设备的算法满足不了灵活的需求;

由此可见,DNS 或者 VIP 方案虽然可以充当服务发现的角色,但在 RPC 场景里面直接用还是很难的。

基于 ZooKeeper 的服务发现

整体的思路很简单,就是搭建一个 ZooKeeper 集群作为注册中心集群,服务注册的时候只需要向 ZooKeeper 节点写入注册信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能,整体流程如下图:

  1. 服务平台管理端先在 ZooKeeper 中创建2个路径,/service/com.demo.xxService/provider/service/com.demo.xxService/consumer,分别用来存储服务提供方的节点信息和服务调用方的节点信息。
  2. 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
  3. 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方 watch 服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
  4. 当服务提供方目录下有节点数据发生变更时,ZooKeeper 就会通知给发起订阅的服务调用方。

(下面是作者遇到的问题与解决的方案)

我所在的技术团队早期使用的 RPC 框架服务发现就是基于 ZooKeeper 实现的,并且还平稳运行了一年多,但后续团队的微服务化程度越来越高之后,ZooKeeper 集群整体压力也越来越高,尤其在集中上线的时候越发明显。“集中爆发”是在一次大规模上线的时候,当时有超大批量的服务节点在同时发起注册操作,ZooKeeper 集群的 CPU 突然飙升,导致 ZooKeeper 集群不能工作了,而且我们当时也无法立马将 ZooKeeper 集群重新启动,一直到 ZooKeeper 集群恢复后业务才能继续上线。

经过我们的排查,引发这次问题的根本原因就是 ZooKeeper 本身的性能问题,当连接到 ZooKeeper 的节点数量特别多,对 ZooKeeper 读写特别频繁,且 ZooKeeper 存储的目录达到一定数量的时候,ZooKeeper 将不再稳定,CPU 持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper 就因无法承受瞬间的读写压力,马上宕机。

这次“意外”让我们意识到,ZooKeeper 集群性能显然已经无法支撑我们现有规模的服务集群了,我们需要重新考虑服务发现方案。

基于消息总线的最终一致性的注册中心

我们知道,ZooKeeper 的一大特点就是强一致性,这也就直接导致了 ZooKeeper 集群性能上的下降。

而 RPC 框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后发现这个新上线的节点的。所以我们可以牺牲掉 CP(强制一致性),而选择 AP(最终一致),来换取整个注册中心集群的性能和稳定性。

那么是否有一种简单、高效,并且最终一致的更新机制,能代替 ZooKeeper 那种数据强一致的数据更新机制呢?

因为要求最终一致性,我们可以考虑采用消息总线机制。注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图所示:

image-20201120085043182

  • 当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
  • 消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。

为了性能,这里采用了两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性。

另外,你也可能会想到,服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?

这个问题我们放到了 RPC 框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点。

通过消息总线的方式,我们就可以完成注册中心集群间数据变更的通知,保证数据的最终一致性,并能及时地触发注册中心的服务下发操作。在 RPC 领域精耕细作后,你会发现,服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。

09 | 健康检测:这个节点都挂了,为啥还要疯狂发请求?

怎么保证从 RPC 集群当中选出来的连接一定是可用的呢?

终极的解决方案是让调用方实时感知到节点的状态变化。那在 RPC 框架里,我们应该怎么设计这套机制呢?这就是服务的健康检测。

遇到的问题

某个研发团队发现线上业务的某个接口可用性不高,基本上十次调用里总会有几次失败。查看了具体监控数据之外,发现只有请求具体打到某台机器的时候才会有这个问题,也就是说,集群中有某台机器出了问题。

整个过程如下图所示:

接口调用某台机器的时候已经不能响应了,那为什么 RPC 框架还会继续把请求发到这台有问题的机器上呢?

根据现场实际情况,有这么几个线索:

  1. 通过日志发现请求确实会一直打到这台有问题的机器上;
  2. 从监控上看,这台机器还是有一些成功的请求;
  3. 深入进去看异常日志,发现调用方到目标机器的定时心跳会有间歇性失败;
  4. 从目标机器的监控上可以看到该机器的网络指标有异常,出问题时间点 TCP 重传数比正常高10倍以上。

有了上面4条线索,基本可以得出这样的结论:那台服务器在某些时间段出现了网络故障。

那我们的服务检测机制也是有问题的,有的服务本来都已经病危了,但还以为人家只是感冒。接下来,我们就来看看服务检测的核心逻辑。

健康检测的逻辑

服务的健康状况包括 TCP 连接状况和应用本身是否存活状况,所以下线通知调用方的方式并不能解决实际的问题。

业内常用的检测方法就是用心跳机制。服务方状态有3个:

  • 健康状态:建立连接成功,并且心跳探活也一直成功;
  • 亚健康状态:建立连接成功,但是心跳请求连续失败;
  • 死亡状态:建立连接失败。

具体状态间转换图如下:

系统初始化,如果建立连接成功,那就是健康状态,否则就是死亡状态;

如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态;

如果服务处于亚健康状态后,连续几次都能正常响应心跳请求,那就可以转回健康状态;如果一直是亚健康状态,那就会被断定为是死亡节点,死亡后要关闭连接;

如果在某个时间里,死亡的节点能够重连成功,那它就可以重新被标记为健康状态。

具体的解决方案

寻求解决方案之前,必须考虑到以下2个问题:

  1. 每个接口的调用频次不一样。有的接口可以1秒内调用上百次,有的接口可能半个小时才会调用一次,所以不能简单的把总失败的次数当作判断条件;
  2. 服务接口的响应时间也是不一样的,有的接口可能 1ms,有的接口可能是10s,所以我们也不能把 TPS 值来当作判断条件。

可用率:某一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表。

总结

加完心跳机制,是不是就没有问题了呢?

检测程序所在机器和目标机器之间的网络如果出现故障,就会产生误判。有一个办法可以减少误判的几率,那就是把检测程序部署在多个机器里面,分布在不同的机器上,甚至不同的机房,只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。

10 | 路由策略:怎么让请求按照设定的规则发到不同的节点上?

IP 路由

我们可以重新回到调用方发起 RPC 调用的流程。在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(就是我们常说的负载均衡),那我们是不是可以在选择节点前加上“筛选逻辑”,把符合我们要求的节点筛选出来。那这个筛选的规则是什么呢?就是我们前面说的灰度过程中要验证的规则。

举个具体例子你可能就明白了,比如我们要求新上线的节点只允许某个 IP 可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合,按照这个例子的逻辑,最后会过滤出一个节点,这个节点就是我们刚才新上线的节点。通过这样的改造,RPC 调用流程就变成了这样:

这个筛选过程在我们的 RPC 里面有一个专业名词,就是“路由策略”,而上面例子里面的路由策略是我们常见的 IP 路由策略,用于限制可以调用服务提供方的 IP。使用了 IP 路由策略后,整个集群的调用拓扑如下图所示:

参数路由

有了 IP 路由之后,上线过程中我们就可以做到只让部分调用方请求调用到新上线的实例,相对传统的灰度发布功能来说,这样做我们可以把试错成本降到最低。

但在有些场景下,我们可能还需要更细粒度的路由方式。比如,在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用。

在流量切换的过程中,为了保证整个流程的完整性,我们必须保证某个主题对象的所有请求都使用同一种应用来承接。假设我们改造的是商品应用,那主题对象肯定是商品 ID,在切流量的过程中,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。

很显然,上面的 IP 路由并不能满足我们这个需求,因为 IP 路由只是限制调用方来源,并不会根据请求参数请求到我们预设的服务提供方节点上去。

那我们怎么利用路由策略实现这个需求呢?其实你只要明白路由策略的本质,就不难明白这种参数路由的实现。

我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,我们可以很容易地拿到请求参数,也就是我们例子中的商品 ID,我们可以根据注册中心下发的规则来判断当前商品 ID 的请求是过滤掉新应用还是老应用的节点。因为规则对所有的调用方都是一样的,从而保证对应同一个商品 ID 的请求要么是新应用的节点,要么是老应用的节点。使用了参数路由策略后,整个集群的调用拓扑如下图所示:

相比 IP 路由,参数路由支持的灰度粒度更小,他为服务提供方应用提供了另外一个服务治理的手段。灰度发布功能是 RPC 路由功能的一个典型应用场景,通过 RPC 路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。

11 | 负载均衡:节点负载差距这么大,为什么收到的流量还一样?

从一个需求说起

有一次碰上流量高峰,突然发现线上服务的可用率降低了,经过排查发现,是因为其中有几台机器比较旧,由于负载太高,扛不住压力。

RPC 框架有没有什么智能负载的机制,能及时地自动控制服务节点接收到的访问量?

什么是负载均衡?

当我们的一个服务节点无法支撑现有的访问量时,我们会部署多个节点,组成一个集群,然后通过负载均衡,将请求分发给这个集群下的每个服务节点。

负载均衡主要分为软负载和硬负载。软负载就是在一台或多台服务器上安装负载均衡的软件,如 LVS、Nginx 等;
硬负载就是通过硬件设备来实现的负载均衡,如 F5 服务器等。
负载均衡的算法主要有随机法、轮询法、最小连接法等。

RPC 框架中的负载均衡

我们在服务治理的时候,针对不同接口服务、不同的服务分组,我们的负载均衡策略是需要可配的,如果大家都经过这一个负载均衡设备,就不容易根据不同的场景来配置不同的负载均衡策略了。

RPC 的负载均衡完全是由 RPC 框架自身实现,RPC 的服务调用都会与注册中心下发的所有服务节点建立长连接,在每次发起 RPC 调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起 RPC 调用请求。

RPC 负载均衡策略一般包括随机权重、Hash、轮询。

如何设计自适应的负载均衡?

调用者只要知道每个服务节点处理请求的能力,再根据服务处理节点请求的能力来判断要分给它多少流量。

那服务调用者节点又该如何判定一个服务节点的处理能力呢?

服务调用者收集每个服务节点的指标数据,比如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时、服务节点的状态指标。通过这些指标,计算出一个分数。

怎么根据这些指标来打分呢?

可以为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。

我们又该如何根据分数去控制给每个服务节点发送的流量呢?

我们可以配合随机权重的负载均衡策略去控制。例如给一个服务节点综合打分是 8 分(满分 10 分),服务节点的权重是 100,那么计算后最终权重就是 80(100*80%),这个节点接收的流量就是总流量的 80%。

整体的设计方案如下图所示:

image-20201120085314326

解释一下关键步骤:

  1. 添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器;
  2. 运行时状态指标收集器收集服务节点 CPU 核数、CPU 负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取;
  3. 请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999 等;
  4. 可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分;
  5. 通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。

课后思考

RPC 框架中还有哪些负载均衡策略?它们的优缺点是什么?

答:以 Dubbo 为例,常用的负载均衡方法有:

  1. 基于权重随机算法;
  2. 基于最少活跃调用数算法
  3. 基于 hash 一致性;
  4. 基于加权轮询算法;

Dubbo 默认使用基于权重随机算法。

轮询算法与随机算法相对来说编码比较简单,适用于集群中各个节点提供服务能力等同且无状态的场景。两个方法将服务节点性能当成一样。

但是实际复杂环境,服务节点处能力不一样。这就需要我们有比重分发请求,于是加入权重属性,就有了权重的随机算法与加权轮询算法。另外如果某个服务节点出现问题,服务处理缓慢。轮询算法与随机算法还会持续的将请求发分到服务节点,进一步加重服务节点情况。这是一个比较大的缺点。

最少活跃调用数算法,将会记录服务节点的活跃数,活跃数越少,表明该服务提供者效率越高,单位时间内可处理更多的请求,可以有效改善上面说的情况。

hash 一致性算法,适用于服务有状态的的场景,但是实际上很少需要有状态的场景,该算法比较少使用。(各服务节点排列成圆环,如果一个服务调用失败,会重新转发至该服务的下一个节点。)

12 | 异常重试:在约定时间内安全可靠地重试

为什么需要异常重试?

考虑这样一个场景,我们发起一次用户登录操作,这时会通过远程的用户服务来获取用户基本信息,但这时恰好网络出现了问题,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功,这时我们要怎么做呢?

是在代码里 catch 一下,失败了就再发起一次调用吗?这样做显然不够优雅吧。

RPC 框架的重试机制

当调用端发起的请求失败时,RPC 框架自身可以进行重试,用户可以自行设置是否开启重试以及重试的次数。

这个机制是怎么实现的呢?

image-20201120085356725

调用端在发起 RPC 调用时,会经过负载均衡,选择一个节点发送请求,当发送失败时我们就可以捕获异常,根据特定的异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数的时候,就返回给调用端动态代理一个失败异常。

用户在使用异常重试时需要注意哪些问题呢?

  1. 需要注意被调用的服务业务逻辑是幂等的。
  2. 我把调用端的请求超时时间设置为 5s,结果连续重试 3 次,每次都耗时 2s,那最终这个请求的耗时是 6s,那这样的话,调用端设置的超时时间是不是就不准确了呢?

如何在约定时间内安全可靠地重试?

  • 业务逻辑必须是幂等的;
  • 超时时间需要重置;
  • 去掉有问题的服务节点;

还有可以优化的地方吗?

如果某个业务异常也需要重试,怎么解决呢?我们可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。

这样,一个可靠的重试机制就诞生了,如下图:

image-20201120085520186

课后思考

在整个 RPC 调用的流程中,异常重试发生在哪个环节?

(在动态代理后,路由策略前,因为上次重试过的节点可以通过新增路由策略过滤掉。)

13 | 优雅关闭:如何避免服务停机带来的业务损失?

关闭为什么会有问题?

微服务为了适应快速迭代的业务,就会经常涉及到重启服务。因此在 RPC 体系里,就要考虑在重启服务的过程中,怎么做到让调用方系统不出问题呢?

服务提供方要上线的时候,一般是通过部署系统完成实例重启。在这个过程中,服务提供方并不会告诉调用方他们需要操作哪些机器。而调用方也无法预测到提供方要对哪些机器重启上线,因此负载均衡就有可能把正在重启的机器选出来,从而导致调用失败。

关闭流程

这时候,你可能会想到,RPC 里面不是有服务发现吗?当服务提供方关闭前,先通知注册中心进行下线,注册中心告诉调用方进行节点摘除。关闭流程如下图所示:

但是这样也有不足,比如:

  1. 整个关闭过程中依赖了两次 RPC 调用,一次是服务提供方通知注册中心,一次是注册中心通知服务调用方;
  2. 服务发现只保证最终一致性,并不保证实时性,所以注册中心并不能保证把要下线的节点实时推送到所有调用方;

那服务提供方自己来通知行不行?

大部分场景下,这么做确实没有问题,但是线上还是会偶尔出现问题。通过分析调用方请求日志,发现出问题请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭通知的时间早不到1ms,如果再加上网络传输时间的话,服务提供方收到请求时,它应该正在处理关闭逻辑。

优雅关闭

当服务提供方正在关闭,如果之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)。然后调用方收到这个异常后,RPC 框架把这个节点从健康列表挪出,并把请求重试到其他节点。

那要怎么捕获到关闭事件呢?

可以通过捕获操作系统的进程关闭信号,在Java语言里,对应的是Runtime # addShutdownHook方法。在 RPC 启动的时候,我们提前注册关闭钩子,在里面添加2个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象并通知调用方。当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。

那关闭过程中已经在处理的请求会不会受到影响呢?

我们可以给对象加上引用计数器,每开始处理请求前加1,完成请求处理减1,通过该计数器我们就可以快速判断是否有正在处理的请求。

考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,我们可以在 ShutdownHook 里面加上超时时间控制,超时强制退出应用。超时时间可设定成 10s,基本就可以了。整个流程如下图所示:

总结

Tomcat 关闭的时候也是先从外层到里层逐层进行关闭,先保证不接收新请求,然后再处理关闭前收到的请求。

14 | 优雅启动:如何避免流量打到没有启动完成的节点?

应用启动也要这么讲究吗?

运行了一段时间后的应用,执行速度会比刚启动的应用更快。这是因为在 Java 里面,运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,热点代码也不用每次都解释执行,从而提升执行速度。

如果让刚启动的应用就承担像停机前一样的流量,就会使应用在启动之初就处于高负载状态,从而导致调用方请求出现大面积超时,进而对线上业务产生损害行为。

启动预热

启动预热简单来说,就是让刚启动的服务不承担全部的流量,而是让它随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。

在 RPC 里面,我们该怎么实现这个功能呢?

我们可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用。对于刚启动的应用,让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。

具体怎么实现呢?

首先调用方需要获取服务提供方启动的时间,通过服务发现,除了可以拿到 IP 列表,还需要拿到对应的启动时间。我们把这个时间作用在负载均衡上,如果是基于权重的负载均衡算法,就让权重随着时间推移慢慢增加,增加到设置的权重值。整个过程如下图所示:

那当我在大批量重启服务的时候,会不会导致没有重启的机器因为扛的流量太大而出现问题?

可能通过第11讲学到的自适应负载均衡算法平缓地切换,所以也是没有问题。

启动预热是从调用方的角度出发,逐渐增加流量。那对于服务提供方本身来说,有没有相关方案可以实现这种效果呢?

延迟暴露

对于调用方来说,只要获取到了服务提供方的 IP,就有可能发起 RPC 调用,但如果这时候服务提供方没有启动完成的话,就会导致调用失败,从而使业务受损。

那有什么办法可以避免这种情况吗?

其实只需要把注册到注册中心步骤挪到应用启动完成后就可以了。但是这样还是没有实现最开始的目标,虽然应用启动了,但 JVM 内存里还是冷的。如果我们能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后 才去加载,就可以降低重启后第一次请求出错的概率。

那具体怎么实现呢?

我们可以在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook,让用户(指使用 RPC 框架的程序员)可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。整个应用启动过程如下图所示:

15 | 熔断限流:业务如何实现自我保护?

为什么需要自我保护?

RPC 是解决分布式系统通信问题的一大利器,而分布式系统的一大特点就是高并发。在这样的情况下,我们服务节点就可能由于访问量过大而引起一系列的问题,比如业务处理耗时过长、CPU 飘高、频繁 Full GC 以及服务进程直接宕机等等。

在生产环境中,我们要保证服务的稳定性和高可用性,就需要业务进行自我保护。

服务端的自我保护

假如服务端某个负载压力过高,我们该如何保护这个节点呢?

限流。那是在服务端的业务逻辑中做限流吗?有没有更优雅的方式?

我们可以在 RPC 框架中集成限流的功能,让使用方自己去配置限流阈值;我们还可以在服务端添加限流逻辑,当调用端发送请求过来时,服务端在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,就让服务端直接抛回给调用端一个限流异常,否则就执行正常的业务逻辑。

那服务端的限流逻辑又该如何实现呢?

方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最为常用。

各个应用调用方发送过来的请求有时并不是相等的,这时我们就应该对这个应用、甚至是某个 IP 发送过来的请求流量做限流。

使用方该如何配置应用维度以及 IP 维度的限流呢?

RPC 框架真正强大的地方在于它的治理功能,我们可以通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。

一台节点的阈值假如说是每秒 1000 次;如果服务集群有 10 台节点,那么提供的服务限流阈值在理想情况下就是每秒 10000 次。这时我们对服务节点扩容到 20 个节点,理论上就应该是 20000 次,这样操作每次都要自己去计算,重新配置,显然太麻烦了。

我们可以让 RPC 框架去计算,当注册中心或配置中心将限流阈值配置下发的时候,我们可以将总服务节点也下发给服务节点,之后由服务节点自己计算限流阈值。这就就解决问题了吧?

还有一个问题,那就是在实际情况下,一个服务节点所接收到的访问量并不是绝对均匀的,比如有 20 个节点,而每个节点限流的阈值是 500,其中有的节点访问量已经达到阈值了,但有的节点可能在这一秒内的访问量是 450,这时调用端发送过来的总调用量还没有达到 10000 次,但可能也会被限流,这样不就不精确了?那有没有比较精确的限流方式呢?

我们可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点调用这个限流服务来判断是否到达了限流阈值。

我们甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。

但是这种方式在性能和耗时上都有很大劣势,需要结合具体应用场景进行选择。

调用端的自我保护

举个例子,假如我要发布一个服务 B,而服务 B 又依赖服务 C,当一个服务 A 来调用服务 B 时,服务 B 的业务逻辑调用服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 在频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机。

由此可见,服务 B 调用服务 C,服务 C 执行业务逻辑出现异常时,会影响到服务 B,甚至可能会引起服务 B 宕机。这还只是 A->B->C 的情况,试想一下 A->B->C->D->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的。

所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。最有效的自我保护方式就是熔断。

我们可以先了解下熔断机制。

熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。

在正常情况下,熔断器是关闭的;当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;

当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。

了解完熔断机制,你就会发现,在业务逻辑中加入熔断器其实是不够优雅的。

熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器。我们可以回想下 RPC 的调用流程:

你看图的话,有没有想到在哪个步骤整合熔断器会比较合适呢?

我的建议是动态代理,因为在 RPC 调用的流程中,动态代理是 RPC 调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。

课后思考

在使用 RPC 的过程中业务要实现自我保护,针对这个问题你是否还有其他的解决方案?

答:服务保护一般就是限流、熔断、降级。 限流的落地方式有:Guava RateLimiter、lua+Redis、Sentinel 等; 熔断:Hystrix、Resilience4j; 降级:服务降级,就是对不怎么重要的服务进行低优先级的处理。说白了,就是尽可能的把系统资源让给优先级高的服务。资源有限,而请求是无限的。

16 | 业务分组:如何隔离流量?

在高并发场景下,熔断和限流的出发点都是为了实现自我保护,一旦发生这种行为,业务都是有损的。我们还有别的手段,可以最大限度地保障业务无损,那就是隔离流量。

为什么需要分组?

在早期业务量不大的情况下,我们通过会选择最简单的方法,就是把服务实例统一管理,所有请求都用一个共享的“大池子”来处理。服务调用方跟服务提供方之间的调用拓扑如下图所示:

后期因为业务发展丰富了,流量也会渐渐多起来。我们可以尝试把应用提供方这个大池子划分出不同规格的小池子,而不同小池子之间的隔离带,就是我们在 RPC 里面所说的分组,它可以实现流量隔离。

怎么实现分组?

在第8讲我们说过,服务调用方是通过接口名去注册中心拿到所有服务节点。为了实现分组隔离,我们需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另加一个分组参数,服务提供方在注册的时候也要带上分组参数。

分组的标准可以按照重要级别。这个原则就是保障核心应用不受影响。有了分组之后 ,服务调用方跟服务提供方之间的调用拓扑就如下图所示:

那通过这种分组进行流量隔离,对调用方应用会不会有影响呢?

假如一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,在这种情况下调用方业务就受损了。

如何实现分组高可用?

我们还需要把配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上。整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用。