Bootstrap

基于滑动窗口的限流方案

一、实现原理

根据Redis有序集合(sorted set)结构特点,sorted set的member作为独立的请求元素,score作为时间戳

逻辑图如下

物理图如下

二、代码实现

DistributedSlidingWindowLimiter.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;
}

单元测试

/**
 * 非原子性滑动窗口
 * 单个进行测试
 */
@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脚本

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脚本的函数

/**
 * 读取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方法

/**
 * 原子性
 * 判断请求是否被允许
 * 优化并发出现的问题
 * 保障多个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;
}

单元测试

/**
 * 原子性滑动窗口
 * 单个进行测试
 */
@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 上,因为它们的花括号内的内容相同

;