虽然Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并提供了原子的限流指令。有了这个模块,限流问题就非常简单了。
但是我们用4.0以下的版本怎么办?还好经过我的查找我还是找到了解决的办法的。
经过我的查找有两个方案:
方案一:计数器法,实现比较简单,但是可能在一个窗口期内会有两倍的请求量。所以舍弃。
方案二:用zset数据结构,实现窗口滑动,但是这个方案如果是几千用户还行,如果是几十万上百万用户的话,那这个内存消耗太巨大了。
方案三:Redis+lua 实现令牌桶。这时我目前找到最合适的方案了
我们重点讲一下方案三:为啥要用lua,因为redis读取lua脚本可以实现原子性操作,所以原子性的问题就解决了,而且令牌桶的这个方案就是漏斗算法来的,很好的解决了窗口期两倍请求量的问题。
public class VisitLimitUtils {
private static VisitLimitUtils visitLimitUtils = new VisitLimitUtils();
public static VisitLimitUtils getInstance(){
return visitLimitUtils;
}
/**
*
* @param jedis
* @param lockKey 添加令牌Key
* @return
*/
public boolean set(Jedis jedis, String lockKey) {
lockKey = "visitLimit:"+lockKey;
String script = getLuaScript();
List<String> argvList = new ArrayList<>();
//桶最大容量
argvList.add(GameContext.redisBucketapacity);
//每次添加令牌数
argvList.add(GameContext.redisAddToken);
//令牌添加间隔(毫秒)
argvList.add(GameContext.redisAddInterval);
argvList.add(System.currentTimeMillis()+"");
Long result = (Long) jedis.eval(script, Collections.singletonList(lockKey), argvList);
Long RELEASE_SUCCESS = -1L;
//使用完的jedis连接要释放会连接池
jedis.close();
if (RELEASE_SUCCESS.equals(result)) {
return false;
}
return true;
}
/**
* 获取lua脚本,令牌桶限流,redis执行lua脚本可以保证原子性
* @return
*/
private String getLuaScript(){
/* KEYS[1] string 限流的key
ARGV[1] int 桶最大容量
ARGV[2] int 每次添加令牌数
ARGV[3] int 令牌添加间隔(毫秒)
ARGV[4] int 当前时间戳*/
return "local bucket_capacity = tonumber(ARGV[1])\n" +
"local add_token = tonumber(ARGV[2])\n" +
"local add_interval = tonumber(ARGV[3])\n" +
"local now = tonumber(ARGV[4])\n" +
" \n" +
"-- 保存上一次更新桶的时间的key\n" +
"local LAST_TIME_KEY = KEYS[1]..\"_time\"; \n" +
"-- 获取当前桶中令牌数\n" +
"local token_cnt = redis.call(\"get\", KEYS[1]) \n" +
"-- 桶完全恢复需要的最大时长\n" +
"local reset_time = math.ceil(bucket_capacity / add_token) * add_interval;\n" +
" \n" +
"if token_cnt then -- 令牌桶存在\n" +
" -- 上一次更新桶的时间\n" +
" local last_time = redis.call('get', LAST_TIME_KEY)\n" +
" -- 恢复倍数\n" +
" local multiple = math.floor((now - last_time) / add_interval)\n" +
" -- 恢复令牌数\n" +
" local recovery_cnt = multiple * add_token\n" +
" -- 确保不超过桶容量\n" +
" local token_cnt = math.min(bucket_capacity, token_cnt + recovery_cnt) - 1\n" +
" \n" +
" if token_cnt < 0 then\n" +
" return -1;\n" +
" end\n" +
" \n" +
" -- 重新设置过期时间, 避免key过期\n" +
" redis.call('set', KEYS[1], token_cnt, 'EX', reset_time) \n" +
" redis.call('set', LAST_TIME_KEY, last_time + multiple * add_interval, 'EX', reset_time)\n" +
" return token_cnt\n" +
" \n" +
"else -- 令牌桶不存在\n" +
" token_cnt = bucket_capacity - 1\n" +
" -- 设置过期时间避免key一直存在\n" +
" redis.call('set', KEYS[1], token_cnt, 'EX', reset_time);\n" +
" redis.call('set', LAST_TIME_KEY, now, 'EX', reset_time + 1000); \n" +
" return token_cnt \n" +
"end";
}
public static void main(String[] args) throws InterruptedException {
HttpDispatcher.getInstance().load("com.cloud.gold");
Jedis jedis = RedisUtil.INSTANCE.getJedis();
for (;;){
System.out.println(VisitLimitUtils.getInstance().set(jedis,"10001"));
}
}
}