Redis实现访问控制频率

高可用服务设计之二:Rate limiting 限流与降级

nginx限制请求之一:(ngx_http_limit_conn_module)模块

nginx限制请求之二:(ngx_http_limit_req_module)模块

nginx限制请求之三:Nginx+Lua+Redis 对请求进行限制

nginx限制请求之四:目录进行IP限制

Redis实现访问控制频率

 

一、redis的计数器INCR在限流场景的应用介绍
  1.1、INCR 限流应用的redis官方介绍
  1.2、INCR结合案例讲解
二、 redis的令牌桶限流算法实现
  2.1、lua脚本1---生成令牌的lua脚本:ratelimitInit.lua
  2.2、lua脚本2---获取令牌ratelimit.lua
  2.3、springboot中相关的代码

三、redis的计数器限流实现
四、redis的list限流实现
五、使用redis-cell模块对用户进行请求频率控制

 

 

在《高可用服务设计之二:Rate limiting 限流与降级》的应用级限流中,介绍了多种方法例如:

1、使用guava提供工具库里的RateLimiter类(内部采用令牌捅算法实现)进行限流 
2、使用Java自带delayqueue的延迟队列实现(编码过程相对麻烦,此处省略代码) 
3、使用Redis实现,存储两个key,一个用于计时,一个用于计数。请求每调用一次,计数器增加1,若在计时器时间内计数器未超过阈值,则可以处理任务。

可行性分析

最快捷且有效的方式是使用RateLimiter实现,但是这很容易踩到一个坑,单节点模式下,使用RateLimiter进行限流一点问题都没有。但是…线上是分布式系统,布署了多个节点,而且多个节点最终调用的是同一个短信服务商接口。虽然我们对单个节点能做到将QPS限制在400/s,但是多节点条件下,如果每个节点均是400/s,那么到服务商那边的总请求就是节点数x400/s,于是限流效果失效。使用该方案对单节点的阈值控制是难以适应分布式环境的,至少目前我还没想到更为合适的方式。 对于第二种,使用delayqueue方式。其实主要存在两个问题,

  1. 短信系统本身就用了一层消息队列,有用kafka,或者rabitmq,如果再加一层延迟队列,从设计上来说是不太合适的。
  2. 实现delayqueue的过程相对较麻烦,耗时可能比较长,而且达不到精准限流的效果
  3. 对于第三种,使用redis进行限流,其很好地解决了分布式环境下多实例所导致的并发问题。因为使用redis设置的计时器和计数器均是全局唯一的,不管多少个节点,它们使用的都是同样的计时器和计数器,因此可以做到非常精准的流控。同时,这种方案编码并不复杂,可能需要的代码不超过10行。

一、redis的计数器INCR在限流场景的应用介绍

 1.1、INCR 限流应用的redis官方介绍

首先建议大家好好阅读一下官方文章,如何利用incr命令实现一些应用模式(Pattern)。

INCR命令的介绍与应用

--下面是从redis中文官方文档中拷贝------------------------------------------------------------------------ --------------------------------------------------------------------------

实例: 限速器

限速器是一种可以限制某些操作执行速率的特殊场景。

传统的例子就是限制某个公共api的请求数目。

假设我们要解决如下问题:限制某个api每秒每个ip的请求次数不超过10次。

我们可以通过incr命令来实现两种方法解决这个问题。

实例: 限速器 1

更加简单和直接的实现如下:

FUNCTION LIMIT_API_CALL(ip)
ts = CURRENT_UNIX_TIME()
keyname = ip+":"+ts
current = GET(keyname)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second"
ELSE
    MULTI
        INCR(keyname,1)
        EXPIRE(keyname,10)
    EXEC
    PERFORM_API_CALL()
END

这种方法的基本点是每个ip每秒生成一个可以记录请求数的计数器。

但是这些计数器每次递增的时候都设置了10秒的过期时间,这样在进入下一秒之后,redis会自动删除前一秒的计数器。

注意上面伪代码中我们用到了MULTIEXEC命令,将递增操作和设置过期时间的操作放在了一个事务中, 从而保证了两个操作的原子性。

