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

1,091 阅读18分钟

我的原文

限流概念

目的

  • 通过对并发/请求进行限速来保护系统,防止系统过载。
  • 做到有损服务,而不是不服务。
  • 负载过高时,优先保护核心服务或业务

限流方式

限流的方式有很多:

  • QPS:限制每秒的请求数
  • 并发数:避免开启过多线程导致资源耗尽
  • 连接数:限制TCP连接数

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

限流规则

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

规则包含有:

  • 限流算法
  • 参数,限流阈值
  • 相应策略等(处理方式等)

静态规则

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

压测需要得到的相关指标

  • 服务:对于计算型服务,需要考虑服务本身计算带来的系统资源消耗
  • 缓存及数据库:需要访问存储的服务,需要考虑数据库的负载
  • 消息队列:虽然一般消息队列可以用来作为削峰用途,但是有的消息队列是作为其他业务事务等用途的,也需要考虑其负载情况
  • 其他:下游链路中涉及到的各种服务或者中间件,都应该考虑到其负载

需要考虑:

  • 不同API的不同阈值
  • 整个链路涉及到的服务阈值:如果某个请求链路需要请求10个服务,则阈值计算需要考虑这10个服务能够提供给此服务的流量配额
  • 不同的限流策略有不同的限流阈值
  • 理论上每次API实现的业务逻辑的改变,以及增加、减少API都要进行重新压测,相对比较麻烦
  • 阈值的估算应该考虑整个服务的安全水位,阈值定义过高则容易产生过载,过低则浪费资源

动态规则

动态规则的两种实现:

  • 自适应:服务根据RT,负载,QPS等等指标动态变更限流规则
  • 外部通知:通过调用服务API来通知服务更新或服务自己轮询规则服务器

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

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

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

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

服务端限流

服务在处理请求前,应该对请求进行限流计算,防止系统过载。
同时也要考虑到为不同的业务的客户端提供不同的限流策略,不能因为某个业务的问题达到达到限流阈值而造成其他业务无法请求服务。

服务端限流优点

  • 更好控制整个服务的负载情况:服务端的限流阈值不会因为客户端数量增加或减少而改变
  • 方便对不同上游服务进行不同阈值的限流策略:可以对不同的调用者进行不同的限流配额,也可以给不同业务打上不同的tag再根据tag来限流。

缺点

  • 如果服务端只针对QPS限流,而不考虑连接数:服务在建连过程中也会产生一些资源消耗,而这些压力往往可能会成为瓶颈。特别是短连接,不断的建链过程会产生大量的资源消耗
  • 如果服务端也针对连接数进行限制:则不好对不同链路或服务进行配额区分。容易造成某个业务或服务的连接过多而导致其他服务也被限制

客户端限流

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

优点

  • 达到阈值不会请求服务端,避免服务端产生额外的资源消耗,如建立连接

缺点

  • 客户端的数量的增加或减少需要重新计算每个客户端的限流阈值
  • 客户端限流可能出现bug,或者客户端负载均衡产生倾斜导致限流失效
  • 服务不同API不同限流阈值:下游服务较多,而每个服务的不同API有不同限流配额,则客户端的限流较为复杂

网关限流

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

优点

  • 能很好的保护整个集群的负载压力,服务端数量增加或减少,则网关进行相应的阈值调整即可
  • 对不同的上游业务的服务设置不同的限流配额和不同的限流策略

缺点

  • 需要网关资源
  • 网关本身高可用性

限流粒度

限流的粒度可以分为:

  • 服务:对服务所有API进行统一的限流策略
  • API:每个API会有不同的请求链路,则相应会有不同的限流策略(阈值等)
  • API参数:很多时候我们希望能够对某个热点数据中访问频次最高的 Top K 数据进行限制。例如:秒杀,大促等场景,不要因为某个商品的频繁访问引起的限流导致其他商品无法访问。

服务粒度

一个服务提供一个统一的限流的策略。
优点是非常简单,但很容易造成限流失效,无法保护服务本身及下游。

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

API粒度

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

  • 增加或减少API,则限流策略要做相应的调整
  • API实现的改变:请求处理实现变化则可能需要重新对限流阈值进行调整,避免因为增加一些业务逻辑而导致服务本身或者下游服务过载。

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

