一、实现原理
根据Redis有序集合(sorted set)结构特点,sorted set的member作为独立的请求元素,score作为时间戳
逻辑图如下
物理图如下
二、代码实现
DistributedSlidingWindowLimiter.java文件
java
@Resource
private JedisClient jedisClient;
/**
* 滑动窗口
* 该方法存在原子性问题,会导致数据不准确或者多个命令被切割干扰
* @param key
* @param windowTime 窗口时间(单位秒)
* @param limit 限制数量
* @return
*/
public boolean isAllowed(String key, long windowTime, int limit) {
String redisKey = "rate_limit:" + key;
long now = System.currentTimeMillis();
// 当前时间窗开始
long windowStart = now - windowTime * 1000;
// member需要唯一,避免相同毫秒并发请求,导致score一样,member也可以
String uuid = UUID.randomUUID().toString();
// 删除当前开始时间窗口之前的请求
jedisClient.zremrangeByScore(redisKey, 0, windowStart);
// 新的请求
jedisClient.zadd(redisKey, now, uuid);
// 剩下的元素数量就是请求数量
Long count = jedisClient.zcard(redisKey);
if (count != null && count > limit) {
return false;
}
return true;
}
单元测试
java
/**
* 非原子性滑动窗口
* 单个进行测试
*/
@Test
public void test1(){
String key = "timeKey";
// 时间窗口(单位秒)
long windowTime = 10;
// 时间窗口允许通过的请求次数
int limit = 100;
for(int i=0; i<100; i++){
boolean allowedAtomic = distributedSlidingWindowLimiter.isAllowed(key, windowTime, limit);
if(allowedAtomic){
log.info("通过");
}else{
log.info("不通过");
}
}
}
因为涉及同时多个redis操作命令,该方法存在原子性问题,高并发下,未清理掉超时请求,导致计数异常
使用lua脚本进行优化
resources\script\luaSlidWindowScript.txt
路径下独立存放lua脚本
Lua
local key = KEYS[1]
local now = tonumber(ARGV[1])
local uniqueMember = ARGV[2]
local windowTime = tonumber(ARGV[3])
local limit = tonumber(ARGV[4])
-- 1. 添加当前请求到有序集合
redis.call('zadd', key, now, uniqueMember)
-- 2. 删除超时的请求
redis.call('zremrangebyscore', key, 0, now - windowTime * 1000)
-- 3. 统计当前请求的数量
local count = redis.call('zcard', key)
-- 4. 判断是否超出限流阈值
if count > limit then
-- 超过限流
return 0
else
redis.call('expire', key, windowTime)
-- 允许请求
return 1
end
封装读取lua脚本的函数
java
/**
* 读取resources目录下的脚本文件
* @param filePath
* @return
*/
private String loadScript(String filePath) {
try {
// 获取文件路径
Path path = Paths.get(getClass().getClassLoader().getResource(filePath).toURI());
// 读取文件内容
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
// 过滤 --注释
lines = lines.stream().filter(e -> !e.startsWith("--")).collect(Collectors.toList());
// 拼接成单个字符串
return String.join("\n", lines);
} catch (Exception e) {
throw new RuntimeException("Failed to load script from file: " + filePath, e);
}
}
新增isAllowedAtomic方法
java
/**
* 原子性
* 判断请求是否被允许
* 优化并发出现的问题
* 保障多个redis命令执行期间的原子性, 在脚本执行的过程中,不会受到其他客户端对 Redis 的干扰
* 同时限流的逻辑变得更可靠,即便在高并发场景下,也能保持数据一致性
* @param key 限流的 Redis 键
* @param windowTime 时间窗口(秒)
* @param limit 最大允许请求数
* @param luaScriptFile lua脚本文本
* @return true 表示允许请求,false 表示限流
*/
public boolean isAllowedAtomic(String key, long windowTime, int limit, String luaScriptFile) {
// Lua 脚本
String luaScript = loadScript(luaScriptFile);
// 当前时间(毫秒)
long now = System.currentTimeMillis();
List<String> keys = Collections.singletonList(key);
List<String> args = new ArrayList<>();
args.add(String.valueOf(now));
// member需要唯一,避免相同毫秒并发请求,导致score一样,member也可以
args.add(UUID.randomUUID().toString().replace("-", ""));
args.add(String.valueOf(windowTime));
args.add(String.valueOf(limit));
// 执行 Lua 脚本
Object result = jedisClient.eval(luaScript, keys, args);
// 判断结果
return Integer.parseInt(result.toString()) == 1;
}
单元测试
java
/**
* 原子性滑动窗口
* 单个进行测试
*/
@Test
public void test2(){
String luaFile = "script/luaSlidWindowScript.txt";
String key = "timeKey";
// 时间窗口(单位秒)
long windowTime = 10;
// 时间窗口允许通过的请求次数
int limit = 100;
boolean allowedAtomic = distributedSlidingWindowLimiter.isAllowedAtomic(key, windowTime, limit, luaFile);
if(allowedAtomic){
log.info("通过");
}else{
log.info("不通过");
}
}
/**
* 循环进行压测请求
*/
@Test
public void test3(){
String luaFile = "script/luaSlidWindowScript.txt";
String key = "timeKey";
// 时间窗口(单位秒)
long windowTime = 5;
// 时间窗口允许通过的请求次数
int limit = 5 * 10;
int succ = 0;
int fail = 0;
for(int i = 0; i < 500; i++){
long s = System.currentTimeMillis();
boolean allowedAtomic = distributedSlidingWindowLimiter.isAllowedAtomic(key, windowTime, limit, luaFile);
long e = System.currentTimeMillis();
if(allowedAtomic){
succ++;
log.info("通过 spend time={}", e-s);
}else{
fail++;
log.info("不通过 spend time={}", e-s);
}
}
log.info("done; succ={}; fail={}", succ, fail);
}
三、滑动窗口升级为lua脚本的作用
模拟问题场景
假设你没有使用 Lua 脚本,而是使用普通的 Redis 命令来实现限流:
在高并发下可能会出现以下问题:
请求穿透:
线程 A 执行到统计请求数量时,发现未超过限流。
在线程 A 处理完之前,线程 B 插入了更多请求,导致最终超过限流,但线程 A 仍然允许了请求。
数据竞争:
线程 A 和线程 B 同时执行 zadd 和 zremrangebyscore,结果未清理掉超时请求,导致计数异常。
Lua 脚本的优势
单线程执行:Redis 保证脚本从 zadd 到 zremrangebyscore 再到 zcard 是连续且不被打断的。
一致性保障:限流逻辑完全在脚本内完成,不会受到其他客户端的影响。
总结
Redis 的 Lua 脚本通过其原子性解决了高并发场景下的计数不一致问题,特别适合需要对多个 Redis 命令组合操作的场景,例如分布式限流器。它的核心作用包括:
保证数据一致性:所有操作作为一个整体执行,避免并发问题。
简化并发控制:无需使用分布式锁等额外机制。
提升执行效率:将多个命令合并为一个脚本调用,减少网络开销。
通过 Lua 脚本,你可以在高并发和分布式场景中实现更可靠的限流机制
四、redis集群使用lua脚本注意事项
在 Redis 集群中使用 Lua脚本需要注意Key的Slot分配问题
1.原子性无法保证:
如果 Lua 脚本操作的多个 Key 分布在不同的哈希槽(以及不同的节点)上,那么 Redis 集群无法保证这些操作的原子性。这意味着,脚本执行过程中可能出现部分 Key 被修改,而另一部分 Key 未被修改的情况,导致数据不一致。这是绝对不能接受的,尤其是在需要事务性操作的场景下。
2.CROSSSLOT 错误:
如果你尝试在 Lua 脚本中操作分布在不同哈希槽的 Key,Redis 集群会返回 CROSSSLOT 错误,阻止脚本的执行。这是 Redis 集群为了保证数据一致性而采取的强制措施。
解决
1.单 Key 操作:
如果你的 Lua 脚本只操作一个 Key,那么就不存在跨槽的问题,可以直接使用
2.Hash Tag(哈希标签):
这是推荐的方法。通过在 Key 中使用花括号 {} 包裹一部分字符串,可以强制让这部分字符串参与哈希计算,从而控制 Key 的 Slot 分配。例如,{user1}:data1 和 {user1}:data2 就会被分配到同一个 Slot 上,因为它们的花括号内的内容相同