实例: 限速器 2

另外一个实现是对每个ip只用一个单独的计数器(不是每秒生成一个),但是需要注意避免竟态条件。 我们会对多种不同的变量进行测试。

FUNCTION LIMIT_API_CALL(ip):
current = GET(ip)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second"
ELSE
    value = INCR(ip)
    IF value == 1 THEN
        EXPIRE(value,1)
    END
    PERFORM_API_CALL()
END

上述方法的思路是,从第一个请求开始设置过期时间为1秒。如果1秒内请求数超过了10个,那么会抛异常。

否则,计数器会清零。

上述代码中,可能会进入竞态条件,比如客户端在执行INCR之后,没有成功设置EXPIRE时间。这个ip的key 会造成内存泄漏,直到下次有同一个ip发送相同的请求过来。

把上述INCR和EXPIRE命令写在lua脚本并执行EVAL命令可以避免上述问题(只有redis版本>=2.6才可以使用)

local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],1)
end

还可以通过使用redis的list来解决上述问题避免进入竞态条件。

实现代码更加复杂并且利用了一些redis的新的feature,可以记录当前请求的客户端ip地址。这个有没有好处 取决于应用程序本身。

FUNCTION LIMIT_API_CALL(ip)
current = LLEN(ip)
IF current > 10 THEN
    ERROR "too many requests per second"
ELSE
    IF EXISTS(ip) == FALSE
        MULTI
            RPUSH(ip,ip)
            EXPIRE(ip,1)
        EXEC
    ELSE
        RPUSHX(ip,ip)
    END
    PERFORM_API_CALL()
END

The RPUSHX command only pushes the element if the key already exists.

RPUSHX命令会往list中插入一个元素,如果key存在的话

上述实现也可能会出现竞态,比如我们在执行EXISTS指令之后返回了false,但是另外一个客户端创建了这个key。

后果就是我们会少记录一个请求。但是这种情况很少出现,所以我们的请求限速器还是能够运行良好的。

 -------------------------------------------------------------------------- -------------------------------------------------------------------------- --------------------------------------------------------------------------

1.2、INCR结合案例讲解

模式:计数器

Redis原子性自增操作,最明显的应用就是计数器了,类似Java的AtomicInteger。
可以结合EXPIRE,INCRBY,GET,SET,DECR等操作做很多很多事情。
多命令的情况下要注意事务或者使用Lua script哦。

模式:Rate limiter 限流器
限流器的应用

限流器的应用非常广泛,比如Github对外提供了非常丰富的API,但考虑到数据安全和系统资源,对匿名用户和经过认证的用户的请求API频率都是要有限制的。
可以看看Github API的Rate limiting
认证的用户每小时请求次数是5000,没认证的用户每小时只能请求60次,依靠原始IP来区分未认证用户。

上面介绍了一个很典型的应用场景,如果一个系统对我提供服务,开放API的话,为了防刷和系统资源的平衡,限流器的应用是很有必要的。
调用Github API返回结果的时候,response的Header里面都会带有限流的信息,这是一个非常好的设计,大致如下:

curl -i https://api.github.com/users/octocat
HTTP/1.1 200 OK
Date: Mon, 01 Jul 2013 17:27:06 GMT
Status: 200 OK
 
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 56
X-RateLimit-Reset: 1372700873

我在做网关设计中也借鉴过这种设计方式,另外也参考过spring-cloud-zuul微服务网关中的一个API限流库的代码,里面Filter的设计还是很不错的。

结合实例说明

针对每个来访IP,限制每秒只能访问10次。

模式1:string(key=ip+time)

KEY值的设计会决定你的解决方案。
一种是KEY是IP+当前秒数(UNIX时间戳),那么在该秒内的所有访问,都会对这个KEY执行INCR命令,这个KEY在当前秒之后就没用了其实,设置过期时间大于1秒即可。
该方案的伪码表示如下:

FUNCTION LIMIT_API_CALL(ip)
    ts = CURRENT_UNIX_TIME() 
    keyname = ip+":"+ts 
    current = GET(keyname)
    IF current != NULL AND current > 10 THEN
        ERROR "too many requests per second"
    ELSE    
        MULTI
        INCR(keyname,1) 
        EXPIRE(keyname,10) 
        EXEC
        PERFORM_API_CALL()
    END

