跳至主要內容

RPC 重点难点

holic-x...大约 101 分钟架构RPC

RPC 重点难点

学习核心

​ 掌握 RPC 框架基础架构和一系列治理功能、集群管理相关的高级功能(服务发现、健康检查、路由策略、负载均衡、优雅启停等等)

  • 架构设计:

    • ① RPC 架构(核心基础功能设计:Bootstrap模块、集群模块、协议模块、传输模块)
    • ② 可扩展架构-插件化架构(基于插件化思想解决架构的可扩展功能)
  • 服务发现:实时感知集群IP的变化,实现接口跟服务集群节点IP的映射

    • ① 服务发现方案对比(基于服务提供者IP与接口映射):DNS、DNS+VIP(负载均衡)、注册中心
      • DNS:由于DNS的多级缓存机制,客户端无法及时感知服务提供方IP的变化
      • DNS + VIP:基于DNS+负载均衡思路,将VIP绑定到DNS中,然后由服务调用方与VIP交互获取到真正的调用IP。虽然可以有效解决IP节点更换问题,但需手动维护、整体成本较高、不具备灵活性
      • 注册中心:基于服务订阅 和 服务发现,将服务提供者IP注册到注册中心,然后由服务调用者通过指定信息从注册中心中定位内容
    • ② 基于Zookeeper的注册中心
      • 潜在的性能问题:针对大批节点同时上下线、大批服务变更请求激增、集群间各节点需同步大量服务节点数据等场景,可能会导致注册中心负载过而宕机、各节点数据不一致、服务下发不及时等问题,主要还是Zookeeper的性能问题导致
    • ③ 基于消息总线的注册中心
      • CP VS AP:在分布式场景中,有时候并不需要过于追求强一致性(CP方案),而是采取一些方案(AP方案)在基于系统稳定的情况下达到最终一致性
      • 通过采用"消息总线"的通知机制,来保证注册中心数据的最终一致性,以此解决这些问题
  • 健康监测:

    • 【心跳检测】机制:
      • 引入【心跳检测】机制可以辅助过滤掉一些问题节点,避免业务执行受到影响
      • 但由于网络波动和服务负载的限制,单纯依赖【心跳检测】机制可能会造成一些误判的情况
    • 【业务请求因素】辅助:基于单一使用【心跳检测】机制的场景下可能会出现的一些问题,思考是否可以引入一些辅助指标作为问题节点的校验依据
      • 业务请求指标:针对业务请求,考虑到请求频率和接口响应时间(TPS)不同,因此无法单纯将这两个业务请求指标单一作为辅助校验
      • 可用率:引入可用率突破口,限定在一段时限内接口请求响应成功的百分比阈值,如果低于这个设定的阈值则认为服务节点不可用(兼顾了上述业务指标的局限性)
  • 路由策略:多场景的路由选择:根据不同的场景控制选择合适的目标机器

    • ① 问题切入:基于业务上线变更的隐藏问题切入,思考如何降低业务上线变更风险?
      • 场景测试覆盖:尽可能在测试阶段覆盖全场景流程测试,但是根据实际业务经验这个过程很难做到全覆盖
      • 灰度发布、流量隔离:在升级改造应用的场景中,为了保证调用方可以平滑地切换调用新应用逻辑,一般采用灰度发布,先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果
    • ② 路由策略:RPC中通过路由策略可以实现灰度发布、流量隔离等场景应用
      • IP路由策略:调用方可以通过IP路由策略进行过滤和筛选,请求到指定的服务。相对于传统的灰度发布功能,可以做到只让部分调用方请求到新上线的实力(限制调用),以此降低试错成本
      • 参数策略:针对系统平滑升级新老应用流量切换的过程,为了保证整个流程的完整性,必须限定某个主题对象所有的请求都是同一个应用来承接,因此通过引入【参数策略】,通过对新老应用进行标签标记,实现更加细粒度的流量隔离和定点调用场景
  • 负载均衡:节点负载差距这么大为什么受到的流量还是一样?

    • ① 问题切入:集群节点存在一些性能配置较低的服务节点,在流量高峰的时候请求达到这些低配节点上无法承载导致可用率下降
      • 问题解决思路:引入负载均衡,当发现服务可用率低的时候通过配置服务节点权重,尽可能将请求调整到高可用节点上。但这个发现和处理的过程中可能存在较大的时间差,会导致业务有损,需思考是否存在一种更加灵活的自适应负载均衡机制
    • ② RPC 框架的负载均衡:不同于传统的负载均衡实现,RPC框架并不依赖于外置的负载均衡设备或者负载均衡服务器来实现负载均衡,而是由RPC框架本身来实现的,服务调用者可以自主选择服务节点,发起服务调用
    • ③ 自适应负载均衡设计:根据服务调用者依赖的服务集群中每个节点的自身状态,智能地控制发送给每个服务节点的请求流量,防止因某个服务节点负载过高、请求处理过慢而影响到整个服务集群的可用率
      • 设计核心:指标收集器、打分器、权重计算器、随机权重策略
  • 异常重试:为了尽可能保证幂等接口可用率的一种手段,这种策略只能用于幂等接口否则会因为多次重试导致应用系统数据写花

    • ① 问题切入:业务调用的过程中可能由于网络抖动导致请求响应超时或失败,需要触发节点重新调用以确保业务的正常运行
      • 基础做法:自行捕获catch异常并主动触发重试,但这种方式不够灵活
      • RPC的重试机制:使用RPC框架发起调用,如果捕获到RPC可以处理的异常就会触发重试,用户可以自定义调整重试配置(重试次数、重试间隔时间等)
    • ③ 重试可能导致的问题(重试注意事项):幂等性超时控制问题节点处理业务异常白名单
      • 业务幂等性的考虑:重试需确保被调用方业务逻辑的幂等性,否则会影响业务
      • 连续重试可能导致的问题:
        • 多次重试导致超出超时时间控制(触发失败):多次重试超出超时时间控制,因此在每次重试之前需要先判定是否已超时,如果未超时则重置超时时间后重试
        • 重复访问到问题节点:每次重试之前需要将问题节点排除,否则可能再次访问到问题节点,降低重试成功率
      • 重试触发的异常类型:
        • 针对一些业务场景下需要基于业务异常类型触发重试,RPC本身没有提供校验自定义业务异常的机制(默认只在RPC框架允许的异常类型下触发重试),因此可以自行设定异常白名单机制,将相关的业务异常类型加入白名单,允许白名单中的异常类型可以触发重试
  • 优雅关闭

    • ① 问题切入:业务调用过程中请求到了进入关闭流程的服务而触发异常

    • ② 策略分析:基于人工通知机制、服务发现、主动通知

      • 人工通知:基于人工通知的形式,当服务要上线迭代的时候通知接口调用方,让其请提前切好流量,避免直接调用到上线中的服务

      • 服务发现:基于服务发现的机制,让调用方感知服务的状态

        • 当要上线节点的时候,由服务方给注册中心发送通知,然后注册中心再通知服务调用方相关节点已下线。在大规模集群场景中服务发现只保证最终一致性,而不保证实时性,因此通过服务发现无法做到应用无损关闭
      • 主动通知:由服务提供方维护调用方连接集合,当服务提供方要关闭的时候依次通知相关的调用方去下线这台机器,以此避免对服务发现的强依赖,缩短调用链路。大部分场景下可以采用这种方案,但实际线上还是偶尔会出现因为服务提供方上线而导致调用失败的问题,还是由于时间差问题,由于请求的时间点与服务方通知的时间点很近,导致请求触发的时候还没被通知到节点已下线,而真正处理请求的时候却发现节点服务已陆续关闭,进而导致调用处理错误(服务提供方没有正确处理关闭后接收到的新请求)

    • ③ 优雅关闭

      • 核心:主动通知,状态控制,重试请求
        • 主动通知:通过捕获操作系统的进程信号捕获到关闭事件并处理(涉及两个核心处理程序:开启/关闭标识处理、安全关闭服务对象)
        • 状态控制,转发请求:当服务方正在关闭时收到了新的业务请求,服务提供方直接返回一个特定异常给调用方(例如ShutdownException)告诉调用方已收到请求,但目前服务正在处理关闭,无法处理这个请求。当调用方接收到异常响应之后,RPC框架将这个节点从健康节点列表移出并自动重试到其他节点
  • 优雅启动

    • ① 问题切入:刚重启的服务提供方因为没有预跑就承担了大流量,部分数据或接口还没反应过来进而导致请求异常

    • ② 启动预热:从调用方的角度出发,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常水平,从而实现平滑上线

      • 基于RPC调用过程分析,根据服务启动时间调整负载均衡的权值比例,以此区分新启动的服务,避免一次性将流量打到新启动的服务上
    • ③ 延迟暴露:从提供方的角度出发,延迟新启动服务的注册时间(即将启动新服务的IP注册到注册中心的操作延后,让调用方延迟拿到新服务的IP),尽量在将新服务IP注册到注册中心之前预热好相关的应用启动、缓存预热等操作,避免新应用初始阶段无法承载流量,降低重启后第一次请求出错的概率

      • 【思路1】:如果遇到RPC服务相关的Bean,将其注册到Spring-BeanFactory里面去,而并不把这个Bean对应的接口注册到注册中心。只有等应用启动完成后,才把接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址
      • 【思路2】:在服务提供方应用启动后,接口注册到注册中心前,预留一个Hook过程,让用户可以实现可扩展的Hook逻辑(进而实现预加载逻辑)
  • 熔断限流:业务如何实现自我保护

    • ① 针对服务端的自我保护(限流):限流实现要考虑到应用和IP级别,便于服务治理时对部分访问量特别大的应用进行合理的限流
      • (1)单机限流方案:
        • 在服务端配置限流阈值,作用于单机(但在服务节点扩容场景下,限流配置调整并不方便)
        • 整合配置中心以动态调整限流阈值(提供服务总节点个数、最大限流阈值,RPC框架根据配置自动计算单机限流阈值),但无法做到精确限流
      • (2)集群限流方案:外接限流服务(让每个节点依赖于一个专门的限流服务)以达精确限流目的,在调用端发送请求时先调用限流服务判断请求是否超出阈值,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常
    • ② 针对客户端的自我保护(熔断):
      • 引入目的:调用端可以通过熔断机制进行自我保护,防止调用下游服务出现异常,或者耗时过长影响调用端的业务逻辑
      • 如何实现:RPC框架可以在动态代理的逻辑中去整合熔断器,实现RPC框架的熔断功能
  • 业务分组:隔离流量(根据"道路划分"案例分析)

    • ① 业务分组:按照一定规则(没有限定规则,可以参考按照业务应用的重要程度)对提供的服务进行分组,通过【接口名+分组名】进行分组绑定
    • ② 主次分组:
      • 问题切入:如果某个分组服务节点宕机,对于单个调用方来说出错的概率就会提升进而导致业务受损。如果允许调用方配置多分组来解决的话,就失去了原有的"分组隔离"的意义
      • 解决方案:将配置的分组区域按照主次分组,只有在主节点不可用的情况下才去选择次分组节点

