关于分布式系统 RPC 中高可用功能的实现

关于分布式系统 RPC 中高可用功能的实现

在分布式系统(尤其是微服务架构)中,RPC 调用是核心组件。为了保证系统的高可用性和稳定性,我们必须面对网络抖动、服务宕机、流量突发等异常情况。熔断、限流、降级、超时是应对这些问题的最佳实践

熔断

熔断是为了防止雪崩效应。当下游服务不可用或响应过慢时,快速失败,不再请求该服务,给下游服务恢复的时间

其实现原理在客户端和服务端都类似,一般是通过断路器实现的:

断路器模式是为了处理服务雪崩而出现的,是快速失败策略的一种实现。简单来说处理过程是,当满足某种条件时(比如一定时间 内响应请求的成功率较小 并且数量较大,比如10秒内请求成功率为百分之五十,发了三百个请求),系统将通过断路器直接将此请求的所有链路都断开

其中某种条件可以基于异常比例 (例如 50% 的请求报错则熔断)或者基于慢调用比例(例如响应时间超过 500ms 的比例超过 50% 则熔断),如果判断被熔断了,那么直接返回失败,或者走降级逻辑

服务被熔断之后,熔断器将自动(一般是由下一次请求而不是计时器触发的,所以这里自动带引号)切换到半开启状态。该状态下,会放行一次远程调用,然后根据这次调用的结果成功与否,转换自身的状态,以实现断路器的弹性恢复

实现对比

spring cloud 借助 Hystrix,Dubbo 借助 Filter,服务端的两个组件都是使用状态机模式

特性 Nginx Spring Cloud Dubbo
实现方式 被动探测。利用 proxy_next_upstream,当后端返回错误码(如 502, 504)或超时时,Nginx 标记该节点不可用,在一段时间内不再转发给它 状态机模式。主动收集成功/失败比例,维护 Closed/Open/Half-Open 状态,通过 AOP 拦截调用 状态机 / Mock。通过 Filter 拦截请求,维护状态;或者利用 Dubbo 原生的 Mock 机制在 Cluster 层进行容错
感知维度 HTTP 状态码 / TCP 连接失败。它不关心业务抛了什么异常,只关心 HTTP 返回是不是 200 异常类型 / 响应时间。能捕获 Java Exception(如 NullPointerException),能感知慢调用 RPC 异常 / 响应时间。能捕获具体的 RPC 业务异常
触发策略 连续失败次数达到阈值 异常比例、慢调用比例、异常数 异常比例、并发数

限流

当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。这种情况常见于热点业务或者黑客攻击,早期的 12306 系统就明显存在这样的问题,全国人民都上去抢票的结果是全国人民谁都买不上票。限流算法可以解决这一问题,利用流量控制减少进入系统的请求量,以避免系统的崩溃

所有的限流算法都是当请求到达一定数目之后,丢弃溢出的请求来实现的,目的是保护系统不被突如其来的大流量压垮,保护服务提供方。那么我们如何实现这一功能呢,常用的限流算法有四种:

限流算法

1,固定窗口:又称流量计数器模式,这种算法将每一个时间段设置为一个窗口,当窗口容纳请求的数量满了之后,丢弃所有的溢出请求。这种算法有一个缺点,如下图所示

2,滑动窗口:这个算法使用先进先出解决上面的问题,在不断向前流淌的时间轴上,漂浮着一个固定大小的窗口,窗口与时间一起平滑地向前滚动。任何时刻静态地通过窗口内观察到的信息,都等价于一段长度与窗口大小相等、动态流动中时间片段的信息

假如我们准备观察时间片段为 10 秒,并以 1 秒为统计精度的话,那可以设定一个长度为 10 的数组(设计通常是以双头队列去实现,这里简化一下)和一个每秒触发 1 次的定时器。这也说明,滑动窗口其实和固定窗口差不多,但是将每一个窗口都切的更细以获取精确度

3,漏桶算法:处理完一个请求代表从桶中流出一颗水滴,每接受一个请求代表向桶中加入一颗水滴,桶可以接受的水滴有限。漏桶以固定速率向外漏出水滴,当水桶已满时便拒绝新的请求进入

现实中系统的处理速度往往受到其内部拓扑结构变化和动态伸缩的影响,所以能够支持变动请求处理速率的令牌桶算法往往可能会是更受程序员青睐的选择

4,令牌桶算法:和漏桶算法相反,固定时间段内向桶中加入一个令牌,令牌满了则丢弃令牌,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求就可以执行;如果桶空了,那么尝试取令牌的请求会被直接丢弃

实现对比

feign 和 Dubbo 实现限流都是需要借助 Sentinel 的

特性 Nginx Spring Cloud (e.g., Sentinel) Dubbo (e.g., Sentinel)
核心算法 漏桶 / 令牌桶 (基于 limit_req_zone) 滑动窗口 / 漏桶 滑动窗口 / 令牌桶
实现原理 内存/Redis共享内存。Nginx 工作进程中维护计数器。如果是集群模式,需要借助于 Lua + Redis JVM 内存。在应用内部通过 QPS 计数器或并发 semaphore 控制 JVM 内存。在 Dubbo Filter 过滤器链中统计
粒度 较粗。通常针对 IP、URL 或整个接口。难以针对"某个用户"或"某个参数"精细化控制 极细。可以针对方法代码块、特定参数(如 user_id=100)、甚至注解级别进行限流 极细。可以针对 Service 的某个 Method 进行限流
性能 极高。用 C 语言写的,基于事件循环,处理网络连接极快 高。但在 Java 堆内存计算,有对象开销 极高。基于 Netty,长连接复用,开销极小

超时

防止线程被无限期占用,避免资源耗尽

实现原理为客户端发起请求时记录时间戳,启动一个定时器,如果超过设定时间未收到响应,则抛出 TimeoutException

常见策略如下

  • 固定超时:配置一个固定的时间值(如 500ms)
  • 自适应超时:根据历史的响应时间(如 P99 或 P95)动态调整超时阈值

实现对比

不同框架对于超时的时间不同,其实现程度相当复杂,先来说一下配置优先级,服务端超时配置大于客户端超时配置

spring cloud 实现如下:

客户端超时设置 3秒,服务端超时设置 1秒。T+1s 服务端执行时间到了1秒。服务端线程抛出 TimeoutException,切断执行,向 Socket 写入一个业务异常/超时响应。T+1.x s 网络传输,客户端收到了这个响应。客户端此时还没到3秒,但是收到了服务端的响应。客户端解析发现是异常或超时,于是向上层抛出错误

按人类的理解,此时的优先级应该是双方配置超时时间较小的优先级高,这违反了客户端优先级吗?没有违反。这里需要区分两个概念:

  • 最大容忍时间(客户端超时):意思是我最多等3秒,如果3秒你还没给我结果,我就不等你了,直接报错
  • 执行承诺时间(服务端超时): 意思是我服务端资源有限,我只允许任务执行1秒。如果1秒没做完,为了不拖垮我的服务器(线程池满),我会强制终止任务

Dubbo 是如何实现的?Dubbo 的设计非常注重性能和保护服务端资源,它对超时的处理非常精细,Dubbo 中存在两个 Timeout 配置:

  • timeout(通常指调用超时,Consumer 端覆盖 Provider 端)
  • execution.timeout(在某些版本逻辑中,Provider 端独立配置的执行超时)

客户端发起请求时,会启动一个 Timeout 计时器(基于 HashedWheelTimer 时间轮)。如果到了客户端设定的超时时间还没收到响应,客户端会主动抛出 RpcException(Timeout)

服务端收到请求后,会解码请求。Dubbo 协议中可以携带超时时间信息。服务端在执行业务逻辑时,会检查是否配置了服务端专属的执行超时时间。但是这个超时时间通常是使用 Consumer 传过来的超时时间作为参考,或者 Provider 自己配置的 timeout

Dubbo 会把这个超时时间通过 Future 或者 RpcContext 传递给业务线程池。如果业务代码执行时间超过了这个阈值,Dubbo 的过滤器(如 TimeoutFilter)会介入。dubbo 的超时时间会取客户端和服务端超时时间的最小值

降级

当服务压力过大或不可用时,牺牲非核心功能,保证核心业务可用,降级会搭配熔断、限流、超时来使用,或者在系统负载过高时执行,会执行预先准备好的兜底逻辑

在 spring cloud 中,需要在服务提供者中编写熔断降级逻辑。在方法上加上 @HystrixCommand 注解,并指定 fallbackMethod 方法

java 复制代码
@ResponseBody
@RequestMapping(value = "/info", method = RequestMethod.GET)
@HystrixCommand(fallbackMethod = "backErrorInfo")
public String printInfo() {
    String url = "http://springbootdemo/backInfo";
    return restTemplate.getForObject(url, String.class);
}
public String backErrorInfo(){
    return "sorry,error";
}

实现对比

特性 Nginx Spring Cloud Dubbo
实现方式 静态降级。当后端挂了,直接返回 Nginx 本地配置的静态文件(如 return 503 {"msg":"繁忙"} 或重定向到静态维护页) 逻辑降级。执行 Java 代码编写的 Fallback 方法,可以返回缓存数据、默认值,或者调用备用接口 Mock 降级。返回配置的空值、Null 对象,或者执行一个 Mock 实现类
灵活性 低。只能返回简单的字符串或 HTML 页面,无法动态获取业务数据 极高。因为是写代码,可以查本地缓存、读数据库兜底数据 中。可以返回固定值或简单 Mock 对象,复杂逻辑需要写 Mock

舱壁隔离模式

又称服务隔离,它原本的意思是设计舰船时,要在每个区域设计独立的水密舱室,一旦某个舱室进水,也只是影响这个舱室中的货物,而不至于让整艘舰艇沉没

在微服务的场景下,舱室进水指的是,一些请求的处理时间相当长,占用了线程资源,一般来说,普通的 java 程序只会设置 200 到 300 条线程,如果该类型请求过多,导致所有的线程被占满,此时该服务就无法处理其他的请求了。水密舱室指的是处理方法,在这种情况出现时,我们一般有以下两种处理方法:

  • 使用线程池来接受该类型请求,线程池设置了最大线程数,就算接受了过多的请求,也不会占用太多机器线程资源
  • 使用信号量来统计该类型线程数,并且设置一个最大阈值,达到这个阈值就不给该资源分配线程资源

举例说明,假设你的系统有两个业务:

  • 业务 A:核心的下单业务(非常重要)。
  • 业务 B:非核心的生成报表业务(不重要)。

当报表服务挂了,处理很慢。如果使用了舱壁隔离,我们给 Business B 单独分配一个小线程池,Business A 使用剩余的大线程池,报表服务挂了,Business B 的 5 个线程很快被占满,新的 Business B 请求直接被拒绝(或者排队),但这 5 个线程的死锁不会影响到 Business A

失败策略

容错策略指的是面对故障,我们该做些什么,这些策略提供一些指导思想,让我们遇到错误时不至于漫无目的。一般对于远程调用框架而言,

1,故障转移

故障转移是指如果调用的服务器出现故障,系统不会立即向调用者返回失败结果,而是自动切换到其他服务副本,尝试其他副本能否返回成功调用的结果

故障转移的实现应当有一定的调用次数限制,以防止过多的错误调用影响系统性能

同时,被调用的接口应当有幂等性,比如 get、remove、put,不然可能会生成脏数据

2,快速失败

快速失败一般使用于一些在与金额支付相关的操作中,比如在支付场景中,需要调用银行的扣款接口,如果该接口返回的结果是网络异常,程序是很难判断到底是扣款指令发送给银行时出现的网络异常,还是银行扣款后返回结果给服务时出现的网络异常的。为了避免这种情况,程序应当尽快抛出异常,由调用者自行处理

3,安全失败

对于一些不重要的业务,一种理想的容错策略是即使旁路逻辑调用实际失败了,也当作正确来返回,如果需要返回值的话,系统就自动返回一个符合要求的数据类型的对应零值,然后自动记录一条服务调用出错的日志备查即可,这种策略被称为安全失败

4,沉默失败

在大量的请求需要等到超时(或者长时间处理后)才宣告失败的时候,很容易由于某个远程服务的请求堆积而消耗大量的线程、内存、网络等资源。此时我们让机器在一段时间内不在对外提供同类型服务,因为该次失败很可能下一次调用也失败。沉默失败会让系统不再向错误机器分配请求流量,将错误隔离开来,避免对系统其他部分产生影响

5,并行调用和广播调用

这两种算是以性能获取准确性的方法,希望在调用之前就开始考虑如何获得最大的成功概率。并行调用是指一开始就同时向多个服务副本发起调用,只要有其中任何一个返回成功,那调用便宣告成功。而广播调用则是要求所有的请求全部都成功,这次调用才算是成功

6,故障恢复

故障恢复一般不单独存在,而是作为其他容错策略的补充措施,一般在微服务管理框架中,如果设置容错策略为故障恢复的话,通常默认会采用快速失败加上故障恢复的策略组合。它是指当服务调用出错了以后,将该次调用失败的信息存入一个消息队列中,然后由系统自动开始异步重试调用

由于故障恢复可能会发送多次请求,因此他与故障转移有一些相同的限制条件,比如服务必须具备幂等性的,有最大重试限制,同时故障恢复策略一般用于对实时性要求不高的主路逻辑,同时也适合处理那些不需要返回值的旁路逻辑

总结

关于熔断、限流、降级、负载均衡配置,都是方法级配置大于全局级配置,服务端配置大于客户端配置

谁发起调用,谁最有决定权。 负载均衡选谁、超时等多久,由发起方(Consumer)决定

谁提供服务,谁最保护自己。 线程池大小、最大连接数,由服务方(Provider)决定

Spring Cloud 中配置不传输,只生效。 每个机器的配置改变的是自己的行为,不会通过网络指令修改服务端的状态。但是在 dubbo 中是可能生效的

客户端没配置超时时间,服务端配置了,这时候按服务端的超时时间为主,那这是不是配置传递了。答案是否定的------这仍然是配置不传输原则的体现,只是工作机制不同

相关推荐
小马爱打代码1 天前
Kafka 偏移量(Offset):消费者如何记住消费位置?
分布式·kafka
温柔的小猪竹1 天前
面向对象的六大原则
java
洛小豆1 天前
孤儿资源治理:如何优雅处理“上传了但未提交”的冗余文件?
java·后端·面试
a努力。1 天前
中国电网Java面试被问:分布式缓存的缓存穿透解决方案
java·开发语言·分布式·缓存·postgresql·面试·linq
草莓熊Lotso1 天前
脉脉独家【AI创作者xAMA】| 开启智能创作新时代
android·java·开发语言·c++·人工智能·脉脉
爱吃山竹的大肚肚1 天前
Kafka中auto-offset-reset各个选项的作用
java·spring boot·spring·spring cloud
只想要搞钱1 天前
java 常用业务方法-记录
java
CodeAmaz1 天前
HashMap 面试全攻略
java·hashmap