<RPC实战与核心原理>学习笔记 --- 进阶篇

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

RPC 就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证在服务提供方能正确地还原出语义,最终实现像调用本地一样地调用远程的目的

RPC 架构设计

网络传输, 保证可靠性 --> TCP
①传输模块, 收发二进制数据, 屏蔽网络传输的复杂性

②协议模块
序列化过程: 用户请求基于方法调用,方法出入参数都是对象数据,对象在网络中传输需要转成二进制
协议封装: 在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,在两个“断句”符号中间放的内容就是请求的二进制数据
压缩功能: 在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原
目的: 保证数据在网络中可以正确传输

③Bootstrap 模块
屏蔽细节, 让研发人员感觉不到本地调用和远程调用的区别
if 用到Spring, 希望可以把一个 RPC 接口定义成一个 Spring Bean,并且这个 Bean 也会统一被 Spring Bean Factory 管理,可以在项目中通过 Spring 依赖注入到方式引用

以上组成单机版本的RPC, (Point to Point)版本的 RPC 框架, 没有集群能力
④集群模块
集群能力,就是针对同一个接口有着多个服务提供者,但这多个服务提供者对于调用方来说是透明的,所以在 RPC 里面还需要给调用方找到所有的服务提供方,并需要在 RPC 里面维护好接口跟服务提供者地址的关系,这样调用方在发起请求的时候才能快速地找到对应的接收地址,这就是“服务发现”。

但服务发现只是解决了接口和服务提供方地址映射关系的查找问题,这更多是一种“静态数据”。说它是静态数据是因为,对于 RPC 来说,每次发送请求的时候都是需要用 TCP 连接的,相对服务提供方 IP 地址,TCP 连接状态是瞬息万变的,所以 RPC 框架里面要有连接管理器去维护 TCP 连接的状态。

有了集群之后,提供方可能就需要管理好这些服务了,那 RPC 就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些常规治理手段。而服务调用方在每次调用前,都需要根据服务提供方设置的规则,从集群中选择可用的连接用于发送请求。

四层RPC 框架
<RPC实战与核心原理>学习笔记 --- 进阶篇_第1张图片

可扩展的架构 - 插件化架构

将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。
在 Java 里面,JDK 有自带的 SPI(Service Provider Interface)服务发现机制,它可以动态地为某个接口寻找服务实现
使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。

但在实际项目中,其实很少使用到 JDK 自带的 SPI 机制,

  1. 首先它不能按需加载,ServiceLoader 加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。
  2. 另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个 Spring Bean,原生的 Java SPI 就不支持。

加上插件功能后, 包含核心功能体系与插件体系的RPC 框架:

这时,整个架构就变成了一个微内核架构,将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。

这样的架构相比之前的架构,有很多优势:

  1. 首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;
  2. 其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。

总结

软件功能 + 系统的可拓展性

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

服务发现的作用就是实时感知集群 IP 的变化,实现接口跟服务集群节点 IP 的映射。
在超大规模集群实战中,更多需要考虑的是保证最终一致性AP。
总结来说就是: “推拉结合,以拉为准”

服务发现原理图

<RPC实战与核心原理>学习笔记 --- 进阶篇_第2张图片

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

为什么不使用 DNS?

DNS查询流程:
<RPC实战与核心原理>学习笔记 --- 进阶篇_第3张图片

如果用 DNS 来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过 DNS 拿到随机的一个服务提供者的 IP,并与之建立长连接
存在问题:

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

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

加一个负载均衡设备, 将域名绑定到这台负载均衡设备上,通过 DNS 拿到负载均衡的 IP。
这样服务调用的时候,服务调用方就可以直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发,如下图所示:
<RPC实战与核心原理>学习笔记 --- 进阶篇_第4张图片
这个方案确实能解决 DNS 遇到的一些问题,但在 RPC 场景里面也并不是很合适,原因有以下几点:

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

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

基于 ZooKeeper 的服务发现

服务发现: 完成接口跟服务提供者 IP 之间的映射

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

<RPC实战与核心原理>学习笔记 --- 进阶篇_第5张图片

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

问题现象:
当有超大批量的服务节点在同时发起注册操作,ZooKeeper 集群的 CPU 突然飙升,导致 ZooKeeper 集群不能工作了,
而且当时也无法立马将 ZooKeeper 集群重新启动,一直到 ZooKeeper 集群恢复后业务才能继续上线

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

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

ZooKeeper 一大特点 – 强一致性,-- 此处有更正 (zk并不是强一致性,而且顺序一致性。也算最终一致性的一种。)
ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新。
它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了 ZooKeeper 集群性能上的下降。

分布式CAP定理
一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)

而 RPC 框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。
毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,
所以可以牺牲掉 CP(强制一致性),而选择 AP(最终一致),来换取整个注册中心集群的性能和稳定性。

–>

消息总线机制

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

  • 当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
  • 消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性
  • 消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里面。
  • 采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。

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

question: 服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?
这个问题放到了 RPC 框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。
服务调用方收到拒绝异常后,会安全重试到其它节点。

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

总结

通常可以使用 ZooKeeper、etcd 或者分布式缓存(如 Hazelcast)来解决事件通知问题,但当集群达到一定规模之后,依赖的 ZooKeeper 集群、etcd 集群可能就不稳定了,无法满足需求。

在超大规模的服务集群下,注册中心所面临的挑战就是超大批量服务节点同时上下线,注册中心集群接受到大量服务变更请求,集群间各节点间需要同步大量服务节点数据,最终导致如下问题:

  • 注册中心负载过高;
  • 各节点数据不一致;
  • 服务下发不及时或下发错误的服务节点列表。