显而易见的,该方案的缺点是系统访问量大时,比如当前秒有10000个IP来访问,Redis中就会出现10000个KEY,虽然有Redis的过期删除,10秒过期就会导致10秒内的所有IP访问的KEY堆积,大量占用Redis的内存。

模式2:string(key=ip)

这种设计也很直接啊,IP为KEY,过期时间1秒,有IP访问就自增,超过1秒,该KEY就会过期,后面的访问重新生成KEY。

FUNCTION LIMIT_API_CALL(ip):
current = GET(ip)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second" 
ELSE
    value = INCR(ip)
    IF value == 1 THEN
        EXPIRE(ip,1)
    END
    PERFORM_API_CALL()
END

官网很明确的指出了这里面的竞争条件,假如多个线程访问,都进入了ELSE进行了自增,ip的值就变为2或更大,EXPIRE没有执行,这个KEY就泄露了,永远保存在Redis中,只有后面又遇到相同IP地址的访问。
因为有IF判断语句,所以这里不能使用MULTI-EXEC事务,必须使用lua脚本,提升了设计复杂度。

local current
current = redis.call("incr",KEYS[1]) 
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],1)
end

 再列举一个示例:假定要限制每分钟每个用户最多只能访问100个页面。

string(key=userId)

通过为用户使用一个名为 rate.limiting:userId 的字符串类型键,每次访问都使用 INCR命令递增该键的键值。
如果递增后的值为 1(第一次访问),则要为键设置过期时间 60秒。
这样每次用户访问都读取该键值,当键值超过100时,说明访问频率超过了限制,需要稍后访问。
该键过期后会自动删除,所以下一分钟用户访问次数又会重新计算。

    $isKeyExists = EXISTS rate.limiting:$userId    // 存在返回 1,不存在返回 0
    if $isKeyExists is 1
        $times = INCR rate.limiting:$userId
        if $times > 100        // 第100次访问会增加到101
            print 访问频率超过限制,请稍后再试 
            exit
        else
            MULTI       //此处,如果不加事务,竞态条件可能出现    
                INCR rate.limiting:$userId     
                EXPIRE $keyName, 60        
            EXEC
        end
    end

 

上面为什么要用MULTI,那是因为如果在执行完INCR rate.limiting:$userId之后,如果(出现故障)没有设置过期时间,那么该键将永远存在,所以需要加上事务。

梳理一下思路

把限制逻辑封装到一个Lua脚本中,调用时只需传入:key、限制数量、过期时间,调用结果就会指明是否运行访问

lua脚本如下:

local notexists = redis.call("set", KEYS[1], 1, "NX", "EX", tonumber(ARGV[2]))
if (notexists) then
  return 1
end
local current = tonumber(redis.call("get", KEYS[1]))
if (current == nil) then
  local result = redis.call("incr", KEYS[1])
  redis.call("expire", KEYS[1], tonumber(ARGV[2]))
  return result
end
if (current >= tonumber(ARGV[1])) then
  error("too many requests")
end
local result = redis.call("incr", KEYS[1])
return result

 

脚本入参说明:

KEYS[1]:key
ARGV[1]:限制数量
ARGV[2]:过期时间

脚本出参说明:
error:限流
其它值:不限流

使用 eval 调用:

eval 脚本名称 key参数 , 允许的最大次数 过期时间参数

例如:

D:\soft\redis\Redis-3.2>redis-cli.exe -h 10.200.140.20 -p 36379 -a mima --eval limit1.lua ip123 , 1 1
(integer) 1

D:\soft\redis\Redis-3.2>redis-cli.exe -h 10.200.140.20 -p 36379 -a mima --eval limit1.lua ip123 , 1 1
(error) ERR Error running script (call to f_604b0036dc392f8994767fe7a558d022da118916): @user_script:12: user_script:12: too many requests

