SpringBoot 项目使用 Redis 对用户IP进行接口限流

news2024/12/23 9:55:50

本文主要参考了该篇文章:https://www.zhihu.com/question/586213782/answer/3038040317?utm_id=0

文章目录

  • 一、思路
    • 1.1 固定时间段(旧思路)
      • 1.1.1 思路描述
      • 1.1.2 思路缺陷
    • 1.2 滑动窗口(新思路)
      • 1.2.1 思路描述
      • 1.2.2 Redis部分的实现
        • **(1)选用何种 Redis 数据结构**
        • **(2)为何选择 zSet 数据结构**
        • **(3)zSet 如何进行范围检查(检查前几秒的访问次数)**
  • 二、代码实现
    • 2.1 固定时间段思路
      • 2.1.1 限流注解
      • 2.1.2 定义lua脚本
      • 2.1.3 注入Lua执行脚本
      • 2.1.3 定义Aop切面类
    • 2.2 滑动窗口思路
      • 2.2.1 限流注解
      • 2.2.2 定义Aop切面类

一、思路

使用接口限流的主要目的在于提高系统的稳定性,防止接口被恶意打击(短时间内大量请求)。

比如要求某接口1分钟内请求次数不超过1000次,那么应该如何设计代码呢?

下面讲两种思路,如果想看代码可直接翻到后面的代码部分

1.1 固定时间段(旧思路)

1.1.1 思路描述

该方案的思路是:使用Redis记录固定时间段内某用户IP访问某接口的次数,其中:

  • Rediskey:用户IP + 接口方法名
  • Redisvalue:当前接口访问次数。

当用户在近期内第一次访问该接口时,向Redis中设置一个包含了用户IP和接口方法名的keyvalue的值初始化为1(表示第一次访问当前接口)。同时,设置该key的过期时间(比如为60秒)。

之后,只要这个key还未过期,用户每次访问该接口都会导致value自增1次。

用户每次访问接口前,先从Redis中拿到当前接口访问次数,如果发现访问次数大于规定的次数(如超过1000次),则向用户返回接口访问失败的标识。

在这里插入图片描述

1.1.2 思路缺陷

该方案的缺点在于,限流时间段是固定的。

比如要求某接口1分钟内请求次数不超过1000次,观察以下流程:

在这里插入图片描述

时间点(分 : 秒)在此时间点的操作Redis 存储情况
00:00用户A第一次访问了该接口key = 127.0.0.1_test,value = 1
00:00时创建,01:00时过期)
00:59用户A连续访问该接口999次key = 127.0.0.1_test,value = 1000
01:00key = 127.0.0.1_test 已过期
01:01用户A连续访问该接口999次key = 127.0.0.1_test,value = 999
01:01时创建,02:01时过期)

可以发现,00:5901:01之间仅仅间隔了2秒,但接口却被访问了1000+999=1999次,是限流次数(1000次)的2倍!

所以在该方案中,限流次数的设置可能不起作用,仍然可能在短时间内造成大量访问

1.2 滑动窗口(新思路)

1.2.1 思路描述

为了避免出现方案1中由于键过期导致的短期访问量增大的情况,我们可以改变一下思路,也就是把固定的时间段改成动态的:

假设某个接口在10秒内只允许访问5次。用户每次访问接口时,记录当前用户访问的时间点(时间戳),并计算前10秒内用户访问该接口的总次数。如果总次数大于限流次数,则不允许用户访问该接口。这样就能保证在任意时刻用户的访问次数不会超过1000次。

如下图,假设用户在0:19时间点访问接口,经检查其前10秒内访问次数为5次,则允许本次访问。

在这里插入图片描述

假设用户0:20时间点访问接口,经检查其前10秒内访问次数为6次(超出限流次数5次),则不允许本次访问。

在这里插入图片描述

1.2.2 Redis部分的实现

(1)选用何种 Redis 数据结构

首先是需要确定使用哪个Redis数据结构。用户每次访问时,需要用一个key记录用户访问的时间点,而且还需要利用这些时间点进行范围检查

(2)为何选择 zSet 数据结构

为了能够实现范围检查,可以考虑使用Redis中的zSet有序集合。

添加一个zSet元素的命令如下:

ZADD [key] [score] [member]

它有一个关键的属性score,通过它可以记录当前member的优先级。

于是我们可以把score设置成用户访问接口的时间戳,以便于通过score进行范围检查key则记录用户IP和接口方法名,至于member设置成什么没有影响,一个member记录了用户访问接口的时间点。因此member也可以设置成时间戳。

(3)zSet 如何进行范围检查(检查前几秒的访问次数)