RPC 框架依赖的注册中心的服务数据的一致性其实并不需要满足 CP,只要满足 AP 即可。
采用“消息总线”的通知机制,来保证注册中心数据的最终一致性,来解决这些问题的。

另外,本节知识点不只可以应用到 RPC 框架的“服务发现”中。
例如服务节点数据的推送采用增量更新的方式,这种方式提高了注册中心“服务下发”的效率,而这种方式还可以利用在其它地方,比如统一配置中心,用此方式可以提升统一配置中心下发配置的效率。

思考

目前服务提供者上线后会自动注册到注册中心,服务调用方会自动感知到新增的实例,并且流量会很快打到该新增的实例。如果想把某些服务提供者实例的流量切走,除了下线实例,有没有想到其它更便捷的办法呢?

官方
1. 改变服务提供者实例的权重,将权重调整为 0
2. 通过路由的方式也可以
3. 动态分组,业务分组的概念,通过业务分组来实现流量隔离。
如果业务分组是动态的,就可以在管理平台动态地自由调整,是否能实现动态地流量切换? -- 高级篇
服务挂了,在注册中心上还得要手动删除下死亡节点,
如果zk的话,服务没了,就代表会话也没了,临时节点的特性,应该会被通知到呀, 为什么还要手动删除呢?

临时节点是需要等到超时时间之后才删除的,不够实时。

---------

如果要能切换流量,那么要服务端配置有权重负载均衡策略,这样服务器端可以通过调整权重来安排流量

---------

消息总栈类似一个队列,队列表示是递增的数字,
注册中心集群的任何一个节点接收到注册请求,都会把服务提供者信息发给消息总栈,
消息总栈会像队列以先进先出的原则推送消息给所有注册中心集群节点,
集群节点接收到消息后会比较自己内存中的当前版本,保存版本大的,这种方式有很强的实效性,
注册中心集群也可以从消息总栈拉取消息,确保数据AP,

个人理解这是为了防止消息未接收到导致个别节点数据不准确,
因为服务提供者可以向任意一个节点发送注册请求,从而降低了单个注册中心的压力,
而注册和注册中心同步是异步的,也解决了集中注册的压力,
在Zookeeper中,因为Zookeeper注册集群的强一致性,导致必须所有节点执行完一次同步,才能执行新的同步,
这样导致注册处理性能降低,从而高I/O操作宕机。

问: 当集中注册时,消息总栈下发通知给注册中心集群节点,对于单个节点也会不停的收到更新通知,这里也存在高I/O问题,会不会有宕机?
答: event bus可以改造成主从模式保证高可用

-------------

服务消费者都是从注册中心拉取服务提供者的地址信息,
所以要切走某些服务提供者数据,只需要将注册中心这些实例的地址信息删除
(其实下线应用实例,实际也是去删除注册中心地址信息),
然后注册中心反向通知消费者,消费者受到拉取最新提供者地址信息就没有这些实例了。
问: 现有开源注册中心是不是还没有消息总线这种实现方式?消息总线有没有开源实现?
答: 通过服务发现来摘除流量是最常见的手段,还可以上下线状态、权重等方式。现成的MQ也是可以充当消息总线来用

-------------: 在AP实现中“两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性”能展开讲一下具体实现吗?
另外请教下CP可以基于zk实现,AP在业内的实现方式有哪些呢?

主要实现callback,拉的动作在客户端,像Eurek属于AP

-------------: 消息总线策略是啥,老师能指点下么,怎么保证消息总线全局版本递增
答: 最简单的就用时间戳

-------------

zookeeper注册中心实现原理
1. 服务平台向zookeeper创建服务目录
2. 服务提供者向zookeeper创建临时节点
3. 服务调用者订阅zookeeper,创建临时节点,拉取服务全量数据,watch服务全部节点数据
4. zookeeper节点发生变化会通知服务调用者
切掉服务流量,只需要将注册中心的配置节点下掉就好了
这还是利用了服务发现

-------------

1:注册中心的核心作用?
完成服务消费者和服务调用者,两者的路径匹配

2:注册中心的核心指标?
高效、稳定、高可用,使服务消费者及时感知节点的变化

3:路径匹配需要的信息?
服务提供者IP、端口、接口、方法+服务分组别名
服务消费者IP、端口
路径匹配可以把分组别名利用上,即使提供者实例上线,
不过由于设置的别名和服务消费者需要的不一致流量也不会打过去,什么时候打过去可以通过配置中心来自由的控制。
分组内也是有多个服务提供者的,这里可以再利用相关的负载均衡策略来具体分发流量。

健康检测: 挂了的节点还疯狂发请求?

每次发请求前,RPC 框架会根据路由和负载均衡算法选择一个具体的 IP 地址
为了保证请求成功,就需要确保每次选择出来的 IP 对应的连接是健康的
->
终极的解决方案是让调用方实时感知到节点的状态变化

线上问题实例

线上业务的某个接口可用性并不高,基本上十次调用里总会有几次失败。

查看了具体的监控数据之后,发现集群中有某台机器出了问题。
<RPC实战与核心原理>学习笔记 --- 进阶篇_第6张图片
线索:

  1. 通过日志发现请求确实会一直打到这台有问题的机器上,因为日志里有很多超时的异常信息。
  2. 从监控上看,这台机器还是有一些成功的请求,这说明当时调用方跟服务之间的网络连接没有断开。因为如果连接断开之后,RPC 框架会把这个节点标识为“不健康”,不会被选出来用于发业务请求。
  3. 深入进去看异常日志,发现调用方到目标机器的定时心跳会有间歇性失败。
  4. 从目标机器的监控上可以看到该机器的网络指标有异常,出问题时间点 TCP 重传数比正常高 10 倍以上。

