针对超高流量带来的请求压力,业界比较常用的一种方式就是“流控”。

“流控”这个词你应该不陌生,当我们坐飞机航班延误或者被取消时,航空公司给出的原因经常就是“因为目的机场流量控制”。对于机场来说,当承载的航班量超过极限负荷时,就会限制后续出港和到港的航班来进行排队等候,从而保护整个机场的正常运转。

流控的常用算法

目前,业界常用的流控算法有两种:漏桶算法和令牌桶算法。

漏桶算法

“漏桶算法”的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。“漏桶算法”在实现上文如其名:它模拟的是一个漏水的桶,所有外部的水都先放进这个水桶,而这个桶以匀速往外均匀漏水,如果水桶满了,外部的水就不能再往桶里倒了。

这里你可以把这些外部的水想象成原始的请求,桶里漏出的水就是被算法平滑过后的请求。从这里也可以看出来,漏桶算法可以比较好地控制流量的访问速度。

令牌桶算法

令牌桶算法是流控中另一种常用算法,控制的是一个时间窗口内通过的数据量。令牌桶算法大概实现是这样的:

  1. 每 1/r 秒往桶里放入一个令牌,r 是用户配置的平均发送速率(也就是每秒会有 r 个令牌放入)。
  2. 桶里最多可以放入 b 个令牌,如果桶满了,新放入的令牌会被丢弃。
  3. 如果来了 n 个请求,会从桶里消耗掉 n 个令牌。
  4. 如果桶里可用令牌数小于 n,那么这 n 个请求会被丢弃掉或者等待新的令牌放入。

细粒度控制

模仿 tcp连接的方式

首先是针对流控的粒度问题。举个例子:在限制 QPS 的时候,流控粒度太粗,没有把 QPS 均匀分摊到每个毫秒里,而且边界处理时不够平滑,比如上一秒的最后一个毫秒和下一秒的第一个毫秒都出现了最大流量,就会导致两个毫秒内的 QPS 翻倍。

一个简单的处理方式是把一秒分成若干个 N 毫秒的桶,通过滑动窗口的方式,将流控粒度细化到 N 毫秒,并且每次都是基于滑动窗口来统计 QPS,这样也能避免边界处理时不平滑的问题。

流控依赖资源瓶颈

全局流控实现中可能会出现的另一个问题是,有时入口流量太大,导致实现流控的资源出现访问瓶颈,反而影响了正常业务的可用性。在微博消息箱业务中,就发生过流控使用的 Redis 资源由于访问量太大导致出现不可用的情况。

针对这种情况,我们可以通过“本地批量预取”的方式来降低对资源的压力。

所谓的“本地批量预取”,是指让使用限流服务的业务进程,每次从远程资源预取多个令牌在本地缓存,处理限流逻辑时先从本地缓存消耗令牌,本地消费完再触发从远程资源获取到本地缓存,如果远程获取资源时配额已经不够了,本次请求就会被抛弃。

通过“本地批量预取”的方式,能大幅降低对资源的压力,比如每次预取 10 个令牌,那么相应地对资源的压力能降低到 1/10。

但是有一点需要注意,本地预取可能会导致一定范围的限流误差。比如:上一秒预取的 10 个令牌,在实际业务中下一秒才用到,这样会导致下一秒业务实际的请求量会多一些,因此本地预取对于需要精准控制访问量的场景来说可能不是特别适合。

全局流控

对于单机瓶颈的问题,通过单机版的流控算法和组件就能很好地实现单机保护。但在分布式服务的场景下,很多时候的瓶颈点在于全局的资源或者依赖,这种情况就需要分布式的全局流控来对整体业务进行保护。

业界比较通用的全局流控方案,一般是通过中央式的资源(如:Redis、Nginx)配合脚本来实现全局的计数器,或者实现更为复杂的漏桶算法和令牌桶算法,比如可以通过 Redis 的 INCR 命令配合 Lua 实现一个限制 QPS(每秒查询量)的流控组件。

下面的示例代码是一个精简版的 Redis+Lua 实现全局流控的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 操作的 Redis Key
local rate_limit_key = KEYS[1]
-- 每秒最大的 QPS 许可数
local max_permits = ARGV[1]
-- 此次申请的许可数
local incr_by_count_str = ARGV[2]
 
-- 当前已用的许可数
local currentStr = redis.call('get', rate_limit_key)
local current = 0
if currentStr then
    current = tonumber(currentStr)
end
 
-- 剩余可分发的许可数
local remain_permits = tonumber(max_permits) - current
local incr_by_count = tonumber(incr_by_count_str)
-- 如果可分发的许可数小于申请的许可数,只能申请到可分发的许可数
if remain_permits < incr_by_count then
    incr_by_count = remain_permits
end
 
-- 将此次实际申请的许可数加到 Redis Key 里面
local result = redis.call('incrby', rate_limit_key, incr_by_count)
-- 初次操作 Redis Key 设置 1 秒的过期
if result == incr_by_count then
    redis.call('expire', rate_limit_key, 1)
end
 
-- 返回实际申请到的许可数
return incr_by_co

自动熔断

一种常见的方式是手动通过开关来进行依赖的降级,微博的很多场景和业务都有用到开关来实现业务或者资源依赖的降级。

另一种更智能的方式是自动熔断机制。自动熔断机制主要是通过持续收集被依赖服务或者资源的访问数据和性能指标,当性能出现一定程度的恶化或者失败量达到某个阈值时,会自动触发熔断,让当前依赖快速失败(Fail-fast),并降级到其他备用依赖,或者暂存到其他地方便于后续重试恢复。在熔断过程中,再通过不停探测被依赖服务或者资源是否恢复,来判断是否自动关闭熔断,恢复业务。

自动熔断这一机制目前业界已经有很多比较成熟的框架可以直接使用,比如,Netflix 公司出品的 Hystrix,以及目前社区很火热的 Resilience4j 等。