51信用卡 限流 实践

为什么需要限流

我们都知道,构建高并发的系统有三大利器:缓存、降级、限流。通过使用缓存,可以让用户在获取数据链路的过程变的更短、获取数据的速度变得更快,从而提升系统的吞吐量,通过使用降级手短,可以把非核心业务的资源用来保证核心业务的正常服务。然而有些场景并不能用缓存和降级来解决,比如稀缺资源的秒杀、抢购、写服务(如评论、下单)、频繁的复杂查询(评论的最后几页),因此需有一种手段来限制这些场景的并发或请求量,即限流。通过限流,可以保证系统处理的流量始终在系统自身能力范围之内,最终做到有损服务而非不服务。

 

常见的几种限流算法简介

目前常用的限流算法有以下几种,每种算法都各有优缺点:

 

一、简单计数法

简单计数法是限流算法里面最简单粗暴的一种算法,其算法的核心思想就是维护一个计数器,这个计数器有一个时间窗口,当达到这个时间窗口时计数器会归零。每当一个新请求到来的时候,都让计数器自增,当计数器自增达到设置的上限时,不再提供服务。

 

简单计数法正如其名,实现起来非常简单,这是它的最大的优点,但太简单粗暴也会导致一些 小的问题,比如无法预防短时间突然的流量高峰。我们举个例子来说,有个api,我们希望这个api每秒钟最多处理的请求数量是1000,即限制它的qps为1000,那么使用简单计数法有什么问题呢?参考下面的图片,在第1秒的前999毫秒内都没有任何请求,但第1秒的最后1毫秒,突然来了1000个请求,这个时候是没有问题的。紧接着,计数器达到了时间窗口,计数归零了,第2秒的第1毫秒,又来了1000个请求,相当于两毫秒内允许api处理了2000个请求,和我们约定的qps为1000有很大的差距。所以,鉴于以上的问题,简单计数可以应用于一些请求相对均匀,对限制要求不是很准确的场景。

 

51信用卡 限流 实践_第1张图片

 

二、滑动窗口算法(rolling window)

滑动窗口算法把时间划分为一个一个的窗口“格子”,记录每个格子的请求数量,每当时间经过了一个格子时向后滑动时间窗格并统计所有时间窗格的请求数量这样的方式来计数。具体的算法思想我们使用两张图来说明。沿用上面的例子,有个api,我们希望这个api每秒钟最多处理的请求数量是1000,即限制它的qps为1000。那么我们把1秒钟分成10个时间窗口格子,那么每个格子就是100ms,每当请求到来时,把当前时间对应格子上的请求数量加1,并计算当前时间格子和之前9个格子的请求数量之和是否超过上限。如下图:可以看到,当前的请求在第10个格子上,新请求过来时,我们去计算当前时间格子的请求数量和前面9个格子的请求数量之和,check是否大于1000,大于的话,则拒绝新的请求。

 

51信用卡 限流 实践_第2张图片

正是因为有之前格子计数的“积累”,所以当同样面临简单计数算法的时间边界情况时,请求在第二秒的初始也不会得到允许,如下图所示。

 

51信用卡 限流 实践_第3张图片

滑动窗口算法保证了在限流的任意时间段之内,请求数量都不会超过设置的上限,所以滑动窗口算法适用于对限流的准确性有一定要求的场景。

 

三、漏桶算法(leaky bucket)

 

51信用卡 限流 实践_第4张图片

漏桶的算法十分简单。只需要满足以下的规则:

1. 有一个固定容量的桶,有水流进来,也有水流出去。对于流出去的水,必须按照一个设定的速率。

2. 当桶里的水已经流空的时候,不需要再流出水。

3. 对于流入水的速率不做限制,如果流入的水速率大于流出的水,则当桶里的水达到了桶的容量时,多余的水被直接丢弃。

 

对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率。而且,当桶满了之后,多余的水将会溢出。将算法中的水换成实际应用中的请求,就是漏桶的算法。由于漏桶算法天生就限制了请求的速度,当使用了漏桶算法,我们可以保证接口会以一个常速速率来处理请求。所以漏桶算法也不会出现简单计数那样的临界问题,非常适合于一些流量整形(Traffic Shaping)和流量控制(TrafficPolicing)等严格要求请求速率的场景。

 

四、令牌桶算法(token bucket)

令牌桶算法也非常简单,有一个容量固定的桶,以一个恒定的速率产生令牌,如果桶内的令牌满了则多余的令牌会被丢弃。每当请求进来时,先去桶内拿一个令牌,桶内的令牌拿完了,则必须等待桶内产生令牌才能允许后续的请求(或者直接拒绝)。由于桶内可以堆积一定的令牌(一般为桶的容量),所以令牌桶算法比漏桶算法有个优点就是令牌桶算法可以允许一定量的流量高峰。

 

令牌桶算法虽然也容许一定量的流量突发,但是和简单计数的边界流量高峰还是有区别的。简单计数法是在每次计数器时间边界的瞬间都可能允许有两倍流量,而令牌桶算法由于有桶里令牌的积累,能允许一定量的流量突发,之后再有更多的请求,都必须等待令牌生成,等待令牌生成期间的所有请求要么阻塞到拿到令牌要么就失败,令牌桶在极端情况下也会有两倍流量的可能,使用令牌桶算法限流的时候需要考虑到这种情况,当业务上非常依赖限流准确性的时候不可使用令牌桶算法,我们通过图示来说明:

 

当一段时间内没有请求(或者请求速度小于令牌产生速度)时,桶里的令牌逐渐又积累起来,又会允许0秒时刻那样的流量突发:

 

只要理解了两点,一,产生令牌的速度恒定。二,没有令牌不得访问。就能理解令牌桶算法的工作原理。

 

“令牌桶算法”和“漏桶算法”的主要区别在于“漏桶算法”能够强行限制数据的传输速率,而“令牌桶算法”在能够限制数据的平均传输数据外,还允许某种程度的突发传输。在“令牌桶算法”中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的上限,因此它适合于具有突发特性的流量。考虑到限流只是对服务/资源的一种保护,而非严格的业务需求,所以51采用了令牌桶算法来实现限流。具体的业务需要使用什么限流算法还要具体分析,当业务量非常大,系统可用性很高的时候使用简单计数也未尝不可。

 

应用级限流及分布式限流

应用级限流是指每个服务自己保证自己的资源以一个合理的限度使用,因为每个服务总有一个极限的请求并发数(QPS/TPS阈值),如果超过了这个上限,服务就会变得无响应或者响应很慢,从而不能很好的提供服务,通过限流,使服务的处理能力在上限之内,则服务就可以继续提供服务了。应用级限流对于保护单个服务节点是非常有效的,但是应用级限流不能保护服务集群背后的资源,比如控制对DB资源的访问,这种情况就需要使用分布式限流,保证一个服务集群总的请求量在设定的上限之内。

 

分布式限流的实现思路一般有两个:

第一种思路是把限流的算法应用到Redis/Memcached上,这样所有集群内的机器使用的都是一个单点的计数器,从而来实现分布式限流。例如使用Redis + Lua脚本实现,Redis集群作为计数的存储设备,Luau脚本来实现具体的算法。这里多说一句Redis + Lua这种思路,Redis虽然是单线程的,无需担心命令执行的线程安全问题,但是由于限流算法的复杂性,实现一次限流判断,需要多次和Redis之间交互,一次限流判断过程中的多次Redis交互有可能构成“先检查后执行(Check-Then-Act)”操作,这样的操作会导致Redis后面的行为可能基于一次失效的数据,从而导致限流算法发生问题,根据墨菲定律,当并发数量达到一定程度的时候,这样的问题一定会发生。而且多次的Redis交互之间也占用了额外的网络IO请求时间,所以解决这个问题需要使用Lua脚本,把限流的算法用Lua脚本实现,然后就可以一次执行Lua脚本就得到限流结果。Lua脚本在Redis执行的过程中不会被其他的操作打断,没有了“先检查后执行”问题,但也要注意Lua脚本不可过于复杂,如果因Lua脚本写的不正确有可能导致整个Redis集群夯住,从而引发悲剧事故。还有一点需要注意,Lua脚本无法从Redis获取系统时间(注2)。前面介绍的一些限流常见算法中,除了简单计数法,其他的算法的一般实现都需要获得一个系统时间,可以先通过Redis的Time命令获取时间,然后传入Lua脚本中这样的方式来实现。

下面附一个简单计数算法的Lua实现:

 

-- KEYS[1] key: 限流的redis key,外部计算传入
-- ARGV[1] limit: 最大访问流量/数量限制
-- ARGV[2] dur: 限流的总时长,单位毫秒
-- ARGV[3] permits: 请求的流量,如果限制数量,则为1

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local dur = ARGV[2]
local permits = tonumber(ARGV[3])
local current = tonumber(redis.pcall('GET', key) or '0')
if current + permits > limit
then
   return 0
else
   if (current == 0)
   then
       redis.pcall('SET', key, 0)
       redis.pcall('PEXPIRE', key, dur)
   end
   redis.pcall('INCRBY', key, permits)
   return 1
end

实现分布式限流的另外一种思路就是通过把整个集群的限流数量均匀分摊到集群内的每一个机器上面,通过每个节点各自进行应用级别的限流来模拟整个集群的限流。这种思路的实现需要满足两个条件,一是集群内的每个节点承担的流量是均衡的,节点之间如果流量不均衡,则会导致流量高的节点很容易就触发分给自己的上限,因为每个节点的上限是通过集群上限除以节点数量得出的。二是当集群内的节点数量发生变化的时候,要实时去更新每个节点的流量上限,否则一旦有机器的增加,可能会出现导致无法保护后端资源的问题发生。

 

比较一下应用级限流和分布式限流的优缺点,可以发现这两种限流方式各有优缺点。应用级限流对应用节点自身保护,好处是当应用的能力不足以提供服务时,可以随时进行扩容,而无需考虑扩容带来的影响。而分布式限流优点是可以做到对应用后端的资源进行保护,无论是采用单点的存储方式还是使用节点模拟来实现分布式限流,都需要额外的操作来保证限流的正确性。具体使用哪种限流方式,还是要看具体的业务场景。

 

51的限流实践

51信用卡实行的是微服务架构实践,每个DC内的微服务调用使用的是RoundRobin的负载均衡策略,所以可以认为每个服务的流量都是均衡的。另外每个服务在启动的时候会把服务的信息注册到Consul集群(一个高可用、支持跨DC的服务发现与KV存储系统),通过Consul可以获取每个服务部署的节点数量。基于以上的两点,所以51的分布式限流采用的使用节点来模拟集群的方式,另外也对应用级的限流做了支持,可以使用服务治理后台来实时调整,对单个服务所做的调整会实时下发到这个服务的每个实例。对单个节点我们直接使用了谷歌Guava中的RateLimiter类来承担限流功能,RateLimiter是谷歌对令牌桶限流算法的一个比较成熟的轻量级实现,其原理是利用系统时间来维护一个“下次令牌产生时间”,每次请求时,通过计算当前时间和“下次令牌产生时间”的差距可以计算出来当前桶里面有多少个可用令牌,当桶里还有可用令牌时,请求可以继续,并把使用同步的方法把令牌桶里面的令牌数量减一,当桶里没有令牌时,Guava也提供了两种处理方案:阻塞直到产生令牌或等待指定的超时时间后拒绝请求。另外Guava还提供了一个名为setRate()方法,来允许你随时安全地调整限制的速度,根据前面分布式限流与应用级限流中提到的“当集群内的节点数量发生变化的时候,要实时去更新每个节点的流量上限”,实在是太贴心了。

 

限流的配置包含三个维度,针对api的调用限制、针对RPC的调用限制、还有针对服务内某个具体的method的调用限制,分别对应配置文件的service、client、method节点,当配置的对象是api时,还可以更详细的配置当前的api对某个具体的服务限制调用多少(即下面的by-service),配置的yml结构是这样设计的:

 

51信用卡 限流 实践_第5张图片

 

这是接口限流部分对应的后台管理界面:

 

51信用卡 限流 实践_第6张图片

 

当服务出现符合配置的条件时,使用pattern作为Key,去本地缓存的中获取key对应的RateLimiter对象,没有则新创建一个,如果当期配置的是分布式限流,则需要去服务注册发现中心获取服务的节点数量,和内存中缓存的节点数量不一致时矫正单台节点的限流数量。为了提高系统的可用性,并没有每次请求都去服务注册发现中心获取,这个过程在另外的线程中定时执行,使用的时候其实用的是一份本地的可用节点数量的快照。有了RateLimiter对象,就可以直接使用Guava为我们提供的方法了,这部分就不再赘述。

 

 

参考文章:

1. http://www.kissyu.org/2016/08/13/限流算法总结/

2. https://redis.io/commands/eval

3. http://xiaobaoqiu.github.io/blog/2015/07/02/ratelimiter/

4. 《亿级流量网站架构核心技术》

你可能感兴趣的:(限流)