结论:
那台问题服务器在某些时间段出现了网络故障,但也还能处理部分请求。
但是它还没彻底“死”,还有心跳,这样调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。

健康检测的逻辑

当服务方下线,正常情况下肯定会收到连接断开的通知事件,在这个事件里面直接加处理逻辑不就可以了 ? 不可以
应用健康状况不仅包括 TCP 连接状况,还包括应用本身是否存活 --> 心跳机制

服务方状态:

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

状态转换图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第7张图片

具体的解决方案

节点: 健康状态 -> 亚健康状态, “连续”心跳失败次数必须到达某一个阈值

if 调低阈值:

  1. 调用方跟服务节点之间网络状况瞬息万变,出现网络波动的时候会导致误判
  2. 在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接

问题的本质:
核心是服务节点网络有问题,心跳间歇性失败
判断节点状态维度: 心跳检测 + <<<业务请求>>>

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

->

可用率
某一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)
当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,
这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。

总结

正常情况下,大概 30S 会发一次心跳请求

不能简单地依赖端口的连通性来判断应用是否存活,因为在端口连通正常的情况下,应用也可能僵死了
让每个应用实例提供一个“健康检测”的 URL,检测程序定时通过构造 HTTP 请求访问该 URL,然后根据响应结果来进行存活判断,这样就可以防止僵死状态的误判
->
心跳机制

if 检测程序所在的机器和目标机器之间的网络可能还会出现故障, -> 误判
减少误判的几率:
那就是把检测程序部署在多个机器里面,分布在不同的机架,甚至不同的机房。
因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常

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

RPC 在每次发起请求的时候,都需要从多个服务提供方节点里面选择一个用于发请求的节点
节点同质: 这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的

服务提供方集群:
上线应用的时候都不止一台服务器会运行实例 -> 上线涉及到变更 -> 变更可能导致程序异常
为了减少这种风险,一般会选择灰度发布应用实例
但是线上一旦出现问题,影响范围还是挺大的

那对于的 RPC 框架来说,有什么的办法可以减少上线变更导致的风险
->
路由在 RPC 中的应用

如何实现路由策略?

把所有的场景都重新测试一遍
单纯从测试角度出发只能降低风险出现的概率,想要彻底验证所有场景基本是不可能的
->
尽量减小上线出问题导致业务受损的范围
先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果

方法1, 通过服务发现的方式实现流量隔离
在 RPC 里面服务调用方是通过服务发现的方式拿到了所有服务提供方的 IP 地址
当选择要灰度验证功能的时候,让注册中心在推送的时候区别对待,而不是一股脑的把服务提供方的 IP 地址推送到所有调用方
注册中心只会把刚上线的服务 IP 地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个 IP 地址的

通过服务发现的方式来隔离调用方请求,从逻辑上来看确实可行,但注册中心在 RPC 里面的定位是用来存储数据并保证数据一致性的
如果把这种复杂的计算逻辑放到注册中心里面,当集群节点变多之后,就会导致注册中心压力很大,而且大部分情况下一般都是采用开源软件来搭建注册中心,要满足这种需求还需要进行二次开发。
所以从实际的角度出发,通过影响服务发现来实现请求隔离并不划算

方法2, 通过负载均衡的方式实现流量隔离
在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(负载均衡),在选择节点前加上“筛选逻辑”,把符合要求的节点筛选出来
筛选逻辑: 灰度过程中要验证的规则

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

调用流程图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第8张图片

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

IP路由调用拓扑
<RPC实战与核心原理>学习笔记 --- 进阶篇_第9张图片

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

参数路由

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

在流量切换的过程中,为了保证整个流程的完整性,必须保证某个主题对象的所有请求使用同一种应用来承接

IP 路由只是限制调用方来源,并不会根据请求参数请求到预设的服务提供方节点上去

给所有的服务提供方节点都打上标签,用来区分新老应用节点。

在服务调用方发生请求的时候,可以很容易地拿到请求参数,可以根据注册中心下发的规则来判断请求参数是过滤掉新应用还是老应用的节点。
因为规则对所有的调用方都是一样的,从而保证对应同一个请求参数要么是新应用的节点,要么是老应用的节点。
使用了参数路由策略后,整个集群的调用拓扑如下图所示:

参数路由调用拓扑
<RPC实战与核心原理>学习笔记 --- 进阶篇_第10张图片
相比 IP 路由,参数路由支持的灰度粒度更小,服务提供方应用提供了另外一个服务治理的手段。
灰度发布功能是 RPC 路由功能的一个典型应用场景,通过 RPC 路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。

总结

在日常工作中,几乎每天都在做线上变更,每次变更都有可能带来一次事故,为了降低事故发生的概率,不光要从流程上优化操作步骤,还要使的基础设施能支持更低的试错成本。
灰度发布功能作为 RPC 路由功能的一个典型应用场景,可以通过路由功能完成像定点调用、黑白名单等一些高级服务治理功能。在 RPC 里面,不管是哪种路由策略,其核心思想都是一样的,就是让请求按照设定的规则发送到目标节点上,从而实现流量隔离的效果。

总而言之
多场景的路由选择 -> 其核心就是“如何根据不同的场景控制选择合适的目标机器”

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

有一次碰上流量高峰,突然发现线上服务的可用率降低了,经过排查发现,是因为其中有几台机器比较旧了。
有没有好的服务治理策略?
<RPC实战与核心原理>学习笔记 --- 进阶篇_第11张图片

