限流的概念,算法,分布式限流以及微服务架构下限流的难点

2020-07-12  本文已影响0人  zhubaba

限流概念

目的

限流方式

限流的方式有很多:

以下指的限流都是指QPS的限制,不涉及并发数和连接数。

限流规则

限流策略是根据业务特征来设置的,可以设置静态策略,也可以动态变化的。

规则包含有:

静态规则

限流阈值

服务端提前做好整个服务端链路的性能压测,先了解业务特征,需要请求API的QPS,估算好整个服务集群的水位及下游水位(下游服务和相关存储等中间件)。服务端集群的限流阈值建立在压测的数据基础上。

压测需要得到的相关指标

需要考虑:

动态规则

动态规则的两种实现:

限流阈值

根据响应时间或在监控系统以及服务治理中心之上根据服务及下游的负载来动态计算阈值

服务端限流 vs 客户端限流 vs 网关限流

绝大多数情况下限流都是发生在服务端的,因为很多情况下客户端的数量是不确定的。但有时候为了防止单个客户端过度使用服务,那么此处可以在客户端来完成,当然在服务端也可以同时进行。

一般都推荐在服务端做限流

服务端限流

服务在处理请求前,应该对请求进行限流计算,防止系统过载。

同时也要考虑到为不同的业务的客户端提供不同的限流策略,不能因为某个业务的问题达到达到限流阈值而造成其他业务无法请求服务。

服务端限流优点

缺点

客户端限流

客户端调用下游服务时,以每个服务集群的限流配额对下游服务进行过载保护。

优点

缺点

网关限流

请求通过网关来请求服务端,在网关中对不同服务及不同的API进行限流。

优点

缺点

限流粒度

限流的粒度可以分为:

服务粒度

一个服务提供一个统一的限流的策略。

优点是非常简单,但很容易造成限流失效,无法保护服务本身及下游。

如:服务提供两种API,都是访问数据,两种API的查询语句并不一致,API1 查询非常复杂,数据库安全水位只能提供10/s的TPS,而对于API2,数据库可以提供1000/s的TPS,这种情况下,如果按照服务粒度进行限流,则只能提供10/s QPS的限流阈值。所以是非常不合理的。

API粒度

不同的API进行不同的限流策略,这种方式相对复杂些,但是更为合理,也能很好的保护服务。

要考虑几种情况:

大多数情况下,都应该进行API粒度的限流,这样才能更好的保护服务本身及服务的下游服务和中间件,达到更好的限流效果。

限流的处理方式

如果达到了流量限制的阈值,一般处理方式有:

限流算法

固定窗口算法Fixed window

通过维护一个单位时间内的计数值,每当一个请求通过时,就将计数值加1,当计数值超过阈值时,就进入限流处理流程。如果单位时间已经结束,则将计数器清零,开启下一轮的计数。

fixed_window1.png

这种方式的缺点是:窗口是固定的,会存在两个窗口边界突发流量问题。当然,取决于窗口的大小,如果足够小,则这种问题是可以忽略的

如下图:

时间窗口为1s,1s的限制阈值是4,如果一个恶意用户在1s的最后的最后500毫秒发送3个请求,在下一秒的前500毫秒发送3个请求,那么实际在1秒内发送了6个请求,就超过了流量的限制。

fixedwindow2.png

滑动窗口算法Sliding window

将时间窗口在进行细化,分为N个小窗口,窗口以小窗口为最小滑动单位,这样就可以避免在两个时间窗口之间产生毛刺现象。(当然在小窗口之间仍然会产生毛刺)

fixedwindow2.png

令牌桶算法Token Bucket

令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。

有3个阶段

token_bucket1.png

【图来源于dev.to】

token bucket特点

漏桶算法Leaky Bucket

leakybucket1.png

【图来源于dev.to】

leaky bucket特点

和令牌桶区别是:令牌桶是固定速率往桶里放令牌,请求来了取走令牌,桶空了,请求阻塞;漏桶是请求来了往桶里放请求,以固定速率取走请求,桶满了,请求阻塞。令牌桶允许突发流量,而漏桶是不允许的。

动态限流算法

前面的算法都是以恒定速率进行限流的(令牌桶以固定速率将令牌放入桶中,固定和滑动窗口限制阈值都是固定的),这种最大的问题就是每次业务逻辑变更,都要重新进行压力测试以计算出最新的阈值。

在TCP拥塞控制算法中,流量发送速率是跟网络环境相关的。那其实服务的限流也可以根据服务处理请求的时延或负载等指标来进行动态调整。

预热期慢启动

类似于TCP的慢启动,定义一个启动时的限制阈值,在定义的预热时间周期内逐步提升限制阈值,直到周期结束达到定义的正常值。

这非常适合于服务本身或其依赖的存储等需要进行预热的场景。

可以参考 guava

例如:

以令牌桶为例,开始时限制阈值为4/s,预热时间为3/s,正常限制阈值为7/s,那么就在3秒内,将限制阈值每秒增加1最终达到7/s的阈值。

warmup.png

根据响应时间

这种方式根据请求响应时间来实时调整限流的规则,相对较为合理。

请求响应快则平滑调整增加阈值,响应慢则减少阈值,以及定义一个最大安全阈值。

关键在于如何量化快与慢:

那么随着响应时间的变化,阈值也在不断的变化中,阈值的范围在[1, max_value]之间调整。

基于监控系统

如果服务是消耗CPU资源的计算型或者消耗IO资源的存储型等,则基于监控系统更为合理。

根据CPU,load,内存,IO等系统指标和请求响应时间等业务指标综合考虑,随着监控指标的变化动态改变限流规则,这种限流相对较为复杂,根据自己业务设计适合自己的计算方式。

如果是IO型,则IO指标的权重相对要高一些,如果是计算型,则CPU和Load要权重高一些。


分布式限流

单节点限流最大的问题是当服务节点动态添加或减少后,每个服务的限流配额也要跟随动态改变。

如果服务节点增加了,而原来节点限流配额没有减少,则下游服务就可能过载。

而分布式限流则避免了这种问题,通过像redis集群或发票服务器这种取号的方式来限制某个资源的流量。

redis限流

基于redis的单线程及原子操作特性来实现限流功能,这种方式可以实现简单的分布式限流。但是redis本身也容易成为瓶颈,且redis不管是主从结构还是其cluster模式,都存在主节点故障问题。

方案1:固定窗口计数

将要限制的资源名+时间窗口为精度的时间戳 作为redis 的key,设置略大于时间戳的超时时间,然后用redis的incrby的原子特性来增加计数。

如果限流的时间窗口以秒为单位,则


local key = KEYS[1]

local limit = tonumber(ARGV[1])

local acquireCount = tonumber(ARGV[2])

local current = tonumber(redis.call('get', key) or "0")

if current + acquireCount > limit then

    return 0

else

    redis.call("incrby", key, acquireCount)

    redis.call("expire", key, "2")

    return 1

end

这种方案存在的问题:

方案2:令牌桶


local function acquire(key, acquireTokens, currentTimeMillSecond)

    local rateLimiterInfo = redis.pcall("HMGET", key, "lastTimeMilliSecond", "availableTokens", "maxLimit", "rate")

    local lastTimeMilliSecond = rateLimiterInfo[1]

    local availableTokens = tonumber(rateLimiterInfo[2])

    local maxLimit = tonumber(rateLimiterInfo[3])

    local rate = rateLimiterInfo[4]

    local currentTokens = availableTokens;

    local result = -1

    if (type(lastTimeMilliSecond) ~= 'boolean' and lastTimeMilliSecond ~= false and lastTimeMilliSecond ~= nil) then

        local diffTime = currentTimeMillSecond - lastTimeMilliSecond

        if diffTime > 0 then

            local fillTokens = math.floor((diffTime / 1000) * rate)

            local allTokens = fillTokens + availableTokens;

            currentTokens = math.min(allTokens, maxLimit);

        end

    end

    if (currentTokens - acquireTokens >= 0) then

        result = 1

        redis.pcall("HMSET", key, "lastTimeMilliSecond", currentTimeMillSecond, "availableTokens", currentTokens - acquireTokens)

    end

    return result

end

local key = KEYS[1]

local acquireTokens = ARGV[1]

local currentTimeMillSecond = ARGV[2]

local ret = acquire(key, acquireTokens, currentTimeMillSecond)

return ret

这种方案存在的问题:

发票服务器

上述redis方案,是将redis作为一种发票服务器,但是由于redis这种方案本身存在可用性问题(主从切换等),控制规则也比较简单,所以对于可用性要求比较高且规则复杂的需求,都选择自己开发服务器程序来作为发票服务器。

阿里开源的sentinel

发票服务器一般由一些服务进程组成一个或多个发票集群。

而服务通过RPC向发票服务器领票,成功则可以执行,否则则进入限流机制。为了减少RPC通信带来的延迟,一般可以批量获取。

发票规则

发票规则(限流算法)可以存储到一致性存储或者数据库等,发票服务器定期更新或者监听通知来获取规则的变化。

也可以通过其他服务来动态调整算法和阈值,然后通知发票服务器,也可以发票服务器自己根据负载情况来计算。

发票服务器特点:

微服务架构下限流的难点

在微服务架构下,调用链路很长,服务的调用关系复杂,上游服务一个小的改动,将影响所有下游服务,还会产生叠加效应。

流控规则

微服务架构下,固定流控规则是不合适的,固定规则往往根据压测结果进行计算得来,而微服务架构下,链路上一个节点的变化都会导致固定规则的失效。

如:

服务某个链路为 A->B->C->D

而B做了一些业务逻辑的变更,那么链路就有可能产生变化,导致之前的链路压测结果不准确,如果还按照之前的阈值,则可能导致流控失效。

微服务架构下,应该采用动态规则,让服务自适应或者规则服务器来通知服务改变限流规则

根据调用链路的限流规则

下游服务进行限流时,也要考虑给予上游服务的流量配额,否则很容易因为由于某个上游服务的故障,导致整个下游链路不可用。

如:

链路1:A->B->C->D

链路2:D->B->C

A如果故障,导致调用B流量暴涨,那么调用C的流量也会暴涨。这个时候链路2的D服务则会收到B或C的流控影响。

所以B和C应该根据给予链路1和链路2进行不同的配额,链路1达到阈值则对链路1的调用进行限流,不影响链路2的调用。

考虑调用链路优先级

一般微服务场景会根据业务来定义其可用性权重。权重高的业务往往要优先保证其可用性。

那么就存在复杂调用链路上,针对不同业务的请求来进行限流,服务压力过大时,优先保证重要的业务可运行。

如:

链路1:A->B->C->D

链路2:D->B->C

链路1的重要性高于链路2,那么如果C的负载升高时,C就要降低整体的限流阈值,那么就要降低链路2的限流阈值,牺牲链路2的可用性来保证链路1。

所以就需要将链路进行tag标识,服务C根据tag来区分链路。

总结

博客:https://ikenchina.github.io/
知乎:https://zhuanlan.zhihu.com/p/158948815

C++ 令牌桶的实现
https://github.com/ikenchina/ratelimit

上一篇 下一篇

猜你喜欢

热点阅读