你被限流过吗

我还记得14年抢红米的时候,下面这个图是我最烦的一个图

redis zset方法java_限流

抢了两个星期,才终于买到了我的第一台小米手机:红米1s。小米商城加入了一个排队的机制,于是我们可以感知到自己被限流了,但大部分服务,比如最近各大电商的抢茅台活动,并没有让我们感知到限流,不管你是手速不够还是被限流,都会给你返回“很遗憾,已经被抢光了”类似的提示。不过确实也没必要让用户感知到这个机制(你看,程序员又想做产品经理的主了),毕竟结果都是一样的。

对于这种火爆的活动,为了保证服务的稳定性,都需要对特定的接口进行限流,用Redis中的zset实现一个限流器该怎么做呢?

如何实现一个限流器

限流器需要实现的功能:在指定时间内,允许一定量的请求通过

redis zset方法java_时间戳_02

如图所示,横坐标代表了时间,坐标轴上有一个窗口顺着时间的方向,向前移动。窗口最前面的那条线表示的就是“现在”,每进入一个请求,就会在时间轴对应的当下时间处打上一个点。比如我们要实现一个1分钟最多100000次访问的限流器。那么窗口的大小就是1分钟,窗口一直向前移动,我们要保证被窗口框住的请求永远不超过100000个。

使用Redis的zset可以很方便的实现这个功能。主要用到以下几个命令:zremrangeByScore,zcard,zadd。每当一个请求进入,我们就向zset中添加一个member,score值是当前时间的毫秒数。member叫什么不重要,只要保证他不重复就行了。当判断一个请求能否通过的时候,就检测score的值处于“当前时间”和“1分钟之前”之间的member数量,如果超过了限定值,则被限流,否则加入到zset中,给该请求“放行”。为了保证原子性,我们可以选择使用lua脚本来编写逻辑代码。

--KEYS[1]:该次限流对应的key
--ARGV[1]:一分钟之前的时间戳
--ARGV[2]:此时此刻的时间戳
--ARGV[3]:允许通过的最大数量
--ARGV[4]:member名称(随机生成)
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
local res = redis.call('zcard', KEYS[1])
if (res == nil) or (res < tonumber(ARGV[3])) then
    redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
    return 0
else return 1 end

写一个demo,并发校验,可以看到输出(为了方便测试,我设定的是一分钟最多进入10个请求):

[pool-1-thread-72] INFO blog20210109.Limiter - 进入
[pool-1-thread-16] INFO blog20210109.Limiter - 进入
[pool-1-thread-42] INFO blog20210109.Limiter - 进入
[pool-1-thread-22] INFO blog20210109.Limiter - 进入
[pool-1-thread-91] INFO blog20210109.Limiter - 进入
[pool-1-thread-10] INFO blog20210109.Limiter - 进入
[pool-1-thread-33] INFO blog20210109.Limiter - 进入
[pool-1-thread-83] INFO blog20210109.Limiter - 进入
[pool-1-thread-62] INFO blog20210109.Limiter - 进入
[pool-1-thread-35] INFO blog20210109.Limiter - 进入
[main] INFO blog20210109.Limiter - 一分钟内进入的请求数有:10

彩蛋

最开始我的脚本是这样写的:

redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
local res = redis.call('zrangeByScore', KEYS[1], ARGV[1], ARGV[2])
if (res == nil) or (table.getn(res) < tonumber(ARGV[3])) then
    redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
    return 0
else return 1 end

测验的时候,总是限流失败。本来只允许进入10个,但每次总是会进入15个左右,你看出哪里的问题了吗?