思路是,把特定时间间隔之前的member都删掉,留下的member就是时间间隔之内的总访问次数。然后统计当前key中的member有多少个即可。

① 把特定时间间隔之前的member都删掉。

zSet有如下命令,用于删除score范围在[min~max]之间的member

Zremrangebyscore [key] [min] [max]

假设限流时间设置为5秒,当前用户访问接口时,获取当前系统时间戳为currentTimeMill,那么删除的score范围可以设置为:

  • min = 0
  • max = currentTimeMill - 5 * 1000

相当于把5秒之前的所有member都删除了,只留下前5秒内的key

② 统计特定key中已存在的member有多少个。

zSet有如下命令,用于统计某个keymember总数:

 ZCARD [key]

统计的keymember总数,就是当前接口已经访问的次数。如果该数目大于限流次数,则说明当前的访问应被限流。

二、代码实现

主要是使用注解 + AOP的形式实现。

2.1 固定时间段思路

使用了lua脚本。

参考:https://blog.csdn.net/qq_43641418/article/details/127764462

2.1.1 限流注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {

    /**
     * 限流时间,单位秒
     */
    int time() default 5;

    /**
     * 限流次数
     */
    int count() default 10;
}

2.1.2 定义lua脚本

resources/lua下新建limit.lua

-- 获取redis键
local key = KEYS[1]
-- 获取第一个参数(次数)
local count = tonumber(ARGV[1])
-- 获取第二个参数(时间)
local time = tonumber(ARGV[2])
-- 获取当前流量
local current = redis.call('get', key);
-- 如果current值存在,且值大于规定的次数,则拒绝放行(直接返回当前流量)
if current and tonumber(current) > count then
    return tonumber(current)
end
-- 如果值小于规定次数,或值不存在,则允许放行,当前流量数+1  (值不存在情况下,可以自增变为1)
current = redis.call('incr', key);
-- 如果是第一次进来,那么开始设置键的过期时间。
if tonumber(current) == 1 then 
    redis.call('expire', key, time);
end
-- 返回当前流量
return tonumber(current)

2.1.3 注入Lua执行脚本

关键代码是limitScript()方法

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        // 使用Jackson2JsonRedisSerialize 替换默认序列化(默认采用的是JDK序列化)
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        redisTemplate.setKeySerializer(jackson2JsonRedisSerializer);
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        return redisTemplate;
    }


    /**
     * 解析lua脚本的bean
     */
    @Bean("limitScript")
    public DefaultRedisScript<Long> limitScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/limit.lua")));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}

2.1.3 定义Aop切面类

@Slf4j
@Aspect
@Component
public class RateLimiterAspect {
	@Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private RedisScript<Long> limitScript;

	@Before("@annotation(rateLimiter)")
    public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
        int time = rateLimiter.time();
        int count = rateLimiter.count();

        String combineKey = getCombineKey(rateLimiter.type(), point);
        List<String> keys = Collections.singletonList(combineKey);
        try {
            Long number = (Long) redisTemplate.execute(limitScript, keys, count, time);
            // 当前流量number已超过限制,则抛出异常
            if (number == null || number.intValue() > count) {
            	throw new RuntimeException("访问过于频繁,请稍后再试");
            }
            log.info("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, number.intValue(), combineKey);
        } catch (Exception ex) {
            ex.printStackTrace();
            throw new RuntimeException("服务器限流异常,请稍候再试");
        }
    }
    
    /**
     * 把用户IP和接口方法名拼接成 redis 的 key
     * @param point 切入点
     * @return 组合key
     */
    private String getCombineKey(JoinPoint point) {
        StringBuilder sb = new StringBuilder("rate_limit:");
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        sb.append( Utils.getIpAddress(request) );
        
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Class<?> targetClass = method.getDeclaringClass();
        // keyPrefix + "-" + class + "-" + method
        return sb.append("-").append( targetClass.getName() )
                .append("-").append(method.getName()).toString();
    }
}

2.2 滑动窗口思路

2.2.1 限流注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {

    /**
     * 限流时间,单位秒
     */
    int time() default 5;

    /**
     * 限流次数
     */
    int count() default 10;
}

2.2.2 定义Aop切面类

@Slf4j
@Aspect
@Component
public class RateLimiterAspect {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 实现限流(新思路)
     * @param point
     * @param rateLimiter
     * @throws Throwable
     */
    @SuppressWarnings("unchecked")
    @Before("@annotation(rateLimiter)")
    public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
        // 在 {time} 秒内仅允许访问 {count} 次。
        int time = rateLimiter.time();
        int count = rateLimiter.count();
        // 根据用户IP(可选)和接口方法,构造key
        String combineKey = getCombineKey(rateLimiter.type(), point);
        
