限流 SDK 的設計與實現

Ba11ooner發表於2024-03-18

需求分析

請設計一套 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;
}
切面邏輯

巧用 Redis 的過期策略,可以方便地實現滑動視窗的效果。

每次訪問介面後,介面方法執行前:

  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 使用教程

《最佳化介面設計的思路》系列:第七篇—介面限流策略

相關文章