基于滑动窗口的限流方案

一、实现原理

根据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 上,因为它们的花括号内的内容相同

相关推荐
超级小忍16 分钟前
服务端向客户端主动推送数据的几种方法(Spring Boot 环境)
java·spring boot·后端
程序无bug20 分钟前
Spring IoC注解式开发无敌详细(细节丰富)
java·后端
小莫分享22 分钟前
Java Lombok 入门
java
程序无bug22 分钟前
Spring 对于事务上的应用的详细说明
java·后端
食亨技术团队24 分钟前
被忽略的 SAAS 生命线:操作日志有多重要
java·后端
苦学编程的谢38 分钟前
Maven
java·maven·intellij-idea
考虑考虑40 分钟前
Maven 依赖范围(Scope)
java·后端·maven
张小洛1 小时前
Spring AOP 设计解密:代理对象生成、拦截器链调度与注解适配全流程源码解析
java·后端·spring·spring aop·aop
Wyc724091 小时前
SpringBoot
java·spring boot·spring
Bella_chene1 小时前
IDEA中无法使用JSP内置对象
java·servlet·intellij-idea·jsp