Redis 多规则限流和防重复提交方案实现

技术老男孩 2024-02-01 09:21 发表于广东

技术老男孩

分享技术路上的点滴,专注于后端技术,助力开发者成长,欢迎关注。

55篇原创内容

公众号

图片

一、简介

图片

市面上很多介绍 Redis 如何实现限流的,但是大部分都有一个缺点,就是只能实现单一的限流,比如 1 分钟访问 1 次或者 60 分钟访问 10 次这种,但是如果想一个接口两种规则都需要满足呢,我们的项目又是分布式项目,应该如何解决,下面就介绍一下 Redis 实现分布式多规则限流的方式。

图片

二、思考

图片

  1. 如何一分钟只能发送一次验证码,一小时只能发送 10 次验证码等等多种规则的限流;

  2. 如何防止接口被恶意打击(短时间内大量请求);

  3. 如何限制接口规定时间内访问次数。

图片

三、解决方法

图片

3.1 使用 String 结构记录固定时间段内某用户 IP 访问某接口的次数

  • RedisKey = prefix : className : methodName

  • RedisVlue = 访问次数

拦截请求:

  1. 初次访问时设置 [RedisKey] [RedisValue=1] [规定的过期时间];

  2. 获取 RedisValue 是否超过规定次数,超过则拦截,未超过则对 RedisKey 进行加1。

分析: 规则是每分钟访问 1000 次

  1. 考虑并发问题

    • 假设目前 RedisKey => RedisValue 为 999;

    • 目前大量请求进行到第一步( 获取 Redis 请求次数 ),那么所有线程都获取到了值为999,进行判断都未超过限定次数则不拦截,导致实际次数超过 1000 次

    • 解决办法: 保证方法执行原子性(加锁、Lua)。

  2. 考虑在临界值进行访问

    • 思考下图

图片

代码实现: 比较简单(可参考文末代码链接)。

3.2 使用 Zset 进行存储,解决临界值访问问题

图片

网上几乎都有实现,这里就不过多介绍。

1) 先确定最终需要的效果

  • 能实现多种限流规则

  • 能实现防重复提交

通过以上要求设计注解(先想象出最终实现效果)。

 
@RateLimiter(        rules = {                // 60秒内只能访问10次                @RateRule(count = 10, time = 60, timeUnit = TimeUnit.SECONDS),                // 120秒内只能访问20次                @RateRule(count = 20, time = 120, timeUnit = TimeUnit.SECONDS)
        },        // 防重复提交 (5秒钟只能访问1次)        preventDuplicate = true)

2) 编写注解(RateLimiter,RateRule)

编写 RateLimiter 注解:

 
/** * @Description: 请求接口限制 * @Author: yiFei */@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Inheritedpublic @interface RateLimiter {
    /**     * 限流key     */    String key() default RedisKeyConstants.RATE_LIMIT_CACHE_PREFIX;
    /**     * 限流类型 ( 默认 Ip 模式 )     */    LimitTypeEnum limitType() default LimitTypeEnum.IP;
    /**     * 错误提示     */    ResultCode message() default ResultCode.REQUEST_MORE_ERROR;
    /**     * 限流规则 (规则不可变,可多规则)     */    RateRule[] rules() default {};
    /**     * 防重复提交值     */    boolean preventDuplicate() default false;
    /**     * 防重复提交默认值     */    RateRule preventDuplicateRule() default @RateRule(count = 1, time = 5);}

编写 RateRule 注解:

@Target(ElementType.ANNOTATION_TYPE)@Retention(RetentionPolicy.RUNTIME)@Inheritedpublic @interface RateRule {
    /**     * 限流次数     */    long count() default 10;
    /**     * 限流时间     */    long time() default 60;
    /**     * 限流时间单位     */    TimeUnit timeUnit() default TimeUnit.SECONDS;
}
 

3) 拦截注解 RateLimiter

  • 确定 Redis 存储方式

    • RedisKey = prefix : className : methodName

    • RedisScore = 时间戳

    • RedisValue = 任意分布式不重复的值即可

  • 编写生成 RedisKey 的方法

 
/** * 通过 rateLimiter 和 joinPoint 拼接  prefix : ip / userId : classSimpleName - methodName * * @param rateLimiter 提供 prefix * @param joinPoint   提供 classSimpleName : methodName * @return */public String getCombineKey(RateLimiter rateLimiter, JoinPoint joinPoint) {    StringBuffer key = new StringBuffer(rateLimiter.key());    // 不同限流类型使用不同的前缀    switch (rateLimiter.limitType()) {        // XXX 可以新增通过参数指定参数进行限流        case IP:            key.append(IpUtil.getIpAddr(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest())).append(":");            break;        case USER_ID:            SysUserDetails user = SecurityUtil.getUser();            if (!ObjectUtils.isEmpty(user)) key.append(user.getUserId()).append(":");            break;        case GLOBAL:            break;    }    MethodSignature signature = (MethodSignature) joinPoint.getSignature();    Method method = signature.getMethod();    Class<?> targetClass = method.getDeclaringClass();    key.append(targetClass.getSimpleName()).append("-").append(method.getName());    return key.toString();}

4) 编写 Lua 脚本 (两种将时间添加到 Redis 的方法)

a. UUID(可用其他有相同的特性的值)为 Zset 中的 value 值

  • 参数介绍

    • KEYS[1] = prefix : ? : className : methodName

    • KEYS[2] = 唯一ID

    • KEYS[3] = 当前时间

    • ARGV = [次数,单位时间,次数,单位时间, 次数, 单位时间 ...]

  • 由 Java传入分布式不重复的 value 值

-- 1. 获取参数local key = KEYS[1]local uuid = KEYS[2]local currentTime = tonumber(KEYS[3])-- 2. 以数组最大值为 ttl 最大值local expireTime = -1;-- 3. 遍历数组查看是否超过限流规则for i = 1, #ARGV, 2 do    local rateRuleCount = tonumber(ARGV[i])    local rateRuleTime = tonumber(ARGV[i + 1])    -- 3.1 判断在单位时间内访问次数    local count = redis.call('ZCOUNT', key, currentTime - rateRuleTime, currentTime)    -- 3.2 判断是否超过规定次数    if tonumber(count) >= rateRuleCount then        return true    end    -- 3.3 判断元素最大值,设置为最终过期时间    if rateRuleTime > expireTime then        expireTime = rateRuleTime    endend-- 4. redis 中添加当前时间redis.call('ZADD', key, currentTime, uuid)-- 5. 更新缓存过期时间redis.call('PEXPIRE', key, expireTime)-- 6. 删除最大时间限度之前的数据,防止数据过多redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - expireTime)return false
b. 根据时间戳作为 Zset 中的 value 值

  • 参数介绍

    • KEYS[1] = prefix : ? : className : methodName

    • KEYS[2] = 当前时间

    • ARGV = [次数,单位时间,次数,单位时间, 次数, 单位时间 ...]

  • 根据时间进行生成 value 值,考虑同一毫秒添加相同时间值问题

    • 以下为第二种实现方式,在并发高的情况下效率低,value 是通过时间戳进行添加,但是访问量大的话会使得一直在调用 redis.call('ZADD', key, currentTime, currentTime),但是在不冲突 value 的情况下,会比生成 UUID 好。

 
-- 1. 获取参数local key = KEYS[1]local currentTime = KEYS[2]-- 2. 以数组最大值为 ttl 最大值local expireTime = -1;-- 3. 遍历数组查看是否越界for i = 1, #ARGV, 2 do    local rateRuleCount = tonumber(ARGV[i])    local rateRuleTime = tonumber(ARGV[i + 1])    -- 3.1 判断在单位时间内访问次数    local count = redis.call('ZCOUNT', key, currentTime - rateRuleTime, currentTime)    -- 3.2 判断是否超过规定次数    if tonumber(count) >= rateRuleCount then        return true    end    -- 3.3 判断元素最大值,设置为最终过期时间    if rateRuleTime > expireTime then        expireTime = rateRuleTime    endend-- 4. 更新缓存过期时间redis.call('PEXPIRE', key, expireTime)-- 5. 删除最大时间限度之前的数据,防止数据过多redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - expireTime)-- 6. redis 中添加当前时间  ( 解决多个线程在同一毫秒添加相同 value 导致 Redis 漏记的问题 )-- 6.1 maxRetries 最大重试次数 retries 重试次数local maxRetries = 5local retries = 0while true do    local result = redis.call('ZADD', key, currentTime, currentTime)    if result == 1 then        -- 6.2 添加成功则跳出循环        break    else        -- 6.3 未添加成功则 value + 1 再次进行尝试        retries = retries + 1        if retries >= maxRetries then            -- 6.4 超过最大尝试次数 采用添加随机数策略            local random_value = math.random(1, 1000)            currentTime = currentTime + random_value        else            currentTime = currentTime + 1        end    endend
return false

5) 编写 AOP 拦截

 
@Autowiredprivate RedisTemplate<String, Object> redisTemplate;
@Autowiredprivate RedisScript<Boolean> limitScript;
/** * 限流 * XXX 对限流要求比较高,可以使用在 Redis中对规则进行存储校验 或者使用中间件 * * @param joinPoint   joinPoint * @param rateLimiter 限流注解 */@Before(value = "@annotation(rateLimiter)")public void boBefore(JoinPoint joinPoint, RateLimiter rateLimiter) {    // 1. 生成 key    String key = getCombineKey(rateLimiter, joinPoint);    try {        // 2. 执行脚本返回是否限流        Boolean flag = redisTemplate.execute(limitScript,                ListUtil.of(key, String.valueOf(System.currentTimeMillis())),                (Object[]) getRules(rateLimiter));        // 3. 判断是否限流        if (Boolean.TRUE.equals(flag)) {            log.error("ip: '{}' 拦截到一个请求 RedisKey: '{}'",                    IpUtil.getIpAddr(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest()),                    key);            throw new ServiceException(rateLimiter.message());        }    } catch (ServiceException e) {        throw e;    } catch (Exception e) {        e.printStackTrace();    }}
/** * 获取规则 * * @param rateLimiter 获取其中规则信息 * @return */private Long[] getRules(RateLimiter rateLimiter) {    int capacity = rateLimiter.rules().length << 1;    // 1. 构建 args    Long[] args = new Long[rateLimiter.preventDuplicate() ? capacity + 2 : capacity];    // 3. 记录数组元素    int index = 0;    // 2. 判断是否需要添加防重复提交到redis进行校验    if (rateLimiter.preventDuplicate()) {        RateRule preventRateRule = rateLimiter.preventDuplicateRule();        args[index++] = preventRateRule.count();        args[index++] = preventRateRule.timeUnit().toMillis(preventRateRule.time());    }    RateRule[] rules = rateLimiter.rules();    for (RateRule rule : rules) {        args[index++] = rule.count();        args[index++] = rule.timeUnit().toMillis(rule.time());    }    return args;}

RateLimiterAspect.java 代码链接:

https://gitee.com/y_project/RuoYi-Vue/blob/

master/ruoyi-framework/src/main/java/com/

ruoyi/framework/aspectj/RateLimiterAspect.java

作者:翼飞

来源:juejin.cn/post/7298635806475386916

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值