熔断、降级、限流

熔断(circuit break

  股票交易:股票市场的交易时间中,当价格波动的幅度达到一个限定的目标(熔断点)时,对其暂停交易一段时间的机制。

  保险丝:当电路发生故障或异常时,伴随着电流不断升高,并且升高的电流有可能损坏电路中的某些重要器件,也有可能烧毁电路甚至造成火灾。若电路中正确地安置了保险丝,那么保险丝就会在电流异常升高到一定的高度和热度的时候,自身熔断切断电流,从而起到保护电路安全运行的作用

  服务熔断的作用类似于保险丝,当下游某服务出现不可用或响应超时的情况时,上游服务为了保护系统整体的可用性,暂时停止对该服务的调用。

    这种局部牺牲,保全整体的措施就叫做熔断。

 

  熔断在互联网中的理解:

    当异常幅度达到设定的阀值后触发的系统保护机制

    保护机制会将某部分能力关闭,以保证大部分能力的正常

    这种机制是有损的,但是利大于弊

 

  如:前系统中有A,B,C三个服务,服务A是上游,服务B是中游,服务C是下游。它们的调用链如下 A——>B——>C

  当下游服务C因为某些原因变得不可用,积压了大量请求,服务B的请求线程也随之阻塞。线程资源耗尽,使得B服务也变得不可用。紧接着服务A也变得不可用,整个调用链路被拖垮。

  这种调用链路的连锁故障,叫做雪崩。

  这种情况下,就需要使用熔断机制来挽救整个系统。熔断分为熔断开启和熔断恢复:

    1、熔断开启

    在固定时间窗口内,接口调用超时比率达到一个阈值,会开启熔断。进入熔断状态后,后续对该服务接口的调用不再经过网络,直接执行本地的默认方法,达到服务降级的效果。

    2、熔断恢复

    熔断不可能是永久的。当经过了规定时间之后,服务将从熔断状态恢复过来,再次接受调用方的远程调用。

 

   熔断实现

    Spring Cloud Hystrix是基于Netflix的开源框架Hystrix实现,该框架实现了服务熔断、线程隔离等一系列服务保护功能。

对于熔断机制的实现,Hystrix设计了三种状态:

    1.熔断关闭状态(Closed)

      服务没有故障时,熔断器所处的状态,对调用方的调用不做任何限制。

       2.熔断开启状态(Open)

      在固定时间窗口内(Hystrix默认是10秒),接口调用出错比率达到一个阈值(Hystrix默认为50%),会进入熔断开启状态。进入熔断状态后,后续对该服务接口的调用不再经过网络,直接执行本地的fallback方法。

    3.半熔断状态(Half-Open)

      在进入熔断开启状态一段时间之后(Hystrix默认是5秒),熔断器会进入半熔断状态。所谓半熔断就是尝试恢复服务调用,允许有限的流量调用该服务,并监控调用成功率。如果成功率达到预期,则说明服务已恢复,进入熔断关闭状态;如果成功率仍旧很低,则重新进入熔断关闭状态。

 

 

 

降级

  为了预防某些功能出现负荷过载或者响应慢的情况,在其内部暂时舍弃一些非核心接口和数据的请求(如评论、积分),而直接返回一个提前准备好的 fallback(退路) 错误处理信息。释放CPU和内存资源,以保证整个系统的稳定性和可用性。

 

  服务降级分容错降级和屏蔽降级两种模式

  1、屏蔽降级:

    在一个实例中,服务之间尽管可以通过线程池隔离方式实现资源隔离,但是100%的隔离是不现实的。特别是对缓存、网络IO,磁盘IO、内存、CPU、数据库连接资源等公共依赖无法隔离,在业务高峰期或者大促时,服务之间往往存在激烈的竞争,导致核心服务(如双十一下单)运行质量下降,影响系统的稳定运行和客户体验。

    屏蔽降级需要手动操作,当外界的触发条件达到某个临界值时,由运维/开发人员决策,通过服务治理控制台,对某个服务进行人工降级操作。当系统压力恢复正常时,可以对已经屏蔽降级的服务恢复正常。

    降级策略:返回null、返回指定异常、执行本地mock接口实现

  2、容错降级:

    当非核心服务不可用时,可以对故障服务做业务逻辑放通。当然,容错降级不仅仅用于业务放通,它也常用于服务提供方在执行容错逻辑,包括RPC异常、service异常等。

    容错降级策略:

      1)将异常转义

      2)将异常屏蔽掉,直接执行本地模拟接口实现类,返回mock接口的执行结果

 

熔断与降级比较:

  相同点 不同点
熔断

目标一致 都是从可用性和可靠性出发,为了防止系统崩溃;

用户体验类似,最终都让用户体验到的是某些功能暂时不可用;

触发原因不同 服务熔断一般是某个服务(下游服务)故障引起
降级 服务降级一般是从整体负荷考虑;

 

 

 

 

限流(flow limiting)

  什么是限流?

    即,流量控制,指的是限制到达系统的并发请求数,使得系统能够正常的处理部分用户的请求,来保障系统的稳定性。

  为什么要限流?

    即保证系统的稳定性。

    比如秒杀等重大活动,流量激增,后端服务的处理能力是有限的,如果不能处理好突发流量,后端服务很容易被打垮。

    另外一些爬虫或恶意请求,因此我们对外暴露的服务都要以最大的恶意去防备调用者

 

  常见限流算法:

    计数限流:

      最简单的限流算法,逻辑是维护一个计数器(根据用户id/ip作为限流对象),后台配置阀值(如1000次/秒),处理一个请求,计数器+1,请求处理结束,计数器-1,每次请求进来的时候看看计数器的值,如果超过阀值就拒绝请求。

      优点:实现简单,计数器单机可以使用Java中的Atomic原子类,分布式可使用Redis的incr。

      缺点:顶不住超过阀值的瞬时流量冲击,且一般的限流都是为了限制在指定时间间隔内的访问量,因此出现了固定窗口限流算法。」

      使用场景:适用于做API限流,比如对外提供ip定位查询服务api,天气查询api等,可以根据ip做粒度控制,防止恶意刷接口造成异常,也适用于提供API查询服务做配额限制,一般限流后会对请求做丢弃处理。

      

    固定窗口限流:

      它相比于计数限流主要是多了个时间窗口的概念,计数器每过一个时间窗口就重置。规则如下:

        ①:请求次数小于阈值,允许访问并且计数器 +1;

        ②:请求次数大于阈值,拒绝访问;

        ③:这个时间窗口过了之后,计数器清零;      


      固定窗口临界问题:

        假设每秒钟允许100个请求,时间窗口间隔为1秒钟。在第1s的的0.6秒涌入100个请求,然后计数器清零,此时在1.1秒又涌入100个请求。虽然在窗口内的计数没超过阀值,但是全局来看在0.6-1.1秒内涌入了200个请求,超过了允许的阀值。

        为解决这个问题,引入了滑动时间窗口限流算法。

                    

    滑动窗口限流:

      滑动窗口限流解决固定窗口临界值的问题,可以保证在任意时间窗口内都不会超过阈值。

      相对于固定窗口,滑动窗口除了需要引入计数器之外还需要记录时间窗口内每个请求到达的时间点,因此对内存的占用会比较多

      规则如下,假设时间窗口为 1 秒:

        ①:记录每次请求的时间

        ②:统计每次请求的时间 至 往前推1秒这个时间窗口内请求数,并且 1 秒前的数据可以删除

        ③:统计的请求数小于阈值就记录这个请求的时间,并允许通过,反之拒绝

 

      问题:和固定窗口一样都无法解决短时间内集中流量的冲击。

      滑动窗口可以使用Redis的zset存储,key使用用户id/ip等,value和score都使用毫秒时间戳,利用zremrangebyscore 删除时间窗口之外的数据,再用 ZCARD计数

      具体思路:每一个行为到来时,都维护一次时间窗口,将时间窗口之外的记录全部清理掉,只保留窗口内的记录。zset中只有score值非常重要,value没有特别的意义,只要保证它是唯一的就行了。

 

    漏桶算法:

      即,模拟漏桶,水滴持续滴入漏桶中,底部定速流出。如果水滴滴入的速率大于流出的速率,当存水超过桶的大小的时候就会溢出。

      水滴对应的就是请求,桶内容量就是我们允许的流量阀值。流出对应的是处理请求。

      规则如下:

        ①:请求来了放入桶中

        ②:桶内请求量满了拒绝请求

        ③:服务定速从桶内拿请求处理      

      和消息队列类似。一般而言,漏桶也是由队列来实现的,处理不过来的请求就排队,队列满了就开始拒绝请求。和线程池类似。

      缺点:出口处理是匀速的,面对短时间大量的突发请求,即使负载压力不大,请求仍需要在队列等待处理。

      Redis 4.0 提供了限流模块:Redis-Cell,该模块使用了漏桶算法,并提供了原子的限流指令:cl.throttle。

        CL.THROTTLE user123 15 30 60 1
                ▲     ▲  ▲  ▲ ▲
                 |        |    |     | └───── apply 1 operation (default if omitted)
                 |        |   └─┴─────── 30 operations / 60 seconds
                 |       └───────────── 15 max_burst
                └─────────────────── key “user123”

        返回:

        127.0.0.1:6379> CL.THROTTLE user123 15 30 60
          1) (integer) 0   # 0 means allowed; 1 means denied   0表示允许,1表示拒绝
          2) (integer) 16  # total quota (`X-RateLimit-Limit` header)  漏斗容量 capacity
          3) (integer) 15  # remaining quota (`X-RateLimit-Remaining`)  漏斗剩余空间left_quota
          4) (integer) -1  # if denied, time until user should retry (`Retry-After`)  如果被拒绝了,需要多长时间后再试(表示多久后漏桶有空间)
          5) (integer) 2   # time until limit resets to maximum capacity (`X-RateLimit-Reset`)  多长时间后,漏桶完全空出来,单位秒

 

    令牌桶算法:

      令牌桶其实和漏桶的原理类似,只不过漏桶是定速地流出,而令牌桶是定速地往桶里塞入令牌,然后请求只有拿到了令牌才能通过,之后再被服务器处理 

      当然令牌桶的大小也是有限制的,假设桶里的令牌满了之后,定速生成的令牌会丢弃

      规则:

        ①:定速的往桶内放入令牌

        ②:令牌数量超过桶的限制,丢弃

        ③:请求来了先向桶内索要令牌,索要成功则通过被处理,反之拒绝 

 

      和Java的Semaphore信号量类似。

      优点:在应对突发流量时,可以一次全部拿走所有令牌。

      注意:上线时令牌桶内需要先预热放入令牌,否则请求过来会直接被拒绝。令牌可以放到Redis中(为了原子性,使用redis+lua脚本)。

       

 

  限流组件:

    1、阿里的Sentinel限流工具:https://github.com/alibaba/Sentinel ,匀速排队限流策略,采用漏桶算法。

    2、Google Guava 提供的限流工具类 RateLimiter,是基于令牌桶实现的,并且扩展了算法,支持预热功能

    3、Nginx 中的限流模块 limit_req_zone,采用了漏桶算法

    4、OpenResty 中的 resty.limit.req库

  

  限流参考:https://juejin.cn/post/7168077279531106318  

 

附录:

  Java令牌桶限流代码:

import com.yang.custom.redis.common.utils.RedisUtil;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Arrays;
import java.util.List;

/**
 * soul网关中的令牌桶限流
 * KEY[1]:tokenKey=upgrade:tokens
 * KEY[2]: timestampKey=upgrade:timestamp
 * <p>
 * ARGV[1]:每秒往桶内投放token的频率
 * ARGV[2]:桶容量
 * ARGV[3]:当前时间
 * ARGV[4]:请求获取token数量
 */
@Service
public class TokenBucketRateLimiter2 {

    public void isAllow() throws InterruptedException {
        // 限流关键lua脚本
        String script = "local tokens_key = KEYS[1]\n" +
                "local timestamp_key = KEYS[2]\n" +
                "\n" +
                "local rate = tonumber(ARGV[1])\n" +
                "local capacity = tonumber(ARGV[2])\n" +
                "local now = tonumber(ARGV[3])\n" +
                "local requested = tonumber(ARGV[4])\n" +
                "\n" +
                "local fill_time = capacity/rate\n" +
                "local ttl = math.floor(fill_time*2)\n" +
                "\n" +
                "local last_tokens = tonumber(redis.call(\"get\", tokens_key))\n" +
                "if last_tokens == nil then\n" +
                "  last_tokens = capacity\n" +
                "end\n" +
                "\n" +
                "local last_refreshed = tonumber(redis.call(\"get\", timestamp_key))\n" +
                "if last_refreshed == nil then\n" +
                "  last_refreshed = 0\n" +
                "end\n" +
                "\n" +
                "local delta = math.max(0, now-last_refreshed)\n" +
                "local filled_tokens = math.min(capacity, last_tokens+(delta*rate))\n" +
                "local allowed = filled_tokens >= requested\n" +
                "local new_tokens = filled_tokens\n" +
                "local allowed_num = 0\n" +
                "if allowed then\n" +
                "  new_tokens = filled_tokens - requested\n" +
                "  allowed_num = 1\n" +
                "end\n" +
                "\n" +
                "redis.call(\"setex\", tokens_key, ttl, new_tokens)\n" +
                "redis.call(\"setex\", timestamp_key, ttl, now)\n" +
                "\n" +
                "return allowed_num";

        List<String> keys = Arrays.asList("upgrade:tokens", "upgrade:timestamp");

        // 1允许通过,0被限流
        Long result = RedisUtil.eval(script, Long.class, keys, "5", "50", String.valueOf(Instant.now().getEpochSecond()), "1");

    }

}

   令牌桶限流lua脚本:

local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key)

local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local fill_time = capacity/rate  --放满需要时间
local ttl = math.floor(fill_time*2) --令牌生存时间

local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
  last_tokens = capacity
end

--上次刷新时间
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
  last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate)) --需要补多少token
local allowed = filled_tokens >= requested --要填充的大于获取的,则通过
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
  new_tokens = filled_tokens - requested
  allowed_num = 1
end

redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)

return { allowed_num, new_tokens }
View Code

 

 

END.

posted @ 2020-10-07 21:21  杨岂  阅读(1280)  评论(0编辑  收藏  举报