Redis如何多规则限流和Redis防重复提交 | 重构篇

1,060 阅读7分钟

一、引言

在高并发的分布式系统中,如何有效地进行请求限流并防止重复提交,一直是开发者们面临的挑战。尤其是当需要同时针对多个维度(如IP、用户ID、用户名等)进行限流时,现有的一些解决方案往往难以满足灵活性和可维护性的要求。本篇文章将对Redis的多规则限流方案进行重构,探讨如何通过更符合单一职责原则的设计,提升代码的可读性与易用性,并为大家提供一套更加高效、实用的限流解决方案。

二、简介

本文详细讲解了如何利用Redis的Zset结构实现多规则限流,并包含在线演示、源码解析及讲解。针对之前版本在实际项目中的局限性(如防重复提交和限流配置不符合单一职责原则、RateLimiter仅能针对单一前缀key进行限流等问题),本文对代码进行了重构,以更简洁、合理的方式解决这些问题,为开发者提供更优的限流策略。 原文链接 : Redis如何多规则限流和防重复提交?

三、回顾代码

我们之前编写过的注解有 RateLimiter、RateRule , 我们对其进行了lua脚本编写,以及AOP切面编程,已达到以下效果 :

    // 通过注解针对 ip   1分钟内访问10次 , 1小时内访问 20 次 , 1天内访问 60 次
    @RateLimiter( 
        rules = {  
                @RateRule(count = 10, time = 1, timeUnit = TimeUnit.SECONDS), 
                @RateRule(count = 20, time = 1, timeUnit = TimeUnit.HOURS),
                @RateRule(count = 60, time = 1, timeUnit = TimeUnit.DAYS) 
            }, 
        preventDuplicate = true 
    )

针对以上点,我们对原有代码进行修改

四、重构限流以及防重复提交

项目最终结构

image.png

1. 重构注解

新增 RateLimiters 包含 RateLimiter[] , 这样可以方便针对 ip、用户名、局部方法,多个种类多规则限流。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RateLimiters {
    /**
     * 多个限流规则
     */
    RateLimiter[] rateLimiters();
}

删除原本的 RateLimiter 与防重复提交相关的属性 preventDuplicate 、preventDuplicateRule , 将防重复提交单独剥离出来。

@Target({ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RateLimiter {
    /**
     * 限流类型 ( 默认全局 )
     */
    LimitTypeEnum limitTypeEnum() default LimitTypeEnum.GLOBAL;

    /**
     * 对应限流规则
     */
    RateRule[] rateRules();

    /**
     * 在限流后,确定是否将请求加入黑名单 ( 用户 和 ip 都将进入黑名单 )
     */
    boolean addToBlacklist() default false;

    /**
     * 如果配置了 RateLimiters 中 message,以 RateLimiters 为准
     */
    ResultCode message() default ResultCode.REQUEST_RATE_LIMIT;
}

RateRule 不进行过多修改,只修改了对应属性名。

@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RateRule {

    /**
     * 限流次数
     */
    long limit() default 10;

    /**
     * 限流时间
     */
    long timeDuration() default 60;

    /**
     * 限流时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

}

2. 针对重构后的 RateLimiters 进行切面编程

大致讲解一下 :

  • 核心逻辑:boBefore 方法

    • 权限检查:首先检查当前用户是否是管理员,如果是,则跳过限流逻辑。
    • 生成限流 Key:根据限流类型(IP、用户ID、用户名等),生成一个唯一的限流 Key。
    • 执行限流脚本:通过 Redis 执行限流脚本,判断请求是否超过限制。
    • 限流结果处理:如果限流成功(达到限制次数),则可能将用户或 IP 加入黑名单,并抛出限流异常。
  • 类的主要字段

    • RedisUtil redisUtil: 处理与 Redis 的常见操作。
    • RedisTemplate<String, Object> redisTemplate: 用于执行 Redis 的操作,比如执行限流脚本。
    • RedisScript<Boolean> limitScript: 存储一个限流的 Lua 脚本。
    • Snowflake snowflake: 用于生成唯一的 ID(通常用来标识某次请求)。
  • 辅助方法

    • generateLimiterKey: 根据不同的限流模式(IP、用户ID、用户名等)生成限流 Key。
    • getRules: 获取限流规则,将每个限流规则的限制次数和时间转换为 Redis 可用的参数。
    • addToRedisBlackList: 将用户或 IP 添加到 Redis 黑名单中,以阻止未来的访问。
@Aspect
@Order(20)
@Component
@Slf4j
@RequiredArgsConstructor
public class RateLimitersAspect {

    private final RedisUtil redisUtil;
    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisScript<Boolean> limitScript;
    private final Snowflake snowflake;

    @Before(value = "@annotation(rateLimiters)")
    public void boBefore(JoinPoint joinPoint, RateLimiters rateLimiters) {
        if (SecurityUtil.isAdmin()) {
            return;
        }
        RateLimiter[] limiters = rateLimiters.rateLimiters();
        for (RateLimiter limiter : limiters) {
            // 1. 生成限流key
            String limitKey = generateLimiterKey(joinPoint, limiter);
            // 2. 执行脚本返回是否限流成功 (传入key,唯一标识,当前时间 )
            Boolean execute = redisTemplate.execute(limitScript, List.of(
                    limitKey, snowflake.nextIdStr(), String.valueOf(System.currentTimeMillis())
            ), getRules(limiter));
            // 3. 判断是否限流
            if (Boolean.TRUE.equals(execute)) {
                // 3.1 是否加入黑名单
                if (limiter.addToBlacklist()) {
                    this.addToRedisBlackList();
                }
                // 3.2 抛出限流错误
                throw new ServiceException(ResultCode.REQUEST_RATE_LIMIT);
            }
        }
    }

    /**
     * 生成限流key
     */
    private String generateLimiterKey(JoinPoint joinPoint, RateLimiter limiter) {
        StringBuilder key = new StringBuilder(RedisKeyConstants.RATE_LIMIT_CACHE_PREFIX);
        switch (limiter.limitTypeEnum()) {
            case IP -> {
                // 1. IP 模式
                HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
                key.append(IpUtil.getIpAddr(request)).append(":");
            }
            case USER_ID -> {
                // 2. userId 模式
                Long userId = SecurityUtil.getUserId();
                if (userId != null) {
                    key.append(userId).append(":");
                } else {
                    log.error("RateLimitersAspect.generateLimiterKey() => 无法获取到Id");
                    throw new ServiceException(ResultCode.AUTH_TOKEN_INVALID);
                }
            }
            case USER_NAME -> {
                // 3. userId 模式
                String username = SecurityUtil.getUsername();
                if (StringUtils.hasText(username)) {
                    key.append(username).append(":");
                } else {
                    log.error("RateLimitersAspect.generateLimiterKey() => 无法获取到用户名");
                    throw new ServiceException(ResultCode.AUTH_TOKEN_INVALID);
                }
            }
            default -> {
                // 4. 默认全局模式
            }
        }
        // 拼接 role:类名:方法名
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Class<?> targetClass = method.getDeclaringClass();
        key.append(targetClass.getSimpleName()).append(":").append(method.getName());
        return key.toString();
    }

    /**
     * 获取规则集合
     */
    private Object[] getRules(RateLimiter limiter) {
        RateRule[] rateRules = limiter.rateRules();
        // 1. 创建返回对象 ( i * 2 === i << 1)
        Object[] result = new Object[rateRules.length * 2];
        // 2. 遍历规则返回
        for (int i = 0; i < rateRules.length; i++) {
            result[i * 2] = rateRules[i].limit();
            result[i * 2 + 1] = rateRules[i].timeUnit().toMillis(rateRules[i].timeDuration());
        }
        // 3. 返回结果
        return result;
    }

    /**
     * 添加到 redis 黑名单操作
     */
    private void addToRedisBlackList() {
        // http 请求不会为空
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        String ipAddr = IpUtil.getIpAddr(request);
        Long userId = SecurityUtil.getUserId();
        // 3.1.1 用户Id 和 ip 不为空则存入缓存
        if (userId != null) {
            redisUtil.addToCacheSet(RedisKeyConstants.BLACKLIST_USER_ID_CACHE_PREFIX, userId);
        }
        redisUtil.addToCacheSet(RedisKeyConstants.BLACKLIST_IP_CACHE_PREFIX, ipAddr);
    }
}

实际使用

@RateLimiters(rateLimiters = {
        @RateLimiter(limitTypeEnum = LimitTypeEnum.USER_ID,
                rateRules = {@RateRule(
                        limit = 60,
                        timeDuration = 1,
                        timeUnit = TimeUnit.DAYS)})
})

3. 编写工具类调用多规则限流

逻辑与以上相同 , 改工具类是为了方便在项目中调用,而不是使用注解的方式调用。

@Component
@RequiredArgsConstructor
public class RateLimiterUtil {

    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisScript<Boolean> limitScript;
    private final Snowflake snowflake;

    /**
     * @param rateLimitRules rateLimitRules 为单个
     * @return 是否限流
     */
    public boolean rateLimiterSingle(RateLimitRule... rateLimitRules) {
        for (RateLimitRule rateLimitRule : rateLimitRules) {
            // Lua 脚本中的 keys
            List<String> keys = List.of(
                    rateLimitRule.getRedisKey(),
                    snowflake.nextIdStr(),
                    String.valueOf(System.currentTimeMillis())
            );
            // Lua 脚本中的 args
            List<Object> args = List.of(
                    rateLimitRule.getLimit(),
                    rateLimitRule.getTimeUnit().toMillis(rateLimitRule.getTimeDuration())
            );
            // 执行脚本
            Boolean isLimited = redisTemplate.execute(limitScript, keys, args);
            if (Boolean.TRUE.equals(isLimited)) {
                return true;
            }
        }
        return false;
    }

    /**
     * @param rateLimitRules rateLimitRules 是一组
     * @return 是否限流
     */
    public boolean rateLimiterGroup(String redisKey, RateLimitRule... rateLimitRules) {
        // Lua 脚本中的 keys
        List<String> keys = List.of(
                redisKey,
                snowflake.nextIdStr(),
                String.valueOf(System.currentTimeMillis())
        );
        // Lua 脚本中的 args
        Object[] args = new Object[rateLimitRules.length * 2];
        for (int i = 0; i < rateLimitRules.length; i++) {
            args[i * 2] = rateLimitRules[i].getLimit();
            args[i * 2 + 1] = rateLimitRules[i].getTimeUnit().toMillis(rateLimitRules[i].getTimeDuration());
        }
        // 执行脚本
        return Boolean.TRUE.equals(redisTemplate.execute(limitScript, keys, args));
    }

    /**
     * 规则类:定义限流规则
     */
    @Data
    @Builder
    public static class RateLimitRule {
        /**
         * Redis key
         */
        private String redisKey;
        /**
         * 访问限制次数
         */
        private Integer limit;
        /**
         * 限制时间
         */
        private long timeDuration;
        /**
         * 时间单位
         */
        private TimeUnit timeUnit;
    }
}

4. 编写分离出来的 Redis 防重复提交

4.1 Redis 防重复提交注解

编写 PreventDuplicateSubmit 注解 , PreventDuplicateSubmit 中的属性在注释中有具体描述

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface PreventDuplicateSubmit {

    DuplicateTypeEnum type() default DuplicateTypeEnum.IP;

    /**
     * 是否为全局
     * 默认 : ip:classSimpleName:methodName
     * 全局 : ip
     */
    boolean global() default false;


    /**
     * 防重提交锁过期时间(秒)
     * <p>
     * 默认1秒内不允许重复提交
     */
    int expire() default 1;

}

4.2 编写Redis防重复提交的切面方法

主要步骤

  • 生成锁的 Key:首先,通过 generateResubmitLockKey 方法生成一个唯一的 Redis 锁 Key,这个 Key 用于标识某个请求。

  • 获取分布式锁:使用 redissonClient.getLock(prefixKey) 获取一个基于 Redis 的分布式锁对象 RLock

  • 尝试获取锁

    • lock.tryLock(0, preventDuplicateSubmit.expire(), TimeUnit.SECONDS) 尝试立即获取锁,如果获取失败,锁的有效期为 preventDuplicateSubmit.expire() 秒。
    • 如果获取锁失败,说明有相同的请求正在执行,进入下一步。
  • 处理重复提交

    • 如果提交类型是 DuplicateTypeEnum.ARGS,抛出 REQUEST_OTHER_OPERATION 错误,提示用户该操作正在处理中。
    • 否则,抛出 REQUEST_MORE_ERROR 错误,提示用户重复请求。
  • 执行目标方法:如果获取锁成功,使用 joinPoint.proceed() 继续执行目标方法(即实际业务逻辑)。

具体AOP代码

@Aspect
@Order(0)
@Component
@Slf4j
@RequiredArgsConstructor
public class PreventDuplicateSubmitAspect {

    private final RedissonClient redissonClient;

    @Around(value = "@annotation(preventDuplicateSubmit)")
    public Object preventDuplicateSubmit(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
        String prefixKey = generateResubmitLockKey(joinPoint, preventDuplicateSubmit);
        RLock lock = redissonClient.getLock(prefixKey);
        if (!lock.tryLock(0, preventDuplicateSubmit.expire(), TimeUnit.SECONDS)) {
            if (preventDuplicateSubmit.type().equals(DuplicateTypeEnum.ARGS)) {
                throw new ServiceException(ResultCode.REQUEST_OTHER_OPERATION);
            } else {
                throw new ServiceException(ResultCode.REQUEST_MORE_ERROR);
            }
        }
        return joinPoint.proceed();
    }

    /**
     * 生成防重复提交的键
     *
     * @param joinPoint              切入点,表示当前拦截的方法
     * @param preventDuplicateSubmit 防重复提交注解
     * @return 防重复提交的 Redis 键
     */
    private String generateResubmitLockKey(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) {
        String prefix = "";
        Object[] args = joinPoint.getArgs();
        // 获取防重复提交的类型
        DuplicateTypeEnum type = preventDuplicateSubmit.type();

        // 根据类型生成键的前缀
        switch (type) {
            case USER_ID -> {
                // 用户ID模式
                Long userId = SecurityUtil.getUserId();
                if (userId == null) {
                    log.error("PreventDuplicateSubmitAspect.generateResubmitLockKey -> USER_ID 未解析到用户Id");
                } else {
                    prefix = userId.toString();
                }
            }
            case USER_NAME -> {
                // 用户名模式
                String username = SecurityUtil.getUsername();
                if (username == null) {
                    log.error("PreventDuplicateSubmitAspect.generateResubmitLockKey -> USER_NAME 未解析出用户名");
                } else {
                    prefix = username;
                }
            }
            case ARGS ->
                // 参数模式 : TODO 后续修改为 Spring 表达式语言(SP EL)
                // 注意:使用参数生成键时,应防止生成过大的键
                    prefix = Arrays.deepToString(args);
            default -> {
                // 默认模式为IP模式
                HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
                prefix = IpUtil.getIpAddr(request);
            }
        }

        // 如果不是全局限制,进一步添加类名和方法名作为前缀
        if (!preventDuplicateSubmit.global()) {
            // 获取目标方法所属的类的名称
            String className = joinPoint.getTarget().getClass().getSimpleName();
            // 获取方法的名称
            String methodName = joinPoint.getSignature().getName();
            // 组合前缀
            prefix = prefix + ":" + className + ":" + methodName;
        }

        // 返回完整的 Redis 键
        return RedisKeyConstants.PREVENT_DUPLICATE_SUBMIT_PREFIX + prefix;
    }


}

五、重构完成,让我们在项目中使用一下

当我们快速点击的时候,弹出防重复提交操作。

image.png

六、源码地址,在线演示