SpringBoot プロジェクトは Redis を使用してユーザー IP のインターフェイス フローを制限します

1. アイデア

インターフェイス電流制限を使用する主な目的は、システムの安定性を向上させ、インターフェイスが悪意のある攻撃 (短期間に大量のリクエスト) を受けるのを防ぐことです。

たとえば、インターフェイスが 1 分間に 1000 回以下のリクエストを行う必要がある場合、コードはどのように設計されるべきでしょうか?

ここでは 2 つのアイデアを紹介します。コードを確認したい場合は、次のコード部分を直接参照してください。

1.1 固定期間(古い考え方)

1.1.1 アイデアの説明

このソリューションのアイデアは、Redis を使用して、一定期間内にユーザー IP がインターフェイスにアクセスした回数を記録するというものです。

  • Redis キー: ユーザー IP + インターフェイス メソッド名

  • Redis 値: 現在のインターフェイスの訪問数。

近い将来、ユーザーが初めてインターフェイスにアクセスすると、ユーザー IP とインターフェイス メソッド名を含むキーが Redis に設定され、値は 1 (現在のインターフェイスへの最初の訪問を示す) に初期化されます。同時に、キーの有効期限を設定します (例: 60 秒)。

Spring Boot + MyBatis Plus + Vue 3.2 + Vite + Element Plus をベースとしたフロントエンドとバックエンドに分離されたブログ。記事、カテゴリ、タグ管理、ダッシュボードなどの機能をサポートするバックグラウンド管理システムが含まれています。

  • GitHub アドレス: https://github.com/weiwosuoai/WeBlog

  • Gitee アドレス: https://gitee.com/AllenJiang/WeBlog

 
 

その後、キーの有効期限が切れない限り、ユーザーがインターフェイスにアクセスするたびに、値は 1 つずつ増加します。

ユーザーが毎回インターフェースにアクセスする前に、現在のインターフェースのアクセス回数が Redis から取得され、アクセス回数が指定した回数 (たとえば、1000 回を超える) を超えると、インターフェースのアクセス失敗インジケーターが表示されます。ユーザーに返されました。

 

1.1.2 思考の欠陥

この解決策の欠点は、電流制限時間が固定されていることです。

たとえば、インターフェイスが 1 分間に 1000 回以下のリクエストを行う必要がある場合は、次のプロセスを観察してください。

 

写真

写真

00:59 から 01: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 データ構造を選択するか

1 つ目は、どの Redis データ構造を使用するかを決定することです。ユーザーが訪問するたびに、キーを使用してユーザーの訪問時点を記録する必要があり、これらの時点を範囲チェックに使用する必要があります。

2) zSet データ構造を選択する理由

範囲チェックを実現できるようにするには、Redis で zSet 順序付きコレクションを使用することを検討してください。

zSet 要素を追加するコマンドは次のとおりです。

ZADD [key] [score] [member]

これには主要な属性スコアがあり、これを通じて現在のメンバーの優先順位を記録できます。

したがって、スコアをユーザー アクセス インターフェイスのタイムスタンプとして設定して、スコアによる範囲チェックを容易にすることができます。キーはユーザーの IP とインターフェースのメソッド名を記録します。メンバーの設定に関しては影響はありません。メンバーはユーザーがインターフェースにアクセスした時点を記録します。したがって、メンバーをタイムスタンプとして設定することもできます。

3) zSet はどのように範囲チェックを実行しますか (直前の数秒間のアクセス数をチェックします)

このアイデアは、特定の時間間隔の前にすべてのメンバーを削除し、残りのメンバーがその時間間隔内の訪問の合計数になるようにすることです。次に、現在のキーのメンバーの数を数えます。

① 一定時間前にすべてのメンバーを削除します。

zSet には、スコア範囲が次の範囲にあるメンバーを削除する次のコマンドがあります[min~max]

Zremrangebyscore [key] [min] [max]

現在の制限時間が 5 秒に設定されており、現在のユーザーがインターフェイスにアクセスしたときに現在のシステム タイムスタンプが取得されると仮定するとcurrentTimeMill、削除されるスコア範囲は次のように設定できます。

min = 0
max = currentTimeMill - 5 * 1000

これは、5 秒前にすべてのメンバーを削除し、最初の 5 秒以内のキーのみを残すことと同じです。

② 特定のキーの既存メンバーの数をカウントします。

zSet には、キーのメンバーの総数をカウントする次のコマンドがあります。

 ZCARD [key]

カウントされるキーのメンバーの合計数は、現在のインターフェイスがアクセスされた回数です。この数値が現在の制限回数よりも大きい場合は、現在のアクセスを制限する必要があることを意味します。

2. コードの実装

主にアノテーション+AOPという形で実現されます。

Spring Boot + MyBatis Plus + Vue 3.2 + Vite + Element Plus をベースとしたフロントエンドとバックエンドに分離されたブログ。記事、カテゴリ、タグ管理、ダッシュボードなどの機能をサポートするバックグラウンド管理システムが含まれています。

  • GitHub アドレス: https://github.com/weiwosuoai/WeBlog

  • Gitee アドレス: https://gitee.com/AllenJiang/WeBlog

 
 

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();
        zSetOperations.add(combineKey, currentMs, currentMs);
        // 这一步是为了防止member一直存在于内存中
        redisTemplate.expire(combineKey, time, TimeUnit.SECONDS);
        // 移除{time}秒之前的访问记录(滑动窗口思想)
        zSetOperations.removeRangeByScore(combineKey, 0, currentMs - time * 1000);
        
        // 获得当前窗口内的访问记录数
        Long currCount = zSetOperations.zCard(combineKey);
        // 限流判断
        if (currCount > count) {
            log.error("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, currCount, combineKey);
            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();
    }
}

おすすめ

転載: blog.csdn.net/qq_21137441/article/details/132144505