D:\soft\redis\Redis-3.2>redis-cli.exe -h 10.200.140.20 -p 36379 -a mima --eval limit1.lua ip123 , 1 1
(error) ERR Error running script (call to f_604b0036dc392f8994767fe7a558d022da118916): @user_script:12: user_script:12: too many requests

上面两种模式下的对单key的incr有一个问题:拿string(key=userId)来说,如果一个用户在第一分钟的最后一秒访问了99次,在下一分钟的第一秒访问了100次,相当于在两秒访问了199次,与一分钟内最多只能访问100次相比还是差距比较大,尽管这种情况比较极端,但是依然存在。如果要实现粒度更小的控制方式,精确的保证每分钟最多访问100次,就需要使用下面的新方案。

模式3:新思路使用list

新方案需要记录用户每次的访问时间,因此对于每个用户,用列表类型的键记录他最近100次访问的时间。
如果键中的元素超过100个,就判断时间最早的元素距离现在的时间是否小于1分钟,如果是,则表示用户最近1分钟的访问次数超过100次,如果不是就将当前时间加入列表中,同时把最早的元素删除。

    $limitLength = LLEN rate.limiting:$userId
    if $limitLength < 100
        LPUSH rate.limiting:$userId, now()
    else 
        $time = LINDEX rate.limiting:$userId, -1   // 取记录中最早插入的一个元素的时间
        if now() - $time < 60
            print 访问频率超过限制,请稍后再试
        else
            LPUSH rate.limiting:$userId, now()
            LTRIM rate.limiting:$userId, 0, 99     // 删除[0~99]以外的元素
        end
    end

这种方式 now() 的功能是获得当前的 Unix时间,由于要记录当前访问时间,所以当要限制 “A时间最多访问B次” 时,如果”B”比较大,会占用较多内存,实际使用时要去权衡。而且这种方法会出现竞态条件,可以通过脚本避免。

但是在高并发的缓存系统中,大量使用事务是非常糟糕的,可以用redis自带的lua脚本功能实现多个操作的“原子性”

直接上lua script好了:下面的lua中的2个参数:KEYS[1]就是访问IP,ARGV[2]是超时时间的ms值,这里是1000,ARGV[1]比较随意,可以是访问时间的ms毫秒。

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('rpush', KEYS[1], ARGV[1]);
    return redis.call('pexpire', KEYS[1], ARGV[2]);
else
    return redis.call('rpushx', KEYS[1], ARGV[1]);
end;

先执行LLEN(KEY),如果超过限制则返回,否则执行LUA脚本。

之前有个小同事在这里用了KEYS IP*的方式,类似模式1,这里大家要注意,在很多Redis的线上系统中是会禁用KEYS的,因为KEYS会造成系统CPU的使用率骤增,会导致系统不稳定。我直接改成了这个lua script的用法,现在运行的也很不错。

这个LUA脚本解决了官网说的竞争问题,官网的伪代码如下:

FUNCTION LIMIT_API_CALL(ip)
current = LLEN(ip)
IF current > 10 THEN
    ERROR "too many requests per second"
ELSE
    IF EXISTS(ip) == FALSE
        MULTI
          RPUSH(ip,ip)
          EXPIRE(ip,1)
        EXEC
    ELSE 
        RPUSHX(ip,ip)
    END
    PERFORM_API_CALL()
END

简单解释下,这里的竞争在IF EXISTS,多个线程同时判断了IF,都进入了IF,准备执行MULTI-EXEC,当然这里只能顺序执行,一个线程执行完之后,另一个线程也执行,EXPIRE以最后执行的线程为准,由于过期时间的改变,会有略微不准确的情况。

二、 redis的令牌桶限流算法实现

回忆一下令牌桶算法:

Lua脚本在Redis中运行,保证了取令牌和生成令牌两个操作的原子性。

将会有2个lua脚本,一个用于生成令牌,一个用于取令牌。

先看看redis的数据结构:

 

数据结构说明:

  • last_mill_second 最后时间毫秒 
  • curr_permits 当前可用的令牌 
  • max_burst 令牌桶最大值 
  • rate 每秒生成几个令牌 
  • app 应用 

令牌桶内令牌生成借鉴Guava-RateLimiter类的设计