方案1:
在治理平台上调低这几台机器的权重,访问的流量自然就减少了
->
当发现服务可用率降低的时候,业务请求已经受到影响了,这段时间内业务可能已经有损失了
RPC 框架有没有什么智能负载的机制?能否及时地自动控制服务节点接收到的访问量?
当业务方发现部分机器负载过高或者响应变慢的时候再去调整节点权重,真的很可能已经影响到线上服务的可用率了

负载均衡

当一个服务节点无法支撑现有的访问量时,会部署多个节点,组成一个集群,
然后通过负载均衡,将请求分发给这个集群下的每个服务节点,从而达到多个服务节点共同分担请求压力的目的

负载均衡示意图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第12张图片

负载均衡主要分为软负载和硬负载,

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

刚才介绍的负载均衡主要还是应用在 Web 服务上,Web 服务的域名绑定负载均衡的地址,通过负载均衡将用户的请求分发到一个个后端服务上。

RPC 框架中的负载均衡

服务发现一章, 讲解了为什么 … 实现“服务发现”,

  • 不采用 DNS的方式
  • 不采用添加负载均衡设备, 域名绑定负载均衡设备的 IP 的方式
  • 不采用 TCP/IP 四层代理,四层代理 IP 的方式

这几种方式面临问题:

  1. 搭建负载均衡设备或 TCP/IP 四层代理,需要额外成本;
  2. 请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费一些性能;
  3. 负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作,“服务发现”在操作上是个问题;
  4. 在服务治理的时候,针对不同接口服务、服务的不同分组,负载均衡策略是需要可配的,一个负载均衡设备不容易根据不同的场景来配置不同的负载均衡策略了。

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

<RPC实战与核心原理>学习笔记 --- 进阶篇_第13张图片
RPC框架负载均衡示意图

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

  • 随机权重策略应该是最常用的一种了,通过随机算法,基本可以保证每个节点接收到的请求流量是均匀的;
  • 通过控制节点权重的方式,来进行流量控制。比如默认每个节点的权重都是 100,但当把其中的一个节点的权重设置成 50 时,它接收到的流量就是其他节点的 1/2。

由于负载均衡机制完全是由 RPC 框架自身实现的,所以它不再需要依赖任何负载均衡设备,自然也不会发生负载均衡设备的单点问题,服务调用方的负载均衡策略也完全可配,同时可以通过控制权重的方式,对负载均衡进行治理。

有没有什么办法可以动态地、智能地控制线上服务节点所接收到的请求流量?
->
自适应的负载均衡策略

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

if 调用者 提前知道 每个服务节点处理请求的能力, 据此判断分发给它的流量
->
如何知道?
服务调用者收集与之建立长连接的每个服务节点的指标数据,
如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)
通过这些指标,计算出一个分数
->
如何根据指标打分?
为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数
->
如何根据分数去控制给每个服务节点发送多少流量?
配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权重。
eg
给一个服务节点综合打分是 8 分(满分 10 分),服务节点的权重是 100,那么计算后最终权重就是 80(100*80%)。
服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的 80%(这里假设其他节点默认权重都是 100,且指标正常,打分为 10 分的情况)。

RPC自适应负载均衡示意图

关键步骤:

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

总结

RPC 框架的负载均衡与 Web 服务的负载均衡的不同之处在于:
RPC 框架并不是依赖一个负载均衡设备或者负载均衡服务器来实现负载均衡的,而是由 RPC 框架本身实现的,服务调用者可以自主选择服务节点,发起服务调用。

这样的好处是,
RPC 框架不再需要依赖专门的负载均衡设备,可以节约成本;
还减少了与负载均衡设备间额外的网络传输,提升了传输效率;
并且均衡策略可配,便于服务治理。

如何设计一个自适应的负载均衡 – 一个智能负载的解决方案 – 不仅限于RPC 框架中的负载均衡
根据服务调用者依赖的服务集群中每个节点的自身状态,智能地控制发送给每个服务节点的请求流量,防止因某个服务节点负载过高、请求处理过慢而影响到整个服务集群的可用率。

还有哪些负载均衡策略?

以 Dubbo 为例,常用的负载均衡方法有:
1.基于权重随机算法
2.基于最少活跃调用数算法
3.基于 hash 一致性
4.基于加权轮询算法

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

为什么需要异常重试?

一个请求, 希望能够尽可能地执行成功
网络抖动, 请求失败, 在代码逻辑里 catch 一下,失败了就再发起一次调用吗?
->
不够优雅, rpc中的重试

rpc中的重试机制

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

RPC异常重试
<RPC实战与核心原理>学习笔记 --- 进阶篇_第14张图片

调用端在发起 RPC 调用时,会经过负载均衡,选择一个节点,之后它会向这个节点发送请求信息。
当消息发送失败或收到异常消息时,就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,
当重试次数达到用户配置的重试次数的时候,就返回给调用端动态代理一个失败异常,否则就一直重试下去。

->

RPC 框架的重试机制就是调用端发现请求失败时捕获异常,之后触发重试,那是不是所有的异常都要触发重试呢?
这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。

->

在使用异常重试时需要注意哪些问题呢?
网络抖动+重试, 业务逻辑执行两次
如果业务逻辑不是幂等的,比如插入数据操作,那触发重试的话会引发问题

总结: (高频误区)
在使用 RPC 框架的时候,要确保被调用的服务的业务逻辑是幂等的,这样才能考虑根据事件情况开启 RPC 框架的异常重试功能

连续重试对请求超时时间的影响?
eg
把调用端的请求超时时间设置为 5s,结果连续重试 3 次,每次都耗时 2s,那最终这个请求的耗时是 6s,那这样的话,调用端设置的超时时间是不是就不准确了呢?

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

连续的异常重试可能会出现一种不可靠的情况,
那就是连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。