        // 限流逻辑实现
        ZSetOperations zSetOperations = redisTemplate.opsForZSet();
        // 记录本次访问的时间结点
        long currentMs = System.currentTimeMillis();
        // 移除{time}秒之前的访问记录(滑动窗口思想)
        zSetOperations.removeRangeByScore(combineKey, 0, currentMs - time * 1000);
        // 获得当前窗口内的访问记录数(不含本次访问)
        Long currCount = zSetOperations.zCard(combineKey);
        // 把本次访问也算进去,如果总次数限流,则抛出异常
        if (currCount + 1 > count) {
            log.error("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, currCount, combineKey);
            throw new RuntimeException("访问过于频繁,请稍后再试!");
        }
        // 没有限流,则记录本次访问
        zSetOperations.add(combineKey, currentMs, currentMs);
        // 这一步是为了防止member一直存在于内存中
        redisTemplate.expire(combineKey, time, TimeUnit.SECONDS);
    }

    /**
     * 把用户IP和接口方法名拼接成 redis 的 key
     * @param point 切入点
     * @return 组合key
     */
    private String getCombineKey(JoinPoint point) {
        StringBuilder sb = new StringBuilder("rate_limit:");
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        sb.append( Utils.getIpAddress(request) );
        
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Class<?> targetClass = method.getDeclaringClass();
        // keyPrefix + "-" + class + "-" + method
        return sb.append("-").append( targetClass.getName() )
                .append("-").append(method.getName()).toString();
    }
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/744571.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

ELK 企业级日志分析系统(二)

ELK 一、FilebeatELK 部署二、logstash的使用grok正则捕获插件mutate 数据修改插件multiline 多行合并插件date 时间处理插件 一、FilebeatELK 部署 Node1节点&#xff08;2C/4G&#xff09;&#xff1a;node1/192.168.136.52 Elasticsearch Node2节点&#xff08;2C/4G&#…

第7集丨JavaScript 中函数——概述

目录 一、函数概览二、函数定义2.1 函数声明 (函数语句)2.2 函数表达式 (function expression)2.3 匿名函数立即执行2.4 函数生成器声明 (function* 语句)2.5 函数生成器表达式 (function*表达式)2.6 箭头函数表达式 (>)2.7 Function构造函数2.8 生成器函数的构造函数 三、函…

C#实现将小数值四舍五入为整数

一、需求说明 在项目的开发中&#xff0c;遇到一些除法计算内容会产生小数值&#xff0c;但是又需要根据项目的实际情况将这些小数内容化为整数&#xff0c;方便后续内容的实现。 二、需求分析 将小数内容转为整数【但是转为小数又分为几种情况】&#xff1a; ①将小数取为下…

银河麒麟服务器v10 sp1 部署 mysql 客户端工具 DBeaver

上一篇&#xff1a;银河麒麟服务器v10 sp1 安装mysql_csdn_aspnet的博客-CSDN博客 DBeaver 是数据库管理器的客户端&#xff0c;它允许以舒适的方式管理数据库实例的数据和选项。DBeaver 支持任何具有 JDBC 驱动程序的数据库 – MySQL/MariaDB、PostgreSQL、Oracle、DB2 LUW、…

性能测试工具 Loadrunner 和 Jmeter 测试结果大 PK

目录 前言&#xff1a; 测试一&#xff1a;1 个用户陆续执行登录操作&#xff0c;迭代 100 次&#xff0c;运行完就结束 测试二&#xff1a;50 个用户并发执行登录操作&#xff08;有集合点&#xff09; 前言&#xff1a; 性能测试工具LoadRunner和JMeter都是流行的工具&am…

ASP.net 配置CSP

ASP.net 开启全局CSP配置 <add name"Content-Security-Policy" value"default-src self; script-src self unsafe-inline unsafe-eval http://seal.digicert.com; style-src self unsafe-inline;" />在web.config中添加该行配置即可 配置完成后&am…

Im6ull 系统移植之 命令行

一 系统移植 系统移植 主要分四个不分由系统启动流程决定 U-Boot的移植 Linux内核的移植 根文件系统的构建系统烧写 1.1 交叉编译环境 交叉编译器有很多种&#xff0c;我们使用 Linaro 出品的交叉编译器&#xff0c; Linaro 是一间非营利性质的开 放源代码软件工程公…

LeetCode[11]盛水最多的容器

难度&#xff1a;Medium 题目&#xff1a; 给定一个长度为 n 的整数数组 height 。有 n 条垂线&#xff0c;第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。 找出其中的两条线&#xff0c;使得它们与 x 轴共同构成的容器可以容纳最多的水。 返回容器可以储存的最大水量。 …

enreka使用错误

错误 java.net.UnknownHostException: INVENTORYSERVICE 分析&解决&#xff1a; 这里的请求执行错误 但eureka可以找到服务 手动创建RestTemlate到容器中&#xff0c;未加LoadBalanced注解 加上注解后重试&#xff0c;成功

在linux服务器上的基础操作

1.查看该文件最后两行的记录 tail -n 2 book.txt2.查找文本中的内容 grep info book.txt3.查找内容的上下文本内容 $ grep -C 2 info book.txt

leetcode 701. 二叉搜索树中的插入操作

2023.7.12 比较简单&#xff0c;不多说直接看代码&#xff1a; 迭代法&#xff1a; class Solution { public:TreeNode* insertIntoBST(TreeNode* root, int val) {//树为空的情况if(rootnullptr){TreeNode* node new TreeNode(val);return node;}//需要一个pre节点指向父节…

Echart 数据更新了,X轴或者Y轴显示不变化的问题

应用场景&#xff1a;点击不同的图例&#xff0c;显示不同的X轴或者Y轴的图表 问题&#xff1a;数据打印更新了&#xff0c;Y轴仍然不变。 点第一个legend块&#xff0c;Y轴应该[名称1, 名称2, 名称3, 名称4, 名称5, 名称6, 名称7, 名称8], 点第二个legend块&#xff0c;Y轴应…

C#编码规范

一、 命名惯例和规范 注&#xff1a; Pascal&#xff1a;大小写形式&#xff0d;所有单词第一个字母大写&#xff0c;其他字母小写。 驼峰式&#xff1a;大小写形式&#xff0d;除了第一个单词&#xff0c;所有单词第一个字母大写&#xff0c;其他字母小写。 1&#xff1a;类…

Java 动态规划 Leetcode 213. 打家劫舍 II

代码展示&#xff1a; class Solution {public int rob(int[] nums) {int nnums.length;return Math.max(nums[0]childRob(nums,2,n-2),childRob(nums,1,n-1));}public int childRob(int[]nums,int left,int right){if(left>right){return 0;}int nnums.length;//创建数组i…

Unity基础 物理系统 铰链关节 Hinge Joint

铰链关节是一种模拟物理系统中的连接方式&#xff0c;它允许两个物体围绕一个共同的轴点旋转。这种连接方式可以用于模拟门、摆钟、机器人手臂等各种物理运动。 Connected Body&#xff08;连接体&#xff09;&#xff1a;这是铰链关节连接的另一个刚体。通过设置这个属性&…

谷歌自研芯片秘辛首次曝光;黑客要价 10 万美元出售雷蛇源代码等数据;百川智能发布 Baichuan-13B大模型(提供源码)

2023年7月12日科技新闻要点&#xff01;10秒概览&#xff01; 1.科大讯飞&#xff1a;下半年 all in 星火大模型 2.百川智能发布 Baichuan-13B&#xff08;源代码和安装及训练教程作为福利在文章最后告诉获取方式&#xff09; 3.谷歌自研芯片秘辛首次曝光 4.黑客要价 10 万…

第二十五章:InnoDB的数据存储结构

第二十五章&#xff1a;InnoDB的数据存储结构 25.1&#xff1a;数据库的存储结构&#xff1a;页 ​ 索引结构给我们提供了高效的索引方式&#xff0c;不过索引信息以及数据记录都是保存在文件上的&#xff0c;确切说是存储在页结构中。另一方面&#xff0c;索引是在存储引擎中…

N天爆肝数据库——MySQL(3)

本篇文章&#xff0c;主要对DCL、 函数、 约束和多表查询进行知识总结和学习。 期待和大家一起学习进步。DCL DCL-介绍 数据控制语言&#xff0c;用来管理数据库用户、控制数据库的访问权限。DCL-管理用户 查询用户 USE mysql; SELECT * FROM user; 创建用户 CREATE US…

Overleaf论文投稿

1.Overleaf 具体投稿 一步一步教&#xff01;&#xff01;&#xff01;以Springer Nature为例 2.如何在overleaf上寻找会议或期刊的模板 LaTex期刊模板下载与使用

Mocha Pro 2023 v10.0.2(win/mac)无套路安装教程

Mocha Pro 2023 Mocha Pro是一款世界知名的软件和插件&#xff0c;用于平面运动跟踪、旋转描记、对象移除、图像稳定和PowerMesh有机扭曲跟踪。 Mocha的工具对于视觉效果和后期制作至关重要&#xff0c;在最近每一部获得奥斯卡最佳视觉效果提名的电影中都发挥了重要作用。了解…