2.1、lua脚本1---生成令牌的lua脚本:ratelimitInit.lua

local result=1
redis.pcall("HMSET",KEYS[1],
        "last_mill_second",ARGV[1],
        "curr_permits",ARGV[2],
        "max_burst",ARGV[3],
        "rate",ARGV[4],
        "app",ARGV[5])
return result

ratelimitInit.lua的入参说明:

  •  KEYS[1]:key
  • ARGV[1]:last_mill_second
  • ARGV[2]:curr_permits
  • ARGV[3]:max_burst
  • ARGV[4]:rate
  • ARGV[5]:app

出差说明:

 

调试

D:\soft\redis\Redis-3.2>redis-cli.exe -h 10.200.140.20 -p 36379 -a mima --eval ratelimitInit.lua ratelimit:ip123 , 60 1 2 1 app
(integer) 1

D:\soft\redis\Redis-3.2>

 

查看令牌桶生成的数据结构:

D:\soft\redis\Redis-3.2>redis-cli.exe -h 10.200.140.20 -p 36379 -a mima hgetall ratelimit:ip123
 1) "last_mill_second"
 2) "60"
 3) "curr_permits"
 4) "1"
 5) "max_burst"
 6) "2"
 7) "rate"
 8) "1"
 9) "app"
10) "app"

D:\soft\redis\Redis-3.2>

 

2.2、lua脚本2---获取令牌ratelimit.lua

每次getToken根据时间戳生成token,不超过最大值

local ratelimit_info=redis.pcall("HMGET",KEYS[1],"last_mill_second","curr_permits","max_burst","rate","app")
local last_mill_second=ratelimit_info[1]
local curr_permits=tonumber(ratelimit_info[2])
local max_burst=tonumber(ratelimit_info[3])
local rate=tonumber(ratelimit_info[4])
local app=tostring(ratelimit_info[5])
if app == nil then
    return 0
end

local local_curr_permits=max_burst;

if(type(last_mill_second) ~='boolean' and last_mill_second ~=nil) then
    local reverse_permits=math.floor((ARGV[2]-last_mill_second)/1000)*rate
    if(reverse_permits>0) then
        redis.pcall("HMSET",KEYS[1],"last_mill_second",ARGV[2])
    end

    local expect_curr_permits=reverse_permits+curr_permits
    local_curr_permits=math.min(expect_curr_permits,max_burst);

else
    redis.pcall("HMSET",KEYS[1],"last_mill_second",ARGV[2])
end

local result=-1
if(local_curr_permits-ARGV[1]>0) then
    result=1
    redis.pcall("HMSET",KEYS[1],"curr_permits",local_curr_permits-ARGV[1])
else
    redis.pcall("HMSET",KEYS[1],"curr_permits",local_curr_permits)
end

return result

ratelimit.lua的入参说明:

 KEYS[1]:key
ARGV[1]:
ARGV[2]:

调试:

D:\soft\redis\Redis-3.2>redis-cli.exe -h 10.200.140.20 -p 36379 -a mima --eval ratelimit.lua a123a , 1 2
(integer) -1

D:\soft\redis\Redis-3.2>

 

2.3、springboot中相关的代码

1、redis的配置:

# REDIS (RedisProperties)
# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=8
# 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=2000

 

2、redis连接

@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {

    @Override
    @Bean
    public KeyGenerator keyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object target, Method method, Object... params) {
                StringBuilder sb = new StringBuilder();
                sb.append(target.getClass().getName());
                sb.append(method.getName());
                for (Object obj : params) {
                    sb.append(obj.toString());
                }
                return sb.toString();
            }
        };
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate template = new StringRedisTemplate(factory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }

    @Bean("ratelimitLua")
    public DefaultRedisScript getRedisScript() {
        DefaultRedisScript redisScript = new DefaultRedisScript();
        redisScript.setLocation(new ClassPathResource("ratelimit.lua"));
        redisScript.setResultType(java.lang.Long.class);
        return redisScript;
    }
    @Bean("ratelimitInitLua")
    public DefaultRedisScript getInitRedisScript() {
        DefaultRedisScript redisScript = new DefaultRedisScript();
        redisScript.setLocation(new ClassPathResource("ratelimitInit.lua"));
        redisScript.setResultType(java.lang.Long.class);
        return redisScript;
    }


}


