基于滑动窗口的限流方案

ops/2025/1/11 15:45:54/

一、实现原理

根据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/ops/149190.html

相关文章

智慧城市可行性研究报告(第三章)

3 市警用地理平台建设 3.1 项目建设依据 3.1.1 政策依据 (1)“十三五”平安中国建设规划(征求意见稿) 规划强调公安信息化在公安行业所发挥的重要作用,强调强化基础信息采集、大数据汇集应用、情报综合研判、公共安全视频监控建设联网应用,加强专业化指挥力量建设,加强扁…

机器学习特征重要性之feature_importances_属性与permutation_importance方法

一、feature_importances_属性 在机器学习中&#xff0c;分类和回归算法的 feature_importances_ 属性用于衡量每个特征对模型预测的重要性。这个属性通常在基于树的算法中使用&#xff0c;通过 feature_importances_ 属性&#xff0c;您可以了解哪些特征对模型的预测最为重要…

【网络协议】EIGRP - 第一部分

概述 本文将给出有关距离矢量路由协议操作的基础概念、探讨 EIGRP 的基本原理并说明如何进行基本配置和验证。 文章目录 概述距离矢量路由协议EIGRP算法协议相关模块 (PDM)可靠传输协议 (RTP)EIGRP 数据包类型Hello 数据包Update 数据包确认包 (ACK)查询和回复包 EIGRP 路由传…

《通过财报看企业》

“借贷关系”“净资产收益率”“财务报表”、净利润、盈利能力、现金流 第1章 净利润&#xff1a;决定一家公司的股价能涨多高 企业经营&#xff1a;存货周转率 企业市值&#xff1a;市值净利润市盈率 龙头企业&#xff1a;行业内收入规模最大、盈利能力最强&#xff0c;…

RNN心脏病预测-Pytorch版本

本文为为&#x1f517;365天深度学习训练营内部文章 原作者&#xff1a;K同学啊 一 导入数据 import numpy as np import pandas as pd import torch from torch import nn import torch.nn.functional as F import seaborn as sns from sklearn.preprocessing import Standard…

html使用css外部类选择器

在写html时&#xff0c;可以在head标签里导入外部css样式&#xff0c;在body中需要使用这个类的标签时&#xff0c;可以标签中选择类&#xff08;class&#xff09;为定义的css样式。 <!DOCTYPE html> <html lang"en"> <head><meta charset&qu…

java后端对接飞书登陆

java后端对接飞书登陆 项目要求对接第三方登陆&#xff0c;飞书登陆&#xff0c;次笔记仅针对java后端&#xff0c;在看本笔记前&#xff0c;默认已在飞书开发方已建立了应用&#xff0c;并获取到了appid和appsecret。后端要做的其实很简单&#xff0c;基本都是前端做的&…

30天开发操作系统 第 12 天 -- 定时器 v1.0

前言 定时器(Timer)对于操作系统非常重要。它在原理上却很简单&#xff0c;只是每隔一段时间(比如0.01秒)就发送一个中断信号给CPU。幸亏有了定时器&#xff0c;CPU才不用辛苦地去计量时间。……如果没有定时器会怎么样呢?让我们想象一下吧。 假如CPU看不到定时器而仍想计量时…