解决这个问题最直接的方式就是,在每次重试后都重置一下请求的超时时间
当调用端发起 RPC 请求时,如果发送请求发生异常并触发了异常重试,可以先判定下这个请求是否已经超时

  • 超时了就直接返回超时异常,
  • 否则就先重置下这个请求的超时时间,之后再发起重试。

这样可以解决因多次异常重试引发的超时时间失效的问题?
eg:
当调用端设置了异常重试策略,发起了一次 RPC 调用,通过负载均衡选择了节点,将请求消息发送到这个节点,这时这个节点由于负载压力较大,导致这个请求处理失败了,
调用端触发了重试,再次通过负载均衡选择了一个节点,结果恰好仍选择了这个节点,那么在这种情况下,重试的效果也会受到影响
->
因此,需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。

现在的重试机制:

  1. 业务逻辑必须是幂等的
  2. 超时时间需要重置
  3. 去掉有问题的服务节点后

只有 RPC 框架中特定的异常才会如此,比如连接异常、超时异常
服务端业务逻辑中抛回给调用端的异常是不能重试的
eg:
服务端的业务逻辑抛给调用端一个异常信息,而服务端抛出这个异常是允许调用端重新发起一次调用的。
数据库某个数据的更新操作

RPC 框架的重试机制需要怎么优化
->
重试异常的白名单
用户可以将允许重试的异常加入到这个白名单中
如果这个异常类型存在于可重试异常的白名单中,允许对这个请求进行重试

可靠的重试机制

总结

异常重试就是为了尽最大可能保证接口可用率的一种手段,但这种策略只能用在幂等接口上,否则就会因为重试导致应用系统数据“写花”。

  1. 触发重试之前,需要先判定下这个请求是否已经超时
  2. 如果超时了会直接返回超时异常,
  3. 否则需要重置下这个请求的超时时间,防止因多次重试导致这个请求的处理时间超过用户配置的超时时间,从而影响到业务处理的耗时
  4. 在发起重试、负载均衡选择节点的时候,应该去掉重试之前出现过问题的那个节点,这样可以提高重试的成功率
  5. 允许用户配置可重试异常的白名单,这样可以让 RPC 框架的异常重试功能变得更加友好
  6. 在使用 RPC 框架的重试机制时,要确保被调用的服务的业务逻辑是幂等的,这样才能考虑是否使用重试

思考

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

官方:
RPC 为什么需要异常重试?
如果在发出请求时恰好网络出现问题了,导致请求失败,可能需要进行异常重试
异常重试的操作是要在调用端进行的。
因为如果在调用端发出请求时恰好网络出现问题导致请求失败,那么这个请求很可能还没到达服务端,服务端当然就没办法去处理重试了

在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。
由此可见异常重试的操作应该发生在负载均衡之前,在发起重试的时候,会调用负载均衡插件来选择一个服务节点,
在调用负载均衡插件时要告诉负载均衡需要刨除哪些有问题的服务节点。

在整个 RPC 调用的过程中,从动态代理到负载均衡之间还有一系列的操作,
开源的 RPC 框架, 在调用端发送请求消息之前还会经过过滤链,对请求消息进行层层的过滤处理,
之后才会通过负载均衡选择服务节点,发送请求消息,
而异常重试操作就发生在过滤链处理之后,调用负载均衡选择服务节点之前,这样的重试是可以减少很多重复操作的。

异常重试主要有客户端的重试,每个业务层也会有重试,通过幂等,白名单,摘除认为有问题的机器,重试次数来保证业务可用
->
rpc可以做到话,尽量下沉

failsafe failfast failover failback
参考文章:
https://blog.csdn.net/aohongzhu/article/details/107839669
https://blog.csdn.net/weixin_51115925/article/details/109902885

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

关闭带来的问题

可以更方便、更快速地迭代业务
->
系统拆分
经常更新应用系统,时不时还老要重启服务器
->
在重启服务的过程中,RPC 怎么做到让调用方系统不出问题?

上线的大概流程:
当服务提供方要上线的时候,一般是通过部署系统完成实例重启。
在这个过程中,服务提供方的团队并不会事先告诉调用方他们需要操作哪些机器,从而让调用方去事先切走流量。
而对调用方来说,它也无法预测到服务提供方要对哪些机器重启上线,因此负载均衡就有可能把要正在重启的机器选出来,
这样就会导致把请求发送到正在重启中的机器里面,从而导致调用方不能拿到正确的响应结果。

<RPC实战与核心原理>学习笔记 --- 进阶篇_第15张图片
在服务重启的时候,对于调用方来说,这时候可能会存在以下几种情况:

  1. 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。
  2. 调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中

关闭流程

在重启服务机器前,先通过“某种方式”把要下线的机器从调用方维护的“健康列表”里面删除
->
人工通知调用方,让他们手动摘除要下线的机器
每次上线都要通知到所有调用接口的团队, 太过繁琐
->
RPC服务发现, “实时”感知服务提供方的状态
当服务提供方关闭前,是不是可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除

关闭流程图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第16张图片

整个关闭过程中依赖了两次 RPC 调用,一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。
注册中心通知服务调用方都是异步的,而且在大规模集群里面,服务发现只保证最终一致性,并不保证实时性
所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方, 应用并不能无损关闭。

->

不能强依赖“服务发现”来通知调用方要下线的机器,服务提供方自己通知行吗?
在 RPC 里面调用方跟服务提供方之间是长连接,可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个去通知调用方去下线这台机器。
这样整个调用链路就变短了,对于每个调用方来说就一次 RPC,可以确保调用的成功率很高。

->