public class Constants {
    public static final String RATE_LIMIT_KEY = "ratelimit:";
}

public enum Token {
    SUCCESS,
    FAILED;
    public boolean isSuccess(){
        return this.equals(SUCCESS);
    }
    public boolean isFailed(){
        return this.equals(FAILED);
    }
}

如下是Java中判断是否需要限流的代码:

@Service
public class RateLimitClient {

    @Autowired
    StringRedisTemplate stringRedisTemplate;
    @Qualifier("getRedisScript")
    @Resource
    RedisScript<Long> ratelimitLua;
    @Qualifier("getInitRedisScript")
    @Resource
    RedisScript<Long> ratelimitInitLua;

    public Token initToken(String key){
        Token token = Token.SUCCESS;
        Long currMillSecond = stringRedisTemplate.execute(
                (RedisCallback<Long>) redisConnection -> redisConnection.time()
        );
        /**
         * redis.pcall("HMSET",KEYS[1],
         "last_mill_second",ARGV[1],
         "curr_permits",ARGV[2],
         "max_burst",ARGV[3],
         "rate",ARGV[4],
         "app",ARGV[5])
         */
        Long accquire = stringRedisTemplate.execute(ratelimitInitLua,
                Collections.singletonList(getKey(key)), currMillSecond.toString(), "1", "10", "10", "skynet");
        if (accquire == 1) {
            token = Token.SUCCESS;
        } else if (accquire == 0) {
            token = Token.SUCCESS;
        } else {
            token = Token.FAILED;
        }
        return token;
    }
    /**
     * 获得key操作
     *
     * @param key
     * @return
     */
    public Token accquireToken(String key) {
        return accquireToken(key, 1);
    }

    public Token accquireToken(String key, Integer permits) {
        Token token = Token.SUCCESS;
        Long currMillSecond = stringRedisTemplate.execute(
                (RedisCallback<Long>) redisConnection -> redisConnection.time()
        );

        Long accquire = stringRedisTemplate.execute(ratelimitLua,
                Collections.singletonList(getKey(key)), permits.toString(), currMillSecond.toString());
        if (accquire == 1) {
            token = Token.SUCCESS;
        } else {
            token = Token.FAILED;
        }
        return token;
    }

    public String getKey(String key) {
        return Constants.RATE_LIMIT_KEY + key;
    }

}

 

 三、redis的计数器限流实现

3.1、redis的incr限流脚本---incr-limit.lua

local notexists = redis.call("set", KEYS[1], 1, "NX", "EX", tonumber(ARGV[2]))
if (notexists) then
  return 1
end
local current = tonumber(redis.call("get", KEYS[1]))
if (current == nil) then
  local result = redis.call("incr", KEYS[1])
  redis.call("expire", KEYS[1], tonumber(ARGV[2]))
  return result
end
if (current >= tonumber(ARGV[1])) then
  error("too many requests")
end
local result = redis.call("incr", KEYS[1])
return result

 

 

incr-limit.lua入参说明:

KEYS[1]:key
ARGV[1]:限流最大值
ARGV[1]:限流间隔(超时时间)
调试:

 四、redis的list限流实现

4.1、redis的incr限流脚本---incr-limit.lua

local listLen, time
listLen = redis.call('LLEN', KEYS[1])
if listLen and tonumber(listLen) < tonumber(ARGV[1]) then
local a = redis.call('TIME');
redis.call('LPUSH', KEYS[1], a[1]*1000000+a[2])
else
time = redis.call('LINDEX', KEYS[1], -1)
local a = redis.call('TIME');
if a[1]*1000000+a[2] - time < tonumber(ARGV[2])*1000000 then
return 0;
else
redis.call('LPUSH', KEYS[1], a[1]*1000000+a[2])
redis.call('LTRIM', KEYS[1], 0, tonumber(ARGV[1])-1)
end
end
return 1

 

五、使用redis-cell模块对用户进行请求频率控制