学习资料

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

1.RPC 架构(基础版)

​ RPC本质上就是一个远程调用,那肯定就需要通过网络来传输数据。虽然传输协议可以有多种选择,但考虑到可靠性的话,一般默认采用TCP协议。为了屏蔽网络传输的复杂性,需要封装一个单独的数据传输模块用来收发二进制数据,这个单独模块可以叫做传输模块

​ 用户请求的时候是基于方法调用,方法出入参数都是对象数据,对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制(即序列化过程)。但只是把方法调用参数的二进制数据传输到服务提供方是不够的,需要在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,在两个“断句”符号中间放的内容就是请求的二进制数据,这个过程叫做协议封装。**虽然这是两个不同的过程,但其目的都是一样的,都是为了保证数据在网络中可以正确传输。**这里我说的正确,可不仅指数据能够传输,还需要保证传输后能正确还原出传输前的语义。所以我们可以把这两个处理过程放在架构中的同一个模块,统称为协议模块

​ 除此之外,还可以在协议模块中加入压缩功能,这是因为压缩过程也是对传输的二进制数据进行操作。在实际的网络传输过程中,请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题,可以在RPC调用的时候这样操作:在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,可以通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原

​ 传输和协议这两个模块是RPC里面最基础的功能,它们使对象可以正确地传输到服务提供方。但距离RPC的目标——实现像调用本地一样地调用远程,还缺少点东西。因为这两个模块所提供的都是一些基础能力,要让这两个模块同时工作的话,需要手写一些黏合的代码,但这些代码对使用RPC的研发人员来说是没有意义的,而且属于一个重复的工作,会导致使用过程的体验非常不友好。

​ 这就需要在RPC里面把这些细节对研发人员进行屏蔽,让开发者感觉不到本地调用和远程调用的区别。假设有用到Spring的话,可以把一个RPC接口定义成一个Spring Bean,并且这个Bean也会统一被Spring Bean Factory管理,可以在项目中通过Spring依赖注入到方式引用。针对RPC调用的入口,一般叫做Bootstrap模块

​ **基于此,一个点对点(Point to Point)版本的RPC框架就完成了。一般称这种模式的RPC框架为单机版本,因为它没有集群能力。所谓集群能力,就是针对同一个接口有着多个服务提供者,但这多个服务提供者对于调用方来说是透明的,所以在RPC里面还需要给调用方找到所有的服务提供方,并需要在RPC里面维护好接口跟服务提供者地址的关系,**这样调用方在发起请求的时候才能快速地找到对应的接收地址,这就是常说的服务发现

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

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