线上还是会偶尔会出现,因为服务提供方上线而导致调用失败的问题
分析了调用方请求日志收到关闭通知的日志,并且发现了一个线索如下:
出问题请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭通知的时间早不到 1ms,如果再加上网络传输时间的话,那服务提供方收到请求的时候,它应该正在处理关闭逻辑。
这就说明服务提供方关闭的时候,并没有正确处理关闭后接收到的新请求。

优雅关闭

在关闭的时候,设置一个请求“挡板”: 告诉调用方,我已经开始进入关闭流程了,不能再处理这个请求了。

当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)。
调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,
因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点,这样就可以实现对业务无损。

只靠等待被动调用,就会让这个关闭过程整体有点漫长。
如果调用方那个时刻没有业务请求,就不能及时地通知调用方了,
->
加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。
如何捕获到关闭事件呢?
可以通过捕获操作系统的进程信号来获取,
在 Java 语言里面,对应的是 Runtime.addShutdownHook 方法,可以注册关闭的钩子。

  1. 在 RPC 启动的时候,提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。
  2. 同时需要在调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。

关闭过程中已经在处理的请求会不会受到影响呢?
如果进程结束过快会造成这些请求还没有来得及应答,同时调用方会也会抛出异常。
为了尽可能地完成正在处理的请求,首先要把这些请求识别出来。
好比停车场指示牌提示剩余车位
利用这个原理在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器就可以快速判断是否有正在处理的请求

服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。
但考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,可以在整个 ShutdownHook 里面,加上超时时间控制,当超过了指定时间没有结束,则强制退出应用。超时时间建议可以设定成 10s,基本可以确保请求都处理完了。整个流程如下图所示。

优雅关闭流程图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第17张图片

总结

优雅停机,就是为了让服务提供方在停机应用的时候,保证所有调用方都能“安全”地切走流量,不再调用自己,从而做到对业务无损。
其中实现的关键点就在于,让正在停机的服务提供方应用有状态,让调用方感知到服务提供方正在停机


在 RPC 里面,关闭虽然看似不属于 RPC 主流程,但如果不能处理得很好的话,可能就会导致调用方业务异常,从而需要加入很多额外的运维工作。

一个好的关闭流程,可以确保使用框架的业务实现平滑的上下线,而不用担心重启导致的问题。

其实“优雅关闭”这个概念除了在 RPC 里面有,在很多框架里面也都挺常见的,比如像经常用的应用容器框架 Tomcat。Tomcat 关闭的时候也是先从外层到里层逐层进行关闭,先保证不接收新请求,然后再处理关闭前收到的请求。

思考

应用重启上下线的时候,还涉及到应用启动流程。那么如何做到优雅启动,避免请求分发到没有就绪的服务节点呢?

关闭由外到内;启动从内到外

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

运行了一段时间后的应用,执行速度会比刚启动的应用更快。
在 Java 里面,在运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度。
->
但是这些“临时数据”,都在应用重启后就消失了。
如果让刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。

微服务架构里面,上线肯定是频繁发生的
刚重启的服务提供方因为没有预跑就承担了大流量
让应用一开始只接少许流量呢?这样低功率运行一段时间后,再逐渐提升至最佳状态
->

启动预热

刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样

如何实现?
->
控制调用方发送到服务提供方的流量。
调用方发起的 RPC 调用流程: 调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接
->
负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用
对于刚启动的应用,可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。

具体实现
首先对于调用方来说,要知道服务提供方启动的时间,

  • 服务提供方在启动的时候,把自己启动的时间告诉注册中心;
  • 注册中心收到的服务提供方的请求注册时间。

如何确保所有机器的日期时间是一样的?
不用太关心,整个预热过程的时间是一个粗略值,即使机器之间的日期时间存在 1 分钟的误差也不影响,并且在真实环境中机器都会默认开启 NTP 时间同步功能,来保证所有机器时间的一致性。

不管是选择哪个时间,最终的结果就是,调用方通过服务发现,除了可以拿到 IP 列表,还可以拿到对应的启动时间

把这个时间作用在负载均衡上,使用基于权重的负载均衡,但是这个权重是由服务提供方设置的,属于一个固定状态。
现在让这个权重变成动态的,并且是随着时间的推移慢慢增加到服务提供方设定的固定值,整个过程如下图所示:

启动预热过程图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第18张图片

通过这个小逻辑的改动,就可以保证当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处于高负载状态,从而实现服务提供方在启动后有一个预热的过程。

当在大批量重启服务提供方的时候,会不会导致没有重启的机器因为扛的流量太大而出现问题?
当大批量重启服务提供方的时候,对于调用方来说,这些刚重启的机器权重基本是一样的,也就是说这些机器被选中的概率是一样的,大家都是一样得低,也就不存在权重区分的问题了。
但是对于那些没有重启过的应用提供方来说,它们被负载均衡选中的概率是相对较高的,但是可以通过 [负载均衡中] 的自适应负载的方法平缓地切换,所以也是没有问题的。

启动预热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常水平,从而实现平滑上线。
但对于服务提供方本身来说,有没有相关方案可以实现这种效果呢?
和热启动息息相关,延迟暴露

延迟暴露

应用启动的时候都是通过 main 入口,然后顺序加载各种相关依赖的类。
eg: Spring 应用启动
在加载的过程中,Spring 容器会顺序加载 Spring Bean,
如果某个 Bean 是 RPC 服务的话,不光要把它注册到 Spring-BeanFactory 里面去,还要把这个 Bean 对应的接口注册到注册中心。
注册中心在收到新上线的服务提供方地址的时候,会把这个地址推送到调用方应用内存中;
当调用方收到这个服务提供方地址的时候,就会去建立连接发请求。

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

–>

如何避免?

问题原因:
服务提供方应用在没有启动完成的时候,调用方的请求就过来了,
而调用方请求过来的原因是,
服务提供方应用在启动过程中把解析到的 RPC 服务注册到了注册中心,这就导致在后续加载没有完成的情况下服务提供方的地址就被服务调用方感知到了。

->
把接口注册到注册中心的时间挪到应用启动完成后。
具体的做法就是在应用启动加载、解析 Bean 的时候,如果遇到了 RPC 服务的 Bean,只先把这个 Bean 注册到 Spring-BeanFactory 里面去,而并不把这个 Bean 对应的接口注册到注册中心,只有等应用启动完成后,才把接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址。

可以保证应用在启动完后才开始接入流量, 但没有达到开始的目标
因为这时候应用虽然启动完成了,但并没有执行相关的业务代码,所以 JVM 内存里面还是冷的。
如果这时候大量请求过来,还是会导致整个应用在高负载模式下运行,从而导致不能及时地返回请求结果。
而且在实际业务中,一个服务的内部业务逻辑一般会依赖其它资源的,比如缓存数据。
如果能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后才去加载,就可以降低重启后第一次请求出错的概率。

具体实现

还是需要利用服务提供方把接口注册到注册中心的那段时间。
可以在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。
用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。整个应用启动过程如下图所示:

启动顺序图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第19张图片

总结

负载均衡 – 异常重试 – 优雅关闭 – 优雅启动
RPC 里面的启停机流程
虽然启停机流程看起来不属于 RPC 主流程,但是如果能在 RPC 里面把这些“微小”的工作做好,就可以让技术团队感受到更多的微服务带来的好处。

启动预热与延迟暴露,它们并不是 RPC 的专属功能,
在开发其它系统时,也可以利用这两点来减少冷启动对业务的影响。

思考

启动预热
当大批量重启服务提供方的时候,会导致请求大概率发到没有重启的机器上,这时服务提供方有可能扛不住

官方解答:
在非流量高峰的时候重启服务,将影响降到最低;
也可以考虑分批次重启,控制好每批重启的服务节点的数量,当一批服务节点的权重与访问量都到正常水平时,再去重启下一批服务节点。

如果是大批量重启,可以通过:
1、分时分批启动,就和灰度发布一样;
2、在请求低峰把,在热点的应用肯定是有使用低峰的;
3、如果必须同时大批量重启,为了保证服务的可用性,可以在低峰时期,限流,为PLUS服务,非PLUS的就提醒暂时不可用之类的友好提示。

1.启动时逐步增加流量
2.等服务资源完全启动完成,再去注册服务
3.最好在注册前预热jvm,比如提前加载业务缓存

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

自我保护

RPC 是解决分布式系统通信问题的一大利器,而分布式系统的一大特点就是高并发,所以说 RPC 也会面临高并发的场景。

在这样的情况下,提供服务的每个服务节点就都可能由于访问量过大而引起一系列的问题,比如业务处理耗时过长、CPU 飘高、频繁 Full GC 以及服务进程直接宕机等等。
但是在生产环境中,要保证服务的稳定性和高可用性,这时就需要业务进行自我保护,从而保证在高访问量、高并发的场景下,应用系统依然稳定,服务依然高可用。

那么在使用 RPC 时,业务自我保护最常见的方式就是限流了,限流方式可以是多种多样的。
将 RPC 框架拆开来分析,RPC 调用包括服务端和调用端,调用端向服务端发起调用。

服务端的自我保护

<RPC实战与核心原理>学习笔记 --- 进阶篇_第20张图片
if 服务端的某个节点负载压力过高
负载压力高 -> 不让这个节点接收太多的请求

在 RPC 调用中服务端的自我保护策略就是限流,是在服务端的业务逻辑中做限流吗?有没有更优雅的方式?

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

限流示意图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第21张图片

服务端的限流逻辑

限流算法

  • 计数器 (最简单)
  • 滑动窗口 (可以做到平滑限流)
  • 漏斗算法
  • 令牌桶算法 (最常用)

eg
发布了一个服务,提供给多个应用的调用方去调用,这时有一个应用的调用方发送过来的请求流量要比其它的应用大很多,这时就应该对这个应用下的调用端发送过来的请求流量进行限流。
所以说在做限流的时候要考虑应用级别的维度,甚至是 IP 级别的维度,这样做不仅可以对一个应用下的调用端发送过来的请求流量做限流,还可以对一个 IP 发送过来的请求流量做限流。

使用方该如何配置应用维度以及 IP 维度的限流呢?
通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。

在服务端实现限流,配置的限流阈值是作用在每个服务节点上的。
eg:
比如说配置的阈值是每秒 1000 次请求,那么就是指一台机器每秒处理 1000 次请求;
如果服务集群拥有 10 个服务节点,那么提供的服务限流阈值在最理想的情况下就是每秒 10000 次。

另一个场景:
一个服务的业务逻辑依赖的是 MySQL 数据库,由于 MySQL 数据库的性能限制,需要对其进行保护。
假如在 MySQL 处理业务逻辑中,SQL 语句的能力是每秒 10000 次,那么提供的服务处理的访问量就不能超过每秒 10000 次,而的服务有 10 个节点,这时配置的限流阈值应该是每秒 1000 次。
if 服务扩容到 20 个节点,是不是就要把限流阈值调整到每秒 500 次呢?
这样操作每次都要自己去计算,重新配置,显然太麻烦了。

->

可以让 RPC 框架自己去计算,当注册中心或配置中心将限流阈值配置下发的时候,可以将总服务节点数也下发给服务节点,之后由服务节点自己计算限流阈值

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

因为限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。

->

更精确的限流方式
提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值
甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。

限流方式可以让整个服务集群的限流变得更加精确,
但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。
至于要选择哪种限流方式,就要结合具体的应用场景进行选择了。

调用端的自我保护

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

服务异常示意图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第22张图片

在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机。

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

示意图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第23张图片

熔断机制

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

  • 在正常情况下,熔断器是关闭的;
  • 当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;
  • 当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。

在业务逻辑中加入熔断器其实是不够优雅的。在 RPC 框架中,该如何整合熔断器呢?
熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器。
RPC 的调用流程:
<RPC实战与核心原理>学习笔记 --- 进阶篇_第24张图片
在动态代理整合熔断器比较合适,
因为在 RPC 调用的流程中,动态代理是 RPC 调用的第一个关口。
在发出请求时先经过熔断器,

  • 如果状态是闭合则正常发出请求,
  • 如果状态是打开则执行熔断器的失败策略

总结

熔断是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为;
而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为

服务端主要是通过限流来进行自我保护,
在实现限流时要考虑到应用和 IP 级别,方便在服务治理的时候,对部分访问量特别大的应用进行合理的限流;
服务端的限流阈值配置都是作用于单机的,
而在有些场景下,例如对整个服务设置限流阈值,服务进行扩容时,限流的配置并不方便,
可以在注册中心或配置中心下发限流阈值配置的时候,将总服务节点数也下发给服务节点,让 RPC 框架自己去计算限流阈值
还可以让 RPC 框架的限流模块依赖一个专门的限流服务,对服务设置限流阈值进行精准地控制,
但是这种方式依赖了限流服务,相比单机的限流方式,在性能和耗时上有劣势。

调用端可以通过熔断机制进行自我保护,防止调用下游服务出现异常,或者耗时过长影响调用端的业务逻辑,
RPC 框架可以在动态代理的逻辑中去整合熔断器,实现 RPC 框架的熔断功能。

RPC业务保护的其他方案

服务保护一般就是限流、熔断、降级。

  • 限流的落地方式有:Guava RateLimiter、lua+Redis、Sentinel等;
  • 熔断:Hystrix、Resilience4j;
  • 降级:服务降级,就是对不怎么重要的服务进行低优先级的处理。尽可能的把系统资源让给优先级高的服务。资源有限,而请求是无限的。
官方:
降级: 当一个服务处理大量的请求达到一定压力的时候,可以让这个服务在处理请求时减少些非必要的功能,从而降低这个服务的压力。
可以通过服务治理,降低一个服务节点的权重来减轻某一方服务节点的请求压力,达到保护这个服务节点的目的

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

突发流量

  • 限流
  • 隔离流量
    面对复杂的业务以及高并发场景时,另一种可以最大限度地保障业务无损的手段

接口 + 分组管理 -> 流量隔离
一个调用方的流量突然激增,让整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降

  • 以前, 人车都行驶在一条道路
  • 人车分流, 保障行人安全, 提升出行效率

实现

要求不同的调用方应用能拿到的池子内容不同
->
调用方获取服务节点的逻辑: 服务发现
服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的
改造:

  • 调用方去获取服务节点的时候通过 接口+分组参数
  • 服务提供方在注册的时候也要带上 分组参数

分组标准:
按照应用重要级别划分:

  • 非核心应用不要跟核心应用分在同一个组,
  • 核心应用之间应该做好隔离,
  • 一个重要的原则就是保障核心应用不受影响

eg:
提供给电商下单过程中用的商品信息接口,肯定是需要独立出一个单独分组,避免受其它调用方污染的。
服务调用方跟服务提供方之间的分组调用拓扑图
<RPC实战与核心原理>学习笔记 --- 进阶篇_第25张图片

通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。
对服务提供方来说,这种方式是日常治理服务过程中一个高频使用的手段,
通过这种分组进行流量隔离,对调用方应用会不会有影响呢?

如何实现高可用

分组隔离后,单个调用方在发 RPC 请求的时候可选择的服务节点数相比没有分组前减少了
那对于单个调用方来说,出错的概率就升高了。

eg:
一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,
在这种情况下对于服务调用方来说,它的请求无论如何也到达不了服务提供方,从而导致这个调用方业务受损。

马路:

  • 正常情况下人车分离。
  • 但当人行道或者车道出现抢修的时候,在条件允许的情况下,一般都是允许对方借道行驶一段时间,直到道路完全恢复。

调用方应用服务发现的时候,接口名 + 特定分组名, 拿不到其它分组的服务节点的
->
调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,否则分组就没有意义了
->
允许调用方可以配置多个分组。调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义
->
配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;
只要主分组里面的节点恢复正常,就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用。

思考

开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率

官方
配置不同的注册中心,开发人员将自己的服务注册到注册中心 A 上,而测试人员可以将自己的服务注册到测试专属的注册中心 B 上,
这样测试人员在验证功能的时候,调用端会从注册中心 B 上拉取服务节点,开发人员重启自己的服务是影响不到测试人员的。

k8s,“命名空间”的概念,RPC 框架如果支持“命名空间”,也是可以解决这一问题的。

使用的不同的注册中心,就是注册中心是部署了3份,prod、qa、test等,其实test和dev用的是同一个注册中心,因为注册中心内部也有环境的区分,服务在往注册中心注册时,需要说明自己的环境。
所以目前服务间的调用是:
prod走生产网关prod.gateway.com(网关同步注册中心信息) 环境参数默认prod
qa走生产网关qa.gateway.com(网关同步注册中心信息) 环境参数默认qa
test和dev走 test.gateway.com(网关同步注册中心信息),test需要环境的参数,test就是tets,dev就是dev

你可能感兴趣的:(rpc,rpc,java,网络协议)