在生产环境中使用令牌桶还需要考虑下面几个问题:

操作的原子性
先来看一下基于令牌桶如何判断用户的某次请求是否超限的过程:

  1. 获取用户上一次访问的时间戳t1和剩余令牌数量
  2. 计算当前时间到t1这段时间生成的令牌数量n1
  3. 上次剩余的令牌数量加上n1得到当前可用的令牌数量n2
  4. 判断n2是否大于等于本次请求要消耗的令牌数量

这个过程并不是原子性的,在高并发场景下存在数据竞争的问题。


记录存储
在分布式系统中,用户每次访问的机器可能不同,如何保证每次都能取到已有的访问记录?

  对于记录的存储,如果使用每台机器的local cache,就要在负载均衡器对uid或ip进行hash,让同一个uid或ip始终访问同一台机器,考虑到普通的hash算法在增减节点时会导致大量的key失效,最好要使用一致性hash算法以及考虑在节点数量变化时自动对数据进行迁移,实现起来比较的麻烦。一个比较简单的方式是让频率控制服务无状态,把令牌桶数据保存到第三方存储比如redis,利用像redis cluster等比较成熟的分布式分片存储工具去应对高并发的场景。至于如何实现操作的原子性,可以使用lua脚本把上面的令牌桶操作封装成一个原子性的操作,而今天要介绍的redis-cell是一个redis的扩展模块,提供了一个实现令牌桶算法的命令并且操作是原子性的,省去了自己开发lua脚本的麻烦。

安装方式
参考项目的git仓库

使用说明
该扩展模块只提供了一个命令:
CL.THROTTLE <key> <max_burst> <count per period> <period> [<quantity>]

参数说明
key: redis key,对单个用户进行请求频率控制时,可以用uid或者ip地址
max_burst: 令牌桶的容量,由于令牌桶算法中可以在请求频率低的时候积攒一定的令牌,所以令牌桶的容量也就反应了最大的突发流量
count per period: 指定时间段内生成的令牌数量,跟参数period一同决定了生成令牌的速度
quantity: 请求消耗的令牌数量,为可选参数,默认是1

示例
如果定义最大容量是200,每分钟生成500个令牌,每次成功访问消耗2个令牌,相应的命令如下:

cl.throttle user_1 200 500 60 2
1) (integer) 0
2) (integer) 201
3) (integer) 199
4) (integer) -1
5) (integer) 0
复制代码

命令响应说明:
1) 0表示允许访问,1表示访问被拒绝
2) 最大令牌数,初始把桶填满,其实现的默认值为max_burst+1,这里需要注意最大令牌数并不是max_burst参数,而是+1后的值
3) 剩余令牌数,由于上面的命令中指定每次请求消耗2个令牌,所以剩余199
4) 如果访问被拒绝,多少秒后可以重试,如果允许访问这个值为-1
5) 多少秒之后令牌桶会被填满,由于每分钟产生500个令牌,不到1秒令牌桶就会被重新填满,所以返回0

  redis-cell除了提供对令牌桶的原子性操作之外,命令的响应携带的信息也比较丰富,比如在用户获取验证码频率超限的时候,我们就可以利用响应中的第4个字段,给用户返回一个请xx秒之后再试的友好提示。

 参考:
https://redis.io/commands/INCR#pattern-rate-limiter
http://www.redis.cn/commands/incr.html
https://juejin.cn/post/6844904056297619463
https://www.cnblogs.com/niuben/p/10812369.html
https://blog.csdn.net/sunlihuo/article/details/79700225

 

local notexists = redis.call("set", KEYS[1], 1, "NX", "EX", tonumber(ARGV[2]))
if (notexists) then
  return 1
end
local current = tonumber(redis.call("get", KEYS[1]))
if (current == nil) then
  local result = redis.call("incr", KEYS[1])
  redis.call("expire", KEYS[1], tonumber(ARGV[2]))
  return result
end
if (current >= tonumber(ARGV[1])) then
  error("too many requests")
end
local result = redis.call("incr", KEYS[1])
return result
posted on 2015-05-11 11:32  duanxz  阅读(1893)  评论(0编辑  收藏  举报