基于滑动窗口的限流方案

server/2025/1/11 15:59:16/

一、实现原理

根据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脚本

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


http://www.ppmy.cn/server/157511.html

相关文章

【IEEE出版,连续4年EI收录,检索稳定 |南京航空航天大学主办,航空航天交叉研究院承办】第五届传感器与信息技术国际学术会议(ICSI 2025)

重要信息&#xff1a; 本会议已签约合作 IEEE 出版社&#xff01;ISBN号: 979-8-3315-4288-7 连续4年EI收录&#xff0c;检索稳定&#xff01;南京航空航天大学主办&#xff0c;航空航天交叉研究院承办 大会官网&#xff1a;更多详情【论文投稿】 截稿时间&#xff1a;以官…

走进 JavaScript 世界:掌握核心技能

目录 一、引言 二、JavaScript 基础 &#xff08;一&#xff09;历史与特点 脚本语言 与 HTML 和 CSS 紧密结合 事件驱动和异步执行 &#xff08;二&#xff09;语法基础 变量与数据类型 运算符与表达式 控制结构 条件语句 循环语句 &#xff08;三&#xff09;函…

UDP -- 简易聊天室

目录 gitee&#xff08;内有详细代码&#xff09; 图解 MessageRoute.hpp UdpClient.hpp UdpServer.hpp Main.hpp 运行结果&#xff08;本地通信&#xff09; 如何分开对话显示&#xff1f; gitee&#xff08;内有详细代码&#xff09; chat_room zihuixie/Linux_Lear…

谷歌浏览器的开发者文档与资源

谷歌浏览器不仅是全球最流行的web浏览器之一&#xff0c;还提供了功能强大的开发者工具和丰富的开发资源。本文将详细介绍如何利用这些工具和资源来 提升你的网页开发效率。 一、Chrome DevTools简介 Chrome DevTools是一套内置于谷歌浏览器中的开发者工具&#xff0c;允许开发…

国产linux系统(银河麒麟,统信uos)使用 PageOffice 实现后台生成单个PDF文档

PageOffice 国产版 &#xff1a;支持信创系统&#xff0c;支持银河麒麟V10和统信UOS&#xff0c;支持X86&#xff08;intel、兆芯、海光等&#xff09;、ARM&#xff08;飞腾、鲲鹏、麒麟等&#xff09;、龙芯&#xff08;LoogArch&#xff09;芯片架构。 PageOffice 版本&…

计算机网路HTTP、TCP详解

HTTP HTTP基本概念 HTTP&#xff08;超文本传输协议&#xff09;&#xff1a;HTTP是在计算机世界中两点之间传输文字、图片、视频等超文本内容数据的约束与规范。 常见状态码&#xff1a; 2xx&#xff1a;报文被收到&#xff0c;已经在正确处理中。 3xx&#xff1a;重定向…

编写python的几种方式

编写python的三种方式&#xff1a; 1、在IDLE中直接编写 但这个方法是单命令执行&#xff0c;也就是编写一行后按回车键就会运行一下&#xff0c;适用于简单计算&#xff0c;不适用于编写多行代码 2、进入IDLE后新建一个编写页面&#xff0c;这个页面可以编写多行代码并统一执…

Spring MVC详细介绍

1.MVC 设计模式 MVC&#xff08;Model-View-Controller&#xff09;是一种常见的软件设计模式&#xff0c;用于将应用程序的逻辑分离成三个独立的组件&#xff1a; 模型&#xff08;Model&#xff09;&#xff1a;模型是应用程序的数据和业务逻辑的表示。它负责处理数据的读取…