简介:Redis 是一个开源的内存数据库,可以用来作为数据库、缓存、消息中间件等。Redis 是单线程的,又在内存中操作,所以速度极快,得益于 Redis 的各种特性,所以使用 Redis 实现一个限流工具是十分方便的。
1、添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、配置Redis信息
spring:
redis:
database: 0
password:
port: 6379
host: 127.0.0.1
lettuce:
shutdown-timeout: 100ms
pool:
min-idle: 5
max-idle: 10
max-active: 8
max-wait: 1ms
3、限流算法-----固定窗口限流
Redis 中的固定窗口限流是使用 incr
命令实现的,incr
命令通常用来自增计数;如果我们使用时间戳信息作为 key,自然就可以统计每秒的请求量了,以此达到限流目的。
注意:1:对于不存在的 key,第一次新增时,value 始终为 1。
2:INCR 和 EXPIRE 命令操作应该在一个原子操作中提交,以保证每个 key 都正确设置了过期时间,不然会有 key 值无法自动删除而导致的内存溢出。
3.1:lua 脚本实现
local count = redis.call("incr",KEYS[1])
if count == 1 then
redis.call('expire',KEYS[1],ARGV[2])
end
if count > tonumber(ARGV[1]) then
return 0
end
return 1
3.2:SpringBoot 中 RedisTemplate 实现 lua 脚本测试
@SpringBootTest
class RedisLuaLimiterByIncr {
private static String KEY_PREFIX = "limiter_";
private static String QPS = "4";
private static String EXPIRE_TIME = "1";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void redisLuaLimiterTests() throws InterruptedException, IOException {
for (int i = 0; i < 15; i++) {
Thread.sleep(200);
System.out.println(LocalTime.now() + " " + acquire("user1"));
}
}
/**
* 计数器限流
*
* @param key
* @return
*/
public boolean acquire(String key) {
// 当前秒数作为 key
key = KEY_PREFIX + key + System.currentTimeMillis() / 1000;
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Long.class);
//lua文件存放在resources目录下
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limiter.lua")));
return stringRedisTemplate.execute(redisScript, Arrays.asList(key), QPS, EXPIRE_TIME) == 1;
}
}
问题:代码中虽然限制了 QPS 为 4,但是因为这种限流实现是把毫秒时间戳作为 key 的,所以会有临界窗口突变的问题,因为时间窗口的变化,导致了 QPS 超过了限制值 4。
4、限流算法----滑动窗口限流
主要使用 ZSET
有序集合来实现滑动窗口限流。
特点:
1:ZSET 集合中的 key 值可以自动排序。
2:ZSET 集合中的 value 不能有重复值。
3:ZSET 集合可以方便的使用 ZCARD 命令获取元素个数。
4:ZSET 集合可以方便的使用 ZREMRANGEBYLEX 命令移除指定范围的 key 值。
4.1:lua 脚本
--KEYS[1]: 限流 key
--ARGV[1]: 时间戳 - 时间窗口
--ARGV[2]: 当前时间戳(作为score)
--ARGV[3]: 阈值
--ARGV[4]: score 对应的唯一value
-- 1. 移除时间窗口之前的数据
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
-- 2. 统计当前元素数量
local res = redis.call('zcard', KEYS[1])
-- 3. 是否超过阈值
if (res == nil) or (res < tonumber(ARGV[3])) then
redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
return 1
else
return 0
end
4.2:SpringBoot 中 RedisTemplate 实现 lua 脚本测试。
@SpringBootTest
class RedisLuaLimiterByZset {
private String KEY_PREFIX = "limiter_";
private String QPS = "4";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void redisLuaLimiterTests() throws InterruptedException, IOException {
for (int i = 0; i < 15; i++) {
Thread.sleep(200);
System.out.println(LocalTime.now() + " " + acquire("user1"));
}
}
/**
* 计数器限流
*
* @param key
* @return
*/
public boolean acquire(String key) {
long now = System.currentTimeMillis();
key = KEY_PREFIX + key;
String oldest = String.valueOf(now - 1_000);
String score = String.valueOf(now);
String scoreValue = score;
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Long.class);
//lua文件存放在resources目录下
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limiter2.lua")));
return stringRedisTemplate.execute(redisScript, Arrays.asList(key), oldest, score, QPS, scoreValue) == 1;
}
}