​ 基于此,一个比较完善的RPC框架基本就完成了。按照分层设计的原则,将功能模块分为了四层,具体内容见图示:

image-20241219170658971

2.可扩展的架构(插件化架构)

​ 任何软件的开发都不是一蹴而就的,技术迭代谁都躲不过。一些系统可能一开始看起来很完善,也能稳定运行,但是交付给业务方后当有一天业务方有了新的需求,要加入很多新的功能,这时候就会发现当前架构面临的可就是大挑战了,要修改很多地方才能实现。

​ 举个例子,假如设计了一个商品发布系统,早些年只能在网上购买电脑、衣服等实物商品,但现在发展成可以在网上购买电话充值卡、游戏点卡等虚拟商品,实物商品的发布流程是需要选择购买区域的,但虚拟商品并没有这一限制。如果想要在一套发布系统里面同时完成实物和虚拟商品发布的话,就只能在代码里面加入很多的if else判断逻辑,但也有可能整个代码就臃肿、杂乱了,后期也极难维护

​ 同理,设计RPC框架也是一样的,不可能在开始时就面面俱到。那有没有更好的方式来解决这些问题呢?此处引入一个术语插件化架构

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

​ 但在实际项目中,其实很少使用到JDK自带的SPI机制(存在两个致命缺点:无法按需加载,难以和其他框架集成),首先它不能按需加载,ServiceLoader加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个Spring Bean,原生的Java SPI就不支持。

​ 加上了插件功能之后,现有的RPC框架就包含了两大核心体系——核心功能体系与插件体系

image-20241219172156140

此时整个架构就变成了一个微内核架构,将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入RPC导致的包版本冲突问题。

⚽服务发现:CP VS AP?

1.何为服务发现?

​ 结合实际的案例场景分析,假如你要给一位以前从未合作过的同事发邮件请求帮助,但你却没有他的邮箱地址。这个时候你会怎么办呢?如果是我,我会选择去看公司的企业“通信录”。

​ 同理,为了高可用,在生产环境中服务提供方都是以集群的方式对外提供服务,集群里面的这些IP随时可能变化,也需要用一本“通信录”及时获取到对应的服务节点,这个获取的过程一般叫作“服务发现”。对于服务调用方和服务提供方来说,其契约就是接口,相当于“通信录”中的姓名,服务节点就是提供该契约的一个具体实例。服务IP集合作为“通信录”中的地址,从而可以通过接口获取服务IP的集合来完成服务的发现。

image-20241219173059532

​ 这个过程主要涉及三个对象:注册中心、服务调用方、服务提供方,服务发现的过程包括两个核心步骤:

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

2.为什么不使用DNS?

​ 结合上述分析,服务发现的本质就是为了完成接口与服务提供者IP的映射,那么可以思考是否将服务提供者IP统一换成一个域名,利用已经成熟的DNS机制来实现。

image-20241219173547979

​ 如果用DNS来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过DNS拿到随机的一个服务提供者的IP,并与之建立长连接,这看上去并没有太大问题,但可以试着思考在业界为什么很少用到这种方案呢?(从服务发现的及时性思考:新增和删除服务节点是否可以即时生效?

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

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

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

image-20241219175834796

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

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

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

3.基于Zookeeper的服务发现

基于Zookeeper的服务发现

​ 回到服务发现的本质,就是完成接口跟服务提供者IP之间的映射,这个映射可以理解为一种命名服务?此外,还希望注册中心能完成实时变更推送,像开源的ZooKeeper、etcd就可以实现。下述介绍一种基于ZooKeeper的服务发现方式。整体的思路很简单,就是搭建一个ZooKeeper集群作为注册中心集群,服务注册的时候只需要服务节点向ZooKeeper节点写入注册信息即可,利用ZooKeeper的Watcher机制完成服务订阅与服务下发功能,整体流程如下图:

image-20241219180243130

① 服务平台管理端先在ZooKeeper中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息。

② 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息

③ 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方watch该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据

④ 当服务提供方目录下有节点数据发生变更时,ZooKeeper就会通知给发起订阅的服务调用方

Zookeeper 服务发现的潜在问题

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

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

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

​ 经过技术排查,引发这次问题的根本原因就是ZooKeeper本身的性能问题,当连接到ZooKeeper的节点数量特别多,对ZooKeeper读写特别频繁,且ZooKeeper存储的目录达到一定数量的时候,ZooKeeper将不再稳定,CPU持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper就因无法承受瞬间的读写压力,马上宕机。这次“意外”也让开发人员充分意识到,ZooKeeper集群性能显然已经无法支撑现有规模的服务集群了,需要重新考虑服务发现方案。

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

​ ZooKeeper的一大特点就是强一致性,ZooKeeper集群的每个节点的数据每次发生更新操作,都会通知其它ZooKeeper节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了ZooKeeper集群性能上的下降。这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。

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

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

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

image-20241219180659401

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

基于合法性验证和重试策略解决时间差问题:由于存在时间差,可能会导致服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,可以将这块的验证放到RPC框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点

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

⚽健康监测:节点已挂,为什么还要疯狂发请求?

1.场景分析:节点挂了为什么还会疯狂发请求

场景切入

​ 因为有了集群,所以每次发请求前,RPC框架会根据路由和负载均衡算法选择一个具体的IP地址。为了保证请求成功,就需要确保每次选择出来的IP对应的连接是健康的。但实际场景中,调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况,那怎么保证选择出来的连接一定是可用的呢?

最核心的解决方案是让调用方实时感知到节点的状态变化,这样才能做出正确的选择。和现实场景中的开车一样,车有各种各样的零件,但实际上司机不可能在开车之前先去挨个检查下健康情况,而是应该有一套反馈机制(监测机制),比如今天车的大灯坏了,那中控台就可以提示;明天胎压不够了,中控台也能够收到提示。对于汽车中大部分关键零件的状态变化,司机作为调用方(车的使用者),都能够第一时间了解

场景案例分析

​ 一个线上的案例问题:在定位线上业务的某个接口可用性并不高(基本上十次调用里总会有几次失败)时,通过查看了具体的监控数据之后,发现只有请求具体打到某台机器的时候才会有这个问题,也就是说集群中有某台机器出了问题。为了快速恢复业务正常,可以考虑先把这台“问题机器”下线,以快速解决目前的问题。

image-20241219183603061

​ 但实际上问题并没有得到解决,既然这个机器提供的服务出现了问题,为什么RPC还会将请求分到这台有问题的机器上?

​ 接口调用某台机器的时候已经出现不能及时响应了,那为什么RPC框架还会继续把请求发到这台有问题的机器上呢?RPC框架还会把请求发到这台机器上,也就是说从调用方的角度看,它没有觉得这台服务器有问题。此时则可进一步跟踪问题时间点的监控和日志,发现下述几条关键信息:

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

​ **有了对这四个线索的分析,基本上可以得出这样的结论:**那台问题服务器在某些时间段出现了网络故障,但也还能处理部分请求。换句话说,它处于半死不活的状态。但是(是转折,也是关键点),它还没彻底“死”,还有心跳,这样,调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。

​ 虽然一开始为了快速解决问题,手动把那台问题机器下线了。但其实更大的问题是服务检测机制有问题(存在漏洞),有的服务本来都已经病危了,但却还以为只是个小感冒

2.健康监测的逻辑

​ 一般当服务方下线时,正常情况下肯定会收到连接断开的通知事件,在这个事件里面直接加处理逻辑即可(例如前面的汽车故障时中控通知)。但这里却不行,因为应用健康状况不仅包括TCP连接状况,还包括应用本身是否存活,很多情况下TCP连接没有断开,但应用可能已经“僵死了”。

​ 所以,业内常用的检测方法就是用心跳机制。==什么是心跳机制?==所谓心跳机制简单来说就是每个一段时间访问一下服务提供方确认对方的状态

​ 基于此,服务方的状态一般会有三种情况(①我很好,②我生病了,③没回复)。用专业的词来对应这三个状态就是:

健康状态:建立连接成功,并且心跳探活也一直成功;

亚健康状态:建立连接成功,但是心跳请求连续失败;

死亡状态:建立连接失败。

节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化,具体状态间转换图如下

​ 注意关注每个状态的转化,结合几种场景分析:

  • 初始化建立连接:这个阶段如果建立连接成功则处于【健康状态】,如果建立连接失败则处理【死亡状态】(这个阶段还不涉及【亚健康状态】这个状态)
  • 程序运行时:连接建立完成,如果程序在运行过程中发现处于【健康状态】的节点连续几次出现不能响应心跳请求的情况,那么就会被标记为【亚健康状态】
  • 生病状况:
    • 处于【亚健康状态】的节点后续如果恢复正常(连续几次都能正常响应心跳请求)则可以转为【健康状态】;
    • 但是如果病一直不好则会被断定为死亡节点即进入【死亡状态】,死亡之后还需要进行善后,例如关闭连接
  • 复活
    • 死亡并不是真正的死亡,还是存在复活机会的。如果某个时间点里,死亡的节点能够重连成功,那它就可以重新被标记为健康状态

​ 上述状态的转化思路不需要死记硬背,只需要理解实际和人的身体状态类似,从健康->死亡存在一个过渡的阶段(健康->亚健康->死亡),在这个过程中除却人死不能复生之外,对于程序而言是可以从亚健康、死亡状态逐步恢复到健康状态的。

​ 当服务调用方通过心跳机制了解了节点的状态之后,每次发请求的时候,就可以优先从健康列表里面选择一个节点。当然,如果健康列表为空,为了提高可用性,也可以尝试从亚健康列表里面选择一个,这就要看具体的策略了。

3.解决方案

​ 理解了服务健康检测的逻辑,再回到开头我描述的场景里,看看怎么优化。基于上述分析,理解一个节点从健康状态过渡到亚健康状态的前提是**“连续”心跳失败次数必须到达某一个阈值**(取决于具体的配置)。

​ 在上述问题场景中,节点的心跳日志只是间歇性失败(时好时坏),在这个过程中失败次数根本没到阈值,所以调用方会觉得它只是“生病”了,并且很快就好了,所以并没有将其移出可用列表,进而导致调用出现间歇性失败的情况

​ 那该怎么解决呢?最硬核的思路可能会想着既然问题出自于阈值设定,那是不是可以直接改下阈值配置,通过调低阈值的方式来处理。但从另一个角度思考,这种方式虽然可以快速解决问题,但是治标不治本,可能会导致两个问题:

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

​ 回到问题的本源,核心是服务节点网络有问题,心跳间歇性失败。但是基于上述分析,现在判断节点状态只有一个维度(心跳检测),那是不是可以考虑再加上业务请求的维度呢?可以试着顺着这个方向解决问题的。但紧接着又会发现了新的麻烦:

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

​ 通过上述分析思考,可以试着从可用率这个突破口。可用率的计算方式是某一个时间窗口内接口调用成功次数的百分比(成功次数/总调用次数)。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题

4.扩展应用

​ 除了在RPC框架里面会有采用定时“健康检测”,其实在其它分布式系统设计的时候也会用到“心跳探活”机制。

​ 比如在应用监控系统设计的时候,需要对不健康的应用实例进行报警,好让运维人员及时处理。和咱们RPC的例子一样,在这个场景里,也不能简单地依赖端口的连通性来判断应用是否存活,因为在端口连通正常的情况下,应用也可能僵死了。

​ 针对上述问题:可以让每个应用实例提供一个“健康检测”的URL,检测程序定时通过构造HTTP请求访问该URL,然后根据响应结果来进行存活判断,这样就可以防止僵死状态的误判。你想想,这不就是咱们前面讲到的心跳机制吗?

​ **加完心跳机制,是不是就没有问题了呢?**当然不是,因为检测程序所在的机器和目标机器之间的网络可能还会出现故障,如果真出现了故障,不就会误判吗?你以为人家已经生病或者挂了,其实是心跳仪器坏了…

​ 结合实际经验分析,有一个办法可以减少误判的几率,那就是把检测程序部署在多个机器里面,分布在不同的机架,甚至不同的机房。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。

⚽路由策略:如何让请求按照设定的规则发到不同的节点?

1.场景分析(基于业务上线变更的隐藏风险引出路由策略应用)

​ 在真实环境中服务提供方是以一个集群的方式提供服务,这对于服务调用方来说,就是一个接口会有多个服务提供方同时提供服务,所以RPC在每次发起请求的时候,都需要从多个服务提供方节点里面选择一个目标节点。既然这些节点都可以用来完成这次请求,那么就可以简单地认为这些节点是同质的(同质概念:即这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的)

​ 既然服务提供方是以集群的方式对外提供服务,那就要考虑一些实际问题。要知道每次上线应用的时候都不止一台服务器会运行实例,那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,尤其是发生重大变动的时候,导致应用不稳定的因素就变得很多。

​ 为了减少这种风险,一般会选择灰度发布应用实例,比如可以先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例。

​ 但这种方式不好的一点就是,线上一旦出现问题,影响范围还是挺大的。因为对于服务提供方来说,服务会同时提供给很多调用方来调用,尤其是像一些基础服务的调用方会更复杂,比如商品、价格等等,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损

​ 那对于RPC框架来说,有什么的办法可以减少上线变更导致的风险吗?

2.如何实现路由策略?

场景测试覆盖:在上线前把所有的场景都重新测试一遍?这也是一种方法,而且测试肯定是上线前的一个重要环节。但以实际业务经验来看,由于线上环境太复杂了,单纯从测试角度出发只能降低风险出现的概率,想要彻底验证所有场景基本是不可能的。

流量隔离:那如果没法100%规避风险,还能怎么办?可以考虑尽量减小上线出问题导致业务受损的范围。基于这个思路,可以在上线完成后,先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果。那在RPC框架里面我们具体该怎么实现呢?

① 通过【服务发现】特点,从注册中心处切入实现流量隔离(会给注册中心带来额外的逻辑处理负载及二开成本)

​ 在【服务发现】篇章中提到在RPC里面服务调用方是通过服务发现的方式拿到了所有服务提供方的IP地址,因此可以利用这个特点,从注册中心处切入。当选择要灰度验证功能的时候,可以让注册中心在推送的时候区别对待,而不是一股脑的把服务提供方的IP地址推送到所有调用方。换句话说就是,注册中心只会把刚上线的服务IP地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个IP地址的。

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

② IP 路由策略(在选择节点时加上筛选逻辑)

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

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

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

image-20241220111511908

3.参数路由

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

​ 但在有些场景下,可能还需要更细粒度的路由方式。比如,在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了100%且运行一段时间后才能去下线老应用。在流量切换的过程中,为了保证整个流程的完整性,必须保证某个主题对象的所有请求都使用同一种应用来承接。假设改造的是商品应用,那主题对象肯定是商品ID,在切流量的过程中,必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。

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

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

image-20241220112024765

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

总结

​ 在日常工作中,几乎每天都在做线上变更,每次变更都有可能带来一次事故,为了降低事故发生的概率,不光要从流程上优化操作步骤,还要使我们的基础设施能支持更低的试错成本。

灰度发布功能作为RPC路由功能的一个典型应用场景,可以通过路由功能完成像定点调用、黑白名单等一些高级服务治理功能。在RPC里面,不管是哪种路由策略,其核心思想都是一样的,就是让请求按照设定的规则发送到目标节点上,从而实现流量隔离的效果。

⚽负载均衡

1.场景分析

​ 问题分析:在实际的业务场景中,如果碰上流量高峰,突然发现线上服务的可用率降低了。经过排查发现,是因为其中有几台机器比较旧了。当时最早申请的一批容器配置比较低,缩容的时候留下了几台,当流量达到高峰时,这几台容器由于负载太高,就扛不住压力了。针对这种情况有没有好的服务治理策略?

​ 也就是说,在流量高峰的场景中,服务调用方调用到集群中一些配置比较低的节点,而由于负载过高这些节点无法承载导致线上服务可用率降低

​ 初步解决思路:控制服务节点的权重以限制访问流量,即在治理平台上调低这几台机器的权重,如此一来访问的流量自然就减少了。

​ 但基于上述分析,从发现到调整的过程中存在时间差,此时业务已经受到严重影响了才去解决,就会导致这段时间内业务遭受重大损失(因为发现服务可用率降低到阈值,到实际解决的这段时间业务可能会受到重大的影响),为了更好地解决问题,就会进一步思考:RPC框架有没有什么智能负载的机制?能否及时地自动控制服务节点接收到的访问量?

​ 这个需求其实很合理,这也是一个比较普遍的问题。确实,虽说服务治理平台能够动态地控制线上服务节点接收的访问量,但当业务方发现部分机器负载过高或者响应变慢的时候再去调整节点权重,真的很可能已经影响到线上服务的可用率了。基于此,可以以这个问题为背景,进一步学习RPC框架的负载均衡。

2.什么是负载均衡?

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

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

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

​ 上述问题场景中介绍的负载均衡主要还是应用在Web服务上,Web服务的域名绑定负载均衡的地址,通过负载均衡将用户的请求分发到一个个后端服务上

3.RPC框架中的负载均衡

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

image-20241220135637484

​ RPC负载均衡策略一般包括随机权重、Hash、轮询(取决于RPC框架自身的实现):

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

​ 由于负载均衡机制完全是由RPC框架自身实现的,所以它不再需要依赖任何负载均衡设备,自然也不会发生负载均衡设备的单点问题,服务调用方的负载均衡策略也完全可配,同时可以通过控制权重的方式,对负载均衡进行治理。基于此,通过设计一种自适应的负载均衡策略来实现动态地、智能地控制线上服务节点所接收到的请求流量

4.设计自适应的负载均衡

RPC的负载均衡完全由RPC框架自身实现,服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。从调用者的调度思考,调用者需要知道每个服务节点处理请求的能力,再根据服务处理节点处理请求的能力来判断要打给它多少流量?当一个服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。这就有点像日常工作中的分配任务,要多考虑实际情况。当一位下属身体欠佳,就少给他些工作;若刚好另一位下属状态很好,手头工作又不是很多,就多分给他一点。可以结合实际生活场景理解自适应的RPC负载均衡设计

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

​ 可以采用一种打分的策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一个分数。例如总分10分,如果CPU负载达到70%就扣减一定的分数(需要设计合理的打分策略)

② 如何根据这些指标来打分呢?

​ 此处有点像公司对员工进行年终考核,假定要考核专业能力、沟通能力和工作态度,这三项的占比分别是30%、30%、40%,当给一个员工的评分是10、8、8,那他的综合分数就是这样计算的:10*30%+8*30%+8*40%=8.6分

​ 同理,给服务节点打分也一样,可以为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。

③ 服务调用者给每个服务节点都打完分之后,会发送请求,此时又该如何根据分数去控制给每个服务节点发送多少流量呢?

​ 可以配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权重。例如给一个服务节点综合打分是8分(满分10分),服务节点的权重是100,那么计算后最终权重就是80(100*80%)。服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的80%(这里假设其他节点默认权重都是100,且指标正常,打分为10分的情况)

image-20241220143032415

基于上述图示理解分析RPC的自适应负载均衡核心:打分器、指标收集器、权重计算器、随机权重策略,理解收集->打分->权重计算->选择策略这一自适应过程

① 添加【服务指标收集器】并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器

  • 运行时状态指标收集器:收集服务节点CPU核数、CPU负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取
  • 请求耗时指标收集器:收集请求耗时数据,如平均耗时、TP99、TP999等

② 配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分

③ 通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点

5.总结分析

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

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

​ 基于**【如何设计一个自适应的负载均衡】**的思路分析,可以根据服务调用者依赖的服务集群中每个节点的自身状态,智能地控制发送给每个服务节点的请求流量,防止因某个服务节点负载过高、请求处理过慢而影响到整个服务集群的可用率。这个自适应负载均衡的实现方案,其实不只是应用于RPC框架中的负载均衡,它本身便是一个智能负载的解决方案,如果在工作中需要设计一个智能的负载均衡服务,那么完全可以参考

RPC框架的负载均衡关键点就是调用端收集服务端每个节点的指标数据,再根据各方面的指标数据进行计算打分,最后根据每个节点的分数,将更多的流量打到分数较高的节点上

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

1.场景分析

​ 问题分析:发起一次RPC调用去调用远程的一个服务,比如用户的登录操作,会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。当通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题(例如网络抖动),导致请求失败,而这个请求业务方希望它能够尽可能地执行成功,那这时要怎么做呢?

​ 最简单的就是失败时需要重新发起一次RPC调用,从代码实现的角度上来看就是在代码逻辑里catch一下在失败时重新发起一次调用,但这样做显然不够优雅,如果再次调用又失败了呢?还要继续嵌套catch?此时需要切入重试机制概念

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

2.重试机制

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

image-20241220170354012

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

异常重试触发的条件:

​ RPC框架的重试机制就是调用端发现请求失败时捕获异常,之后触发重试,那是不是所有的异常都要触发重试呢?

​ 因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。

异常重试需要注意的点:

① 由于网络抖动导致触发重试,业务逻辑被重复执行(需确保被调用服务业务逻辑的幂等性)

​ 例如在实际业务调用过程中出现了网络抖动的情况,例如当网络突然抖动了一下导致请求超时了,但这个时候调用方的请求信息可能已经发送到服务提供方的节点上,也可能已经发送到服务提供方的服务节点上,那如果请求信息成功地发送到了服务节点上,那这个节点正常情况下是要执行业务逻辑的。但是如果这个时候发起了重试,那么业务逻辑也是会被执行的。如果这个服务业务逻辑不是幂等的(例如插入数据操作),那触发重试的操作就会产生问题了

​ 因此在使用RPC框架的时候,要确保被调用的服务的业务逻辑是幂等的,这样才能考虑根据事件情况开启RPC框架的异常重试功能。

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

① 连续的异常重试执行,导致超出调用端设定的超时时间 (重置计时/超时时间)

​ 基于重试机制,还需要考虑连续重试对请求超时时间的影响。继续考虑这样一个场景:假设将调用端的请求超时时间设置为5s,结果发生了连续重试3次,每次都耗时2s,那这个请求最终成功的耗时是6s,超出了调用端设置的请求超时时间。即连续的异常重试可能会出现一种不可靠的情况:连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间

​ 最简单的做法就是每次重试之后重置一下计时(请求的超时时间),即当调用端发起RPC请求时,如果发送请求发生异常并触发了异常重试,可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试,进而解决因多次异常重试引发的超时时间失效的问题

② 处理失败的节点被重复选择,导致重试成功率不高 (每次发起重试、负载均衡选择节点的时候去除重试之前出现问题的节点,确保重试的成功率)

​ 当调用方通过RPC负载均衡选择了一个节点,将请求消息发送到这个节点,但此时这个节点由于负载过高导致请求处理失败。于是调用法触发重试,再次通过负载均衡选择节点,结果又恰好选到了这个之前出现过问题的节点,这种情况下重试的效果可能就会受到影响

​ 最典型的一个场景就是针对一些接口调用按次数计费的场景,如果因为上述情况影响重试效果,那么就会产生不必要的成本。因此需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率

③ 异常重试的范围可否支持限定的异常类型(重试异常白名单机制)

​ 基于上述分析,RPC框架的异常重试机制,是调用端发送请求之后,如果发送失败会捕获异常、触发重试,但并不是所有的异常都会触发重试的,只有RPC框架中特定的异常才会如此,比如连接异常、超时异常,而对于服务端业务逻辑抛给调用端的异常理论上是不能触发重试的。

​ 但是设想如果需要有这样的场景存在(对于一些服务端抛出给调用端的异常信息,而服务端出这个异常是允许调用端重新发起一次调用的)。比如这个场景:服务端的业务逻辑是对数据库某个数据的更新操作,更新失败则抛出个更新失败的异常,调用端可以再次调用,来触发服务端重新执行更新操作。那这个时候对于调用端来说,它接收到了更新失败异常,虽然是服务端抛回来的业务异常,但也是可以进行重试的

​ 基于此场景RPC框架的重试机制是无法感知哪些业务异常可以进行异常重试的,因此可以从业务逻辑处理的角度出发,自定义白名单机制。自行加个重试异常的白名单,将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后就可以采用自定义异常处理策略。如果这个异常是RPC框架允许重试的异常或者这个异常类型存在于可重试异常的白名单中,就允许对这个请求进行重试(触发重试)

​ 基于上述分析,可以初步设计一个可靠的重试机制

image-20241220173814095

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

1.场景分析

关闭存在的问题

​ 在“单体应用”复杂到一定程度后,一般会进行系统拆分,也就是时下流行的微服务架构。服务拆分之后,自然就需要协同,于是RPC框架就出来了,它用来解决各个子系统之间的通信问题。

​ 再思考一个最基础的问题,系统为啥非要拆分呢?于开发者的角度而言,拆分之后可以更方便、更快速地迭代业务。但与此同时也会衍生一个问题,如果为了适配业务迭代更新,不免需要更新应用系统,那么是不是还是要重启服务器吗?这样会对业务调用产生什么样的影响呢?

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

image-20241223094903033

在服务重启的时候,对于调用方来说,这时候可能会存在以下几种情况:

  • ① 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中
  • ② 调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中,因此可能导致无法拿到正确响应结果
  • ③ 调用方发请求的时候,目标服务正在启动(这种情况的分析基于【优雅启动】篇章展开)

关闭流程

​ 基于上述情况②是因为业务调用方的请求被转到正在关闭的目标服务,那么可以考虑在重启服务机器前,先通过“某种方式”把要下线的机器从调用方维护的“健康列表”里面删除就可以了,这样负载均衡就选不到这个节点,但这个具体的“某种方式”是怎么完成呢?

  • 思路①:最没有效率的办法就是人工通知调用方,通过手动摘除要下线的机器,这种方式很原始也很直接。但这样对于提供方上线的过程来说太繁琐了,每次上线都要通知到所有调用这个接口的团队,整个过程既浪费时间又没有意义,显然不能被正常接受
  • 思路②:RPC的服务发现(用来“实时”感知服务提供方的状态),可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除,关闭流程如下图所示:
image-20241223095559927

​ 虽然可以通过一种自动化方式替换传统人工通知的形式,但这么做并不能完全保证实现无损上下线(服务发现只保证最终一致性,并不保证实时性)。

  • 思路③:主动通知,由服务提供方维护调用方连接集合,主动通知相关调用方节点已下线,不强依赖于服务发现,缩短调用链路

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

​ 不能强依赖“服务发现”来通知调用方要下线的机器,那服务提供方自己来通知行不行?因为在RPC里面调用方跟服务提供方之间是长连接,可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个去通知调用方去下线这台机器。这样整个调用链路就变短了,对于每个调用方来说就一次RPC,可以确保调用的成功率很高。大部分场景下,这么做确实没有问题,但实际业务实践的过程中会发现线上还是会偶尔会出现因服务提供方上线而导致调用失败的问题

​ 上述问题主要还是一个时间差问题,通过分析调用方请求日志跟收到关闭通知的日志,会发现一个情况:出问题请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭通知的时间早不到1ms,如果再加上网络传输时间的话,那服务提供方收到请求的时候,它应该正在处理关闭逻辑。这就说明服务提供方关闭的时候,并没有正确处理关闭后接收到的新请求

2.优雅关闭

​ 上述问题的根本原因在于:服务提供方已经开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的。所以可以在关闭的时候,设置一个请求“挡板”,挡板的作用就是告诉调用方,服务已经开始进入关闭流程了,不能再处理请求。

​ 结合实际现实场景,例如一些政务或者银行柜台在交接班或者有其他要事情处理的时候,柜台工作人员会拿出一个纸板,放在窗口前,上面写到“该窗口已关闭”。在该窗口排队的人虽然有一万个不愿意,也只能换到其它窗口办理业务,因为柜台工作人员会把当前正在办理的业务处理完后正式关闭窗口。

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

​ 但如果只是靠等待被动调用,就会让这个关闭过程整体有点漫长。因为有的调用方那个时刻没有业务请求,就不能及时地通知调用方了,所以可以加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。

​ ==怎么捕获到关闭事件呢?==可以通过捕获操作系统的进程信号来获取,在Java语言里面,对应的是Runtime.addShutdownHook方法,可以注册关闭的钩子。在RPC启动的时候,提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。

关闭过程中已经在处理的请求会不会受到影响呢?关闭过程中可以接收并反馈节点下线但不处理新的请求,对于正在处理中的程序会待其处理完成之后再安全关闭服务。如果进程结束过快会造成这些请求还没有来得及应答,同时调用方会也会抛出异常。为了尽可能地完成正在处理的请求,首先要把这些请求识别出来。这就好比日常生活中,经常看见停车场指示牌上提示还有多少剩余车位,这个是如何做到的呢?如果仔细观察一下,你就会发现它是每进入一辆车,剩余车位就减一,每出来一辆车,剩余车位就加一。可以利用这个原理在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器我们就可以快速判断是否有正在处理的请求

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

image-20241223101553646

​ 在RPC里面,关闭虽然看似不属于RPC主流程,但如果不能处理得很好的话,可能就会导致调用方业务异常,从而需要加入很多额外的运维工作。一个好的关闭流程,可以确保使用框架的业务实现平滑的上下线,而不用担心重启导致的问题。

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

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

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

1.场景分析

​ 对于应用启动来说,同样有一个非常重要的概念"预热"。就好比如日常生活中的热车,行驶之前让发动机空跑一会,可以让汽车的各个部件都“热”起来,减小磨损。

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

​ 但是这些“临时数据”,都在应用重启后就消失了。重启后的这些“红利”没有了之后,如果直接让刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。

​ 在微服务架构里面,上线肯定是频繁发生的,那总不能因为上线,就让过来的请求出现大面积超时吧?既然问题的关键是在于“刚重启的服务提供方因为没有预跑就承担了大流量”,那可以试着让应用一开始只接少许流量,等待低功率运行一段时间后,再逐渐提升至最佳状态,这便是RPC中一个实用功能:启动预热

2.启动预热(基于服务调用方的角度)

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

RPC里面如何实现这个功能?方案分析

​ 方案核心是控制调用方发送到服务提供方的流量

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

具体实现

​ 首先对于调用方来说,要知道服务提供方启动的时间,有两种获取思路:一种是服务提供方在启动的时候,把自己启动的时间告诉注册中心;另外一种就是注册中心收到的服务提供方的请求注册时间。此处可能会犹豫该怎么确保所有机器的日期时间是一样的?这其实不用太关心,因为整个预热过程的时间是一个粗略值,即使机器之间的日期时间存在1分钟的误差也不影响,并且在真实环境中机器都会默认开启NTP时间同步功能,来保证所有机器时间的一致性。不管是选择哪个时间,最终的结果就是,调用方通过服务发现,除了可以拿到IP列表,还可以拿到对应的启动时间。

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

image-20241223142048685

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

​ 此处可能还会有另外一个疑问,即当在大批量重启服务提供方的时候,会不会导致那些没有重启的机器因为扛的流量太大而出现问题?

​ 关于这个问题,可以从两个方向考虑:大批量重启服务提供方的时候,对于调用方来说,这些刚重启的机器权重基本是一样的,也就是说这些机器被选中的概率是一样的,大家都是一样得低,也就不存在权重区分的问题了。但是对于那些没有重启过的应用提供方来说,它们被负载均衡选中的概率是相对较高的,但是可以通过自适应负载的方法平缓地切换,所以也是没有问题的。

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

3.延迟暴露(基于服务提供方的角度)

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

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

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

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

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

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

那具体怎么实现呢?

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

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

1.场景分析

❓为什么需要自我保护?

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

​ 在使用RPC框架时实现自我保护的最常见的方式就是限流,简单有效,但RPC框架的自我保护方式可不只有限流,并且RPC框架的限流方式可以是多种多样的。可以将RPC框架拆开来分析,RPC调用包括服务端和调用端,调用端向服务端发起调用,从这两个方向切入RPC的自我保护机制

2.自我保护

❓服务端的自我保护(限流)

场景分析

​ 场景分析:假如要发布一个RPC服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高了,该如何保护这个节点?

​ 解决方案:既然负载压力高,可以通过限流的方式,让它不要接收太多的请求,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了。在RPC调用中服务端的自我保护策略就是限流

​ 限流是一个比较通用的功能,有两种思路可以实现:

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

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

​ **① 限流逻辑的实现方式 **

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

​ 可以假设下这样一个场景:发布了一个服务,提供给多个应用的调用方去调用。当有一个应用的调用方发送过来的请求流量要比其它的应用大很多,此时就应该对这个应用下的调用端发送过来的请求流量进行限流。因此,在做限流的时候要考虑应用级别的维度,甚至是IP级别的维度,以对一个应用下或者一个IP下的调用端发送过来的请求流量做限流

② 使用方该如何配置应用维度以及IP维度的限流呢?在代码中配置是不是不大方便?=》整合配置中心实现动态配置,实现单机限流

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

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

③ 可否支持动态阈值配置:节点动态扩展时是否也要手动调整阈值?

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

​ 基于此,针对节点动态扩展的问题,可以让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->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的。

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

(1)什么是熔断机制?

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

​ 了解完熔断机制,会发现如果直接在业务逻辑中加入熔断器其实是不够优雅的。因此要思考如何在RPC框架中整合熔断器。熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器。可以回想下RPC的调用流程

image-20241223162749079

​ 结合RPC调用流程图示,可以试想一下在哪个步骤整合熔断器会比较合适呢?

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

3.总结

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

​ 熔断是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为;而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为。虽然这两种手段作用的对象不同,但出发点都是为了实现自我保护,所以一旦发生这种行为,业务都是有损的。

  • ① 针对服务端的自我保护(限流):限流实现要考虑到应用和IP级别,便于服务治理时对部分访问量特别大的应用进行合理的限流
    • (1)单机限流方案:
      • 在服务端配置限流阈值,作用于单机(但在服务节点扩容场景下,限流配置调整并不方便)
      • 整合配置中心以动态调整限流阈值(提供服务总节点个数、最大限流阈值,RPC框架根据配置自动计算单机限流阈值),但无法做到精确限流
    • (2)集群限流方案:
      • 实现:外接限流服务(让每个节点依赖于一个专门的限流服务)以达精确限流目的,在调用端发送请求时先调用限流服务判断请求是否超出阈值,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常
      • 缺点:依赖了限流服务,相比单机的限流方式,在性能和耗时上有劣势。
  • ② 针对客户端的自我保护(熔断):
    • 引入目的:调用端可以通过熔断机制进行自我保护,防止调用下游服务出现异常,或者耗时过长影响调用端的业务逻辑
    • 如何实现:RPC框架可以在动态代理的逻辑中去整合熔断器,实现RPC框架的熔断功能

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

1.场景分析

​ 说起突发流量,限流固然是一种手段,但其实面对复杂的业务以及高并发场景时,还有别的手段可以最大限度地保障业务无损,那就是隔离流量

❓为什么需要分组?(结合"道路划分"案例分析)

​ 思考一个问题:在日常开发中,一般提倡让用户使用起来越简单越好,但是如果在接口上再加一个分组维度去管理,不就让事情变复杂了吗?

​ 实则不然,举个例子。在没有汽车的年代,道路很简单,就一条,行人、洋车都在上边走。那随着汽车的普及以及猛增,道路越来越宽,慢慢地有了高速、辅路、人行道等等。很显然,交通网的建设与完善不仅提高了我们的出行效率,而且还更好地保障了我们行人的安全。

​ 同理,用在RPC治理上也是一样的。假设你是一个服务提供方应用的负责人,在早期业务量不大的情况下,应用之间的调用关系并不会复杂,请求量也不会很大,现有应用有足够的能力扛住日常的所有流量。此时可能并不需要花太多的时间去治理调用请求过来的流量,所以通常会选择最简单的方法,即把服务实例统一管理,把所有的请求都用一个共享的“大池子”来处理。这就类似于“简单道路时期”,服务调用方跟服务提供方之间的调用拓扑如下图所示:

​ 后期因为业务发展丰富了,调用接口的调用方就会越来越多,流量也会渐渐多起来。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降。而这时候作为应用负责人就得变身“救火队长”了,要想尽各种办法来保证应用的稳定。

​ 在经过一系列的救火操作后,肯定要去想更好的应对办法。那回到问题的根本去看,关键就在于,早期为了管理方便,把接口都放到了同一个分组下面,所有的服务实例是以一个整体对外提供能力的。

​ 但后期因为业务发展,这种粗暴的管理模式已经不适用了,这就好比“汽车来了,交通网也得抓紧建设”一样,让人车分流。此时,道路上的人和车就好比我们应用的调用方,可以尝试把应用提供方这个大池子划分出不同规格的小池子,再分配给不同的调用方,而不同小池子之间的隔离带,就是在RPC里面所说的分组,它可以实现流量隔离。

2.业务分组

❓ 如何实现分组?

​ 既然是要求不同的调用方应用能拿到的池子内容不同,因此要结合服务发现理解,因为在RPC流程里,能影响到调用方获取服务节点的逻辑就是它

​ 在前面的【服务发现】篇章分析中,服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,那换到这里的话,这样做其实并不合适,因为这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,需要重新改造下服务发现的逻辑,调用方去获取服务节点需要【接口名 + 分组参数】,相应的服务提供方在注册的时候也是需要提供【接口名 + 分组参数】

​ 通过改造后的分组逻辑,可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。那怎么分组好呢,有没有统一的标准?

​ 坦白讲,这个分组并没有一个可衡量的标准,但可以参考规则:按照应用重要级别划分

​ 非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。比如提供给电商下单过程中用的商品信息接口,肯定是需要独立出一个单独分组,避免受其它调用方污染的。有了分组之后,服务调用方跟服务提供方之间的调用拓扑就如下图所示

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

❓ 如何实现高可用?

​ 分组隔离后,单个调用方在发RPC请求的时候可选择的服务节点数相比没有分组前减少了,那对于单个调用方来说,出错的概率就升高了。比如一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,在这种情况下对于服务调用方来说,它的请求无论如何也到达不了服务提供方,从而导致这个调用方业务受损。

​ 那有没有更高可用一点的方案呢?回到前面说的那个马路例子上,正常情况下是必须让车在车道行驶,人在人行道上行走。但当人行道或者车道出现抢修的时候,在条件允许的情况下,一般都是允许对方借道行驶一段时间,直到道路完全恢复。

同理,RPC中要怎么实现"借道行驶"呢?=》主次分组

​ 在前面的分析中可以看到,调用方应用服务发现的时候,除了带上对应的接口名,还需要带上一个特定分组名,所以对于调用方来说,它是拿不到其它分组的服务节点的,那这样的话调用方就没法建立起连接发请求了。

​ 因此问题的核心就变成了调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,否则分组就没有意义了。一个最简单的办法就是,允许调用方可以配置多个分组。但这样的话,这些节点对于调用方来说就都是一样的了,调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义,并且还没有实现想要的“借道”的效果。

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

3.总结

​ 通过一个道路划分的案例,引出了在RPC里面可以通过分组的方式人为地给不同的调用方划分出不同的小集群,从而实现调用方流量隔离的效果,保障核心业务不受非核心业务的干扰。但在考虑问题的时候,不能顾此失彼,不能因为新加一个的功能而影响到原有系统的稳定性。

​ 其实不仅可以通过分组把服务提供方划分成不同规模的小集群,还可以利用分组完成一个接口多种实现的功能。正常情况下,为了方便自己管理服务,一般都会建议每个接口完成的功能尽量保证唯一。但在有些特殊场景下,两个接口也会完全一样,只是具体实现上有那么一点不同,那么就可以在服务提供方应用里面同时暴露两个相同接口,但只是接口分组不一样罢了

​ 问题思考:在实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。不知道面对这种情况,有什么好办法吗?

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3