限流 SDK 的设计与实现

需求分析

请设计一套 SDK,用于实现接口限流,针对某个 IP 对于特定接口方法的单位时间访问次数进行控制。

  • 限流算法:滑动窗口
  • 可配置项
    • 时间窗口
    • 限流次数

实现思路

算法知识补充

通过滑动窗口实现限流

思想源于计数器(单位时间内数量超过阈值时拒绝请求),但是引入了滑动窗口,相较于固定窗口,更新过程更为平滑,不会出现临界问题(即在更新时刻前后快速涌入流量,不能防止短期流量剧增,却又导致长期流量受控)。

具体实现

SDK 结构
  • annotation:自定义注解
    • RequestLimit:标识限流接口,支持属性配置(时间窗口、限流次数)
  • aop
    • RequestLimitAspect:限流实现切面,统一实现限流处理
  • common:自定义的 ResponseEntity 的等效实现
    • BaseResponse:自定义统一响应对象
    • ErrorCode:自定义错误码
    • ResultUtils:自定义返回工具
  • config
    • RedisConfig:配置 Redis
  • exception:统一异常处理
    • BusinessException:业务异常,与系统异常做区分
    • GlobalExceptionHandler:全局异常处理器

不加粗部分为基础设施,详情参见 项目学习 鱼皮用户中心

本文详细介绍加粗部分的实现。

Maven 依赖
<!--提供 AOP 支持-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>1.9.4</version>
</dependency>

<!--提供日志支持-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

<!--提供 @Data-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<!--提供 Redis 支持-->
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.6.1</version>
</dependency>
代码实现
自定义注解
  • 声明标识的位置 → 修饰方法

  • 声明注解存在的时间 → 运行时仍保留注解

  • 声明注解属性

    • 窗口大小(时间长度)
    • 访问上限
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
    // 限制时间 单位:秒(默认值:一分钟)
    long period() default 60;

    // 允许请求的次数(默认值:5次)
    long count() default 5;
}
切面逻辑

每次访问接口后,接口方法执行前:

  1. 记录请求:使用 Redis 中的 ZSet (有序集合)进行记录。

    1. 获取申请请求的 IP 和 URI。

    2. 获取当前时间戳。

    3. 利用 Zet 的添加功能,记录请求。

      1. 设置 Key:将字符串 req_limit_IPURI 的拼接作为 Key。
      2. 设置 Value:将时间戳作为 Value。
      3. 设置 Score:将时间戳作为 Score。
    4. 设置过期时间:安全机制,避免长间隔请求持续占用内存。
      因为窗口控制仅在请求调用时进行,如果长期不调用接口,又不设置过期时间,会导致不必要的内存消耗。

  2. 控制窗口:删除滑动窗口以外的值。

    1. 从注解中获取窗口大小(即时间段长度)
    2. 利用 ZSet 的删除功能,删除滑动窗口以外的值。
  3. 判断当前访问次数是否已经大于限制次数。

    1. 利用 ZSet 的统计功能统计 Key 出现次数,即窗口内 IP 访问 URI 的次数。
    2. 从注解中获取访问次数上限。
    3. 比较访问次数和次数上限,若访问次数超过次数上限,则抛出异常。
@Aspect
@Component
public class RequestLimitAspect {
    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Resource
    RedisTemplate<String, Long> redisTemplate;

    // 定义切点
    @Pointcut("@annotation(requestLimit)")
    public void controllerAspect(RequestLimit requestLimit) {
    }

    // 织入逻辑
    // 在指定切点周围添加业务逻辑
    @Around("controllerAspect(requestLimit)")
    public Object doAround(ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable {
        // 获取注解中记录的属性
        long period = requestLimit.period(); // 窗口大小
        long limitCount = requestLimit.count(); // 限制次数

        // 引入 ZSet
        ZSetOperations<String, Long> zSetOperations = redisTemplate.opsForZSet();

        // region 记录请求
        // 获取请求:根据参数类型获取 HttpServletRequest
        Object[] args = joinPoint.getArgs();
        HttpServletRequest httpServletRequest = null;
        for (Object arg : args) {
            if (arg instanceof HttpServletRequest) {
                httpServletRequest = (HttpServletRequest) arg;
                break;
            }
        }

        // 从 HttpServletRequest 中获取 IP 和 URI
        // 例:访问 https://www.example.com/products?id=123
        // 假设 www.example.com 对应的 IP 地址为:192.168.1.1
        // getRemoteAddr() → 192.168.1.1
        // getRequestURI → /products?id=123
        String ip = "";
        String uri = "";
        if (httpServletRequest != null) {
            ip = httpServletRequest.getRemoteAddr();
            uri = httpServletRequest.getRequestURI();
            System.out.println(ip);
            System.out.println(uri);
        } else {
            // 没有找到HttpServletRequest参数
            throw new BusinessException(PARAMS_ERROR, "没有找到HttpServletRequest参数");
        }

        // 利用 URI 和 IP 拼接 Key
        String key = "req_limit_".concat(uri).concat(ip);

        // 获取当前时间戳,作为 Value 和 Score
        long currentMs = System.currentTimeMillis();

        // add 参数说明:
        // key:键
        // value:值
        // score :排序权重
        zSetOperations.add(key, currentMs, currentMs);
        // 设置过期时间:安全机制,避免长间隔请求持续占用内存。
        // 即确保内存中的滑动窗口数据不会一直累积,避免内存占用过多。
        // 因为窗口控制仅在请求调用时进行,如果长期不调用接口,又不设置过期时间,会导致不必要的内存消耗。
        redisTemplate.expire(key, period, TimeUnit.SECONDS);
        //endregion

        // region 控制窗口
        // 删除滑动窗口以外的值,根据当前时间和注解中设置的 period 确定窗口大小
        // removeRangeByScore 参数说明:
        // key:表示有序集合的键名。
        // minScore:表示删除范围的最小分数。
        // maxScore:表示删除范围的最大分数。
        zSetOperations.removeRangeByScore(key, 0, currentMs - period * 1000);
        //endregion

        // region 判断当前访问次数是否已经大于限制次数
        // 统计当前访问次数
        // zCard 功能说明:获取有序集合中成员的数量。
        // zCard 参数说明:key,表示有序集合的键名。
        Long count = zSetOperations.zCard(key);
        if (count > limitCount) {
            logger.error("接口拦截:{} 请求超过限制频率【{}次/{}s】,IP为{}", uri, limitCount, period, ip);
            throw new BusinessException(FORBIDDEN_ERROR, "请求超过限制频率");
        }
        //endregion

        // 执行用户请求
        return joinPoint.proceed();
    }
}
Redis 配置
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, String> redisTemplate(@Qualifier("jedisConnectionFactory") JedisConnectionFactory jedisConnectionFactory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(jedisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
        return redisTemplate;
    }

    @Bean
    @Primary
    public JedisConnectionFactory jedisConnectionFactory() {
        JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
        jedisConnectionFactory.setHostName("localhost");
        jedisConnectionFactory.setPort(6379);
        return jedisConnectionFactory;
    }
}

压力测试

Postman vs JMeter

Postman 的 runner 本质上是串行执行多次请求

Jmeter 则是并行执行多个请求

项目源码

基于滑动窗口算法的限流注解实现

参考文档

SpringBoot限制接口访问频率 - 这些错误千万不能犯

JMeter 使用教程

《优化接口设计的思路》系列:第七篇—接口限流策略

posted @ 2024-03-18 17:25  Ba11ooner  阅读(11)  评论(0编辑  收藏  举报