目录
前言
一、何为限流
二、分布式限流/集群流控
三、限流算法
1、固定窗口计数器
2、滑动窗口计数器
3、漏桶
4、令牌桶
四、限流实践
1、脚本编写
2、执行限流
前言
目前我司采用的是网关层限流,即在 nginx 层就控制了每ip每秒仅能通过5次,主要是某部分接口调用实在太频繁,因此限制较为严格,且网关层限流太笼统,因此需要应用层也增加限流,日后可以放宽网关层限流频率,另一个问题是在ip的限制下,许多客户往往通过使用不同的ip来绕过该限制...
一、何为限流
接口限流是系统流量上去后保证高可用的重要手段,本文的接口是指提供给第三方所调用的接口,由于应用服务无法控制调用方的行为,因此当流量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或超时,严重时可导致服务器宕机。
限流是指对应用服务的请求进行限制,例如某一接口的请求限制为10个每秒,那么超过该数值的请求则快速失败或丢弃,使用限流可以应对:
- 热点业务带来的突发请求
- 调用方bug导致的突发请求
- 恶意攻击请求
二、分布式限流/集群流控
当应用为单点应用时,只要对应用进行了限流,那么应用所依赖的各项服务也都得到了保护
但是基于系统高可用考虑,若线上业务是分布式系统,单节点的限流仅能保护自身节点,但无法保护应用所依赖的各种服务;若线上业务是集群部署,假设单节点限流为10个每秒,那么在三个集群下,每个节点都是10个每秒,那么整个系统就是30个每秒,完全无法达到限流的目的。这两种模式在单点限流下,系统在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。
三、限流算法
常用的限流算法有如下四种:
- 固定窗口计数器
- 滑动窗口计数器
- 漏桶
- 令牌桶
1、固定窗口计数器
固定窗口计数器算法概念如下:
- 将时间划分为多个窗口
- 在每个窗口内每有一次请求就将计数器加一
- 如果计数器超过了限制数量,则本窗口内所有的请求都被丢弃当时间到达下一个窗口时,计数器重置
固定窗口计数器是最简单的限流算法,但是其存在一个最大的弊端:它有时候会让通过请求量允许为限制的两倍。假设接口限流为每秒5个,但是不巧的是前一秒的后半秒访问五次,后一秒的前半秒也访问了五次,而这正好组成一个时间窗口,看起来就是在一秒内请求通过了十次。
2、滑动窗口计数器
滑动窗口计数器算法概念如下:
- 将时间划分为多个区间;
- 在每个区间内每有一次请求就将计数器加一维持一个时间窗口,占据多个区间;
- 每经过一个区间的时间,则抛弃最老的一个区间,并纳入最新的一个区间;
- 如果当前窗口内区间的请求计数总和超过了限制数量,则本窗口内所有的请求都被丢弃。
滑动窗口计数器其实就是细分之后的固定窗口计数器,原先的固定窗口是以一秒算作一个时间窗口,然后这次是将一秒再度进行划分,假设划成四个区间,即0~0.25、0.25~0.5、0.5~0.75、0.75~1,同样是每秒内请求不超过五次,在第一个区间时假设请求就已超过五次,那么直接失败,假设请求为一次,那么在第二个区间 0.25~0.5 ,该区间的请求量不能超过总的限制条件且当前区间的数量加上之前区间的数量也不能超过总的限制条件,当然时间到了 0.75~1 这个区间也是一样的。
如果过了0.25秒,则抛弃最老的一个区间 0~0.25,并把区间数往后移一位,整个区间则为 0.25~0.5、0.5~0.75、0.75~1、1~1.25,因此时间区间的精度越高,算法所需的空间容量就越大。
3、漏桶
漏桶算法概念如下:
- 将每个请求视作"水滴"放入"漏桶"进行存储;
- “漏桶"以固定速率向外"漏"出请求来执行如果"漏桶"空了则停止"漏水”;
- 如果"漏桶"满了则多余的"水滴"会被直接丢弃。
漏桶算法多使用队列实现,服务的请求会存到队列中,服务的提供方则按照固定的速率从队列中取出请求并执行,过多的请求则放在队列中排队或直接拒绝。
漏桶算法的缺陷也很明显,当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应。
4、令牌桶
令牌桶算法概念如下:
- 令牌以固定速率生成;
- 生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌会直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求可以执行;
- 如果桶空了,那么尝试取令牌的请求会被直接丢弃。
令牌桶算法既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求,举个例子,令牌桶最大容量是100,每秒生成10个令牌,既可以将请求平均在10个一秒,也允允许一段时间请求为空然后突发来了100个请求,这时桶中正好有100个令牌,那么便都允许通过,因此是目前使用较为广泛的一种限流算法。
四、限流实践
由于我司是集群化部署,因此首先排除单点限流(guava库的 RateLimiter 类),然后尝试了下阿里的开源分布式限流框架 Sentinel ,其单点限流功能易用且强大(采用滑动窗口计数器算法),但是其集群流控需要依赖的东西较多且麻烦(主要是我司没有较好的配置中心),若服务端采用嵌套部署也不是很妥,Sentinel 最好的集群流控部署方案还是将限流服务端和客户端分开。
因此最后采用的还是 redis+lua 脚本实现令牌桶算法,上家公司实现限流的时候仅仅用了redis去实现一系列判断与设值,采用lua脚本的优势有:
- 减少网络开销:使用lua脚本,无需向 redis 发送多次请求,执行一次即可,减少网络传输
- 原子操作:redis 将整个lua脚本作为一个命令执行,原子,无需担心并发
- 复用:lua脚本一旦执行,会永久保存 redis 中,其他客户端可复用
1、脚本编写
令牌桶算法需要在redis中存储当前令牌的数量,同时每隔一段时间还得新增新的令牌,因此我们可以在redis中记录每次请求的时间,同时在调用lua脚本时传入当前时间,通过两次时间差计算得出这段时间内需要生成的令牌数是多少,一并存入redis中,由于第一次运行时默认令牌桶是满的,所以可以将过期时间设置令牌桶恢复至满所需要的时间,以及时释放资源。
具体的lua脚本如下(经过实践):
3.2 版本之后的 redis 脚本支持 redis.replicate_commands(),因此尝试过使用 TIME 命令获取当前时间而不是通过往脚本传入时间的方式,但是经过测试发现在并发调用时脚本里生成的时间都是一致的,导致无法计算两次请求之间需要生成的令牌数,怀疑是开启了命令复制模式即 redis.replicate_commands() 导致,甚是不解,有懂得大佬望赐教,这里将 TIME 命令的脚本也贴出:
-- 开启单命令复制模式
redis.replicate_commands();
local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token')
local last_time = ratelimit_info[1]
local current_token
if ratelimit_info[2] then
current_token = tonumber(ratelimit_info[2])
end
local max_token = tonumber(ARGV[1])
local token_rate = tonumber(ARGV[2])
local now_time = redis.call('TIME')
local current_time = tonumber(now_time[1])
local reverse_time = 1000/token_rate
if current_token == nil then
current_token = max_token
last_time = current_time
else
local past_time = current_time-last_time
local reverse_token = math.floor(past_time/reverse_time)
current_token = current_token+reverse_token
last_time = reverse_time*reverse_token+last_time
if current_token>max_token then
current_token = max_token
end
end
local result = 0
if(current_token>0) then
result = 1
current_token = current_token-1
end
redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_token)
redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_token-current_token)+(current_time-last_time)))
return result
同时将固定时间窗口计数器的lua脚本也列出来供参考:
local c
-- 获取调用脚本时传入的第一个key值(用作限流的 key)
local key = KEYS[1]
-- 获取调用脚本时传入的第一个参数值(限流大小)
local count = tonumber(ARGV[1])
-- lua脚本中使用call和pcall来调用redis的命令,两者效果一样,只是对错误的处理不同
c = redis.call('get',key)
-- 若当前窗口内计数器大于限制数则直接返回
if c and tonumber(c) > count then
return tonumber(c);
end
-- 将当前窗口内计数器+1后然后返回给应用
c = redis.call('incr',key)
if tonumber(c) == 1 then
-- 如果是初次调用则设置过期时间
redis.call('expire',key,ARGV[2])
end
return tonumber(c);
2、执行限流
这里的代码就比较简单了,通过自定义注解+切面的方式来标注需要限流的方法,同时在配置文件中增加令牌桶大小和令牌桶生成速率配置,当然还得增加一些特殊配置,即允许某部分重要客户(调用频率确实过大)和某部分重要方法可以将限制数量扩容至两倍,以应对后续特殊情况。
简易版demo如下:
@SpringBootTest
@RunWith(SpringRunner.class)
public class RedisLimitWithLUA {
private final static Logger logger = LoggerFactory.getLogger(RedisLimitWithLUA.class);
private final static String LUA_SCRIPT = "local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token')\n" +
"local last_time = ratelimit_info[1]\n" +
"local current_token\n" +
"if ratelimit_info[2] then\n" +
" current_token = tonumber(ratelimit_info[2])\n" +
" end\n" +
"local max_token = tonumber(ARGV[1])\n" +
"local token_rate = tonumber(ARGV[2])\n" +
"local current_time = tonumber(ARGV[3])\n" +
"local reverse_time = 1000/token_rate\n" +
"if current_token == nil then\n" +
" current_token = max_token\n" +
" last_time = current_time\n" +
"else\n" +
" local past_time = current_time-last_time\n" +
" local reverse_token = math.floor(past_time/reverse_time)\n" +
" current_token = current_token+reverse_token\n" +
" last_time = reverse_time*reverse_token+last_time\n" +
" if current_token>max_token then\n" +
" current_token = max_token\n" +
" end\n" +
"end\n" +
"local result = 0\n" +
"if(current_token>0) then\n" +
" result = 1\n" +
" current_token = current_token-1\n" +
"end\n" +
"redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_token)\n" +
"redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_token-current_token)+(current_time-last_time)))\n" +
"return result";
@Autowired private RedisTemplate<String, String> stringRedisTemplate;
@Test
public void rateLimitTest() throws InterruptedException {
String key = "test_rateLimit_key";
int max = 10; //令牌桶大小
int rate = 10; //令牌每秒恢复速度
AtomicInteger successCount = new AtomicInteger(0);
Executor executor = Executors.newFixedThreadPool(10);
CountDownLatch countDownLatch = new CountDownLatch(30);
for (int i = 0; i < 30; i++) {
executor.execute(() -> {
RedisScript<Long> redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
Long result = stringRedisTemplate.execute(redisScript, Lists.newArrayList(key), Integer.toString(max), Integer.toString(rate), Long.toString(System.currentTimeMillis()));
boolean isAllow = false;
if (result != null && 1 == result) {
isAllow = true;
successCount.addAndGet(1);
}
logger.info(Boolean.toString(isAllow));
countDownLatch.countDown();
});
}
countDownLatch.await();
logger.info("请求成功{}次", successCount.get());
}
}
设置令牌桶大小为 10,令牌桶每秒恢复 10 个,启动 10 个线程在短时间内进行 30 次请求,并输出每次限流查询的结果。日志输出:
2020-12-13 14:39:17.439 INFO 52619 --- [pool-1-thread-7] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [pool-1-thread-3] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [ool-1-thread-10] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [pool-1-thread-9] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [pool-1-thread-8] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [pool-1-thread-1] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [pool-1-thread-4] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [pool-1-thread-2] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.462 INFO 52619 --- [pool-1-thread-6] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.474 INFO 52619 --- [pool-1-thread-5] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.474 INFO 52619 --- [pool-1-thread-7] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.509 INFO 52619 --- [pool-1-thread-3] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.509 INFO 52619 --- [ool-1-thread-10] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.509 INFO 52619 --- [pool-1-thread-9] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.509 INFO 52619 --- [pool-1-thread-1] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.509 INFO 52619 --- [pool-1-thread-8] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.510 INFO 52619 --- [pool-1-thread-4] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.510 INFO 52619 --- [pool-1-thread-6] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.510 INFO 52619 --- [pool-1-thread-2] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.530 INFO 52619 --- [pool-1-thread-7] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.530 INFO 52619 --- [pool-1-thread-5] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [pool-1-thread-3] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [ool-1-thread-10] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [pool-1-thread-8] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [pool-1-thread-1] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [pool-1-thread-9] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [pool-1-thread-4] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [pool-1-thread-6] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.542 INFO 52619 --- [pool-1-thread-2] c.d.subtable.common.RedisLimitWithLUA : false
2020-12-13 14:39:17.551 INFO 52619 --- [pool-1-thread-7] c.d.subtable.common.RedisLimitWithLUA : true
2020-12-13 14:39:17.551 INFO 52619 --- [ main] c.d.subtable.common.RedisLimitWithLUA : 请求成功15次
可以看到30次请求只成功了15次,多余的5次都是因为新生成的令牌而放行,而其余的请求都返回了false,业务方此时就可以返回业务繁忙或接口请求过于频繁等提示
2021.02.03更新:
上面的令牌桶 lua 脚本存在问题,初始化时不该直接给每秒生成速率个数或者最大令牌数,否则随着请求的密集访问,令牌还会生成,因此一秒内会大于约定的个数,改进后如下:
local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token')
local last_time
if ratelimit_info[1] then
last_time = tonumber(ratelimit_info[1])
end
local current_token = 0
if ratelimit_info[2] then
current_token = tonumber(ratelimit_info[2])
end
local max_token = tonumber(ARGV[1])
-- 令牌生成速率是每秒多少个
local token_rate = tonumber(ARGV[2])
-- 由于当前请求时间是通过传入的方式实现,因此务必保证所有节点的时间是同步的
local current_time = tonumber(ARGV[3])
-- 计算得出生成一个令牌需要多少时间,为了下面计算两次请求之间需要生成的令牌树作铺垫
local reverse_time = 1000/token_rate
if last_time == nil then
-- 初始化置为1,同时消耗未来的时间
current_token = current_token + 1
last_time = current_time + reverse_time/2;
end
if (current_time > last_time) then
-- 计算两次请求的时间差,毫秒
local past_time = current_time-last_time
-- 计算这段时间内需要生成的令牌数
local reverse_token = math.floor(past_time/reverse_time)
current_token = current_token+reverse_token
last_time = reverse_time*reverse_token+last_time
if current_token>max_token then
current_token = max_token
end
end
local result = 0
if(current_token>0) then
result = 1
current_token = current_token-1
end
redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_token)
local expireTime = math.ceil(reverse_time*(max_token-current_token))
if(current_time > last_time) then
expireTime = expireTime + (current_time-last_time)
end
-- 过期时间设为令牌桶生成至满所需要的时间
redis.call('pexpire',KEYS[1],expireTime)
return result