限流的处理方式

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

  • 直接返回错误码:可以返回资源耗尽或者busy等状态码,或者带backoff的重试
  • 等待及重试:要注意不要超过请求超时时间

限流算法

固定窗口算法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个阶段

  • 令牌的产生:以r/s的速率将token放入bucket中,如果bucket中token数已到达bucket的容量b,则丢弃token
  • 获取令牌:在请求处理前从bucket中获取(移除)一个令牌,只有获取到令牌才进行处理
  • 无法获取令牌:当bucket中令牌不够,请求进入限流处理流程(阻塞等待或者直接返回失败,等待应该计算好超时时间)

token_bucket1.png
【图来源于dev.to】

token bucket特点

  • 最常见的单机限流算法,开源组件很多,使用也简单
  • token bucket可以应对短时间的突发流量。例如:bucket容量为10,速率为2/s,而请求QPS为2/s,那么bucket中有8个tokens可以用来应对突发流量。

漏桶算法Leaky Bucket

  • 图左边a:一个桶,不断的倒水进来,桶底下有个洞,按照固定的速率把水漏走,如果水进来的速度比漏走的快,桶可能就会满了,水就溢出了
  • 图右边b:类似于左边a,将请求放入桶中,以固定的速率从桶中拿出请求进行处理,当桶满了,请求将被阻塞或直接拒绝服务

leakybucket1.png
【图来源于dev.to】

leaky bucket特点

  • 不能很好应付突发的流量:例如,桶容量为10,请求处理速率为2/s;请求放入桶的速率为2/s,那么可以容纳8个突发请求放入桶中,其他等待或者丢弃。
  • 桶可以是普通队列形式,也可以是优先队列:优先队列是很有必要的
  • 放入桶中的请求应该计算好其超时时间,如果请求放入桶中到请求被处理,已经超过请求的超时间,则是没有意义的,反而阻塞了其他请求。

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

动态限流算法

前面的算法都是以恒定速率进行限流的(令牌桶以固定速率将令牌放入桶中,固定和滑动窗口限制阈值都是固定的),这种最大的问题就是每次业务逻辑变更,都要重新进行压力测试以计算出最新的阈值。
在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的原子特性来增加计数。

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

  • redis key : 资源名 + unix timestamp
  • count : incrby key count
  • expire 2
  • 检查count是否达到阈值
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

这种方案存在的问题:

  • 要和redis进行交互:时延较差
  • 热点资源redis容易成为瓶颈
  • redis进行主从切换会导致限流失效
  • 服务的时钟会有误差:由于lua中有写操作就不能使用带随机性质的读操作所以不能通过redis lua获取
  • 属于固定窗口算法,在窗口之间容易产生突发流量问题

方案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进行主从切换会导致限流失效
  • 服务的时钟会有误差:由于lua中有写操作就不能使用带随机性质的读操作所以不能通过redis lua获取

发票服务器

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

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

发票规则
发票规则(限流算法)可以存储到一致性存储或者数据库等,发票服务器定期更新或者监听通知来获取规则的变化。
也可以通过其他服务来动态调整算法和阈值,然后通知发票服务器,也可以发票服务器自己根据负载情况来计算。

发票服务器特点:

  • 发票服务器可用性高:通过集群模式,且可以持久化到数据库。
  • 发票服务器负载均衡:服务从发票服务集群领票要注意发票服务器负载均衡,避免造成有的发票服务器发票领完有的却有大量剩余发票
  • 发票服务器高性能:因为发票服务器的计算和存储都基于内存,所以性能不容易成为瓶颈
  • 发票服务器一致性:类似于ID生成器,对于极高要求的场景,可以定期将发票服务器发票的信息等进行持久化存储,故障时再从中进行恢复

微服务架构下限流的难点

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

流控规则

微服务架构下,固定流控规则是不合适的,固定规则往往根据压测结果进行计算得来,而微服务架构下,链路上一个节点的变化都会导致固定规则的失效。
如:
服务某个链路为 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来区分链路。

总结

  • 限流算法往往相对简单且开源方案很多。而难点在于如何选择适合自己的限流算法。
  • 不管是单节点线路还是分布式限流,都有各自的优缺点,需要根据自身业务特性、规模以及架构复杂性来选择适合自己的方案。
  • 微服务架构下,限流变得相当复杂,要考虑清楚各种可能导致限流失效的场景。

C++令牌桶:github.com/ikenchina/r…