Spring Boot、AOP、Luaを使用した分散型レートリミットの実践的実装

一、レートリミットとは?なぜレートリミットが必要なのか?

皆さんは北京の地下鉄に乗ったことがありますか?入場時に長蛇の列ができるのはなぜでしょうか?答えは「レートリミット」です!地下鉄の輸送能力には限界があり、一度に多くの乗客が乗り込むとプラットフォームが混雑し、列車が過剰な乗客を乗せてしまうと安全上の問題が発生します。同様に、私たちのプログラムも処理能力に限界があり、リクエストが処理能力を超えるとシステムがクラッシュします。最悪のクラッシュを避けるため、乗客の入場時間を遅らせる必要があるのです。

レートリミットはシステムの高可用性を保証する重要な手段です!

インターネット企業のトラフィックは非常に大きいため、システムリリース時にトラフィックピークの評価を行います。特に秒単位のセールスプロモーションのようなイベントでは、システムが巨大なトラフィックに圧迫されないように、システムトラフィックが一定の閾値に達した時に一部のトラフィックを拒否します。

レートリミットはユーザーが短時間(ミリ秒単位)のシステム不可用を経験することを意味します。一般的にシステム処理能力を測る指標は毎秒のQPS(Query Per Second)またはTPS(Transaction Per Second)です。システムの毎秒トラフィック閾値が1000の場合、理論上1秒間に1001番目のリクエストが到着すると、このリクエストはレートリミットされます。

二、レートリミットの方案

1、カウンター

Java内部でもアトミックカウンターAtomicIntegerSemaphore信号量を使用して簡単なレートリミットを実装できます。

// 限流の個数
private int maxCount = 10;
// 指定の時間内
private long interval = 60;
// アトミックカウンター
private AtomicInteger atomicCounter = new AtomicInteger(0);
// 開始時間
private long startTime = System.currentTimeMillis();

public boolean limit(int maxCount, int interval) {
    atomicCounter.incrementAndGet();
    if (atomicCounter.get() == 1) {
        startTime = System.currentTimeMillis();
        atomicCounter.incrementAndGet();
        return true;
    }
    // 間隔時間を超えた場合、直接カウントをリセット
    if (System.currentTimeMillis() - startTime > interval * 1000) {
        startTime = System.currentTimeMillis();
        atomicCounter.set(1);
        return true;
    }
    // 間隔時間内で、限流の個数を超えていないかチェック
    if (atomicCounter.get() > maxCount) {
        return false;
    }
    return true;
}
2、漏斗アルゴリズム

漏斗アルゴリズムの考え方は非常に単純です。水を「リクエスト」、漏斗を「システム処理能力の限界」と見なします。水はまず漏斗に入り、漏斗内の水は一定の速度で流出します。流出速度が流入速度より遅い場合、漏斗の容量が限られているため、後続の水は直接溢れ出て(リクエストを拒否し)、これによりレートリミットを実現します。

3、トークンバケットアルゴリズム

トークンバケットアルゴリズムの原理も比較的単純です。病院の受付で診察するのと似ています。番号を取得した後で診察が可能になります。

システムはトークン(token)バケットを維持し、一定の速度でバケットにトークン(token)を投入します。この時、リクエストが処理されたい場合、まずバケットからトークン(token)を取得する必要があります。バケットにトークン(token)がなくなると、リクエストはサービスを拒否されます。トークンバケットアルゴリズムは、バケットの容量、トークンの発行速度を制御することで、リクエストの制限を達成します。

4、Redis + Lua

多くの開発者がLuaが何か知らないかもしれませんか?個人的な理解では、LuaスクリプトはMySQLデータベースのストアドプロシージャと比較的似ており、一連のコマンドを実行し、すべてのコマンドの実行は成功または失敗のいずれかであり、これによりアトミック性を達成します。Luaスクリプトを、ビジネスロジックを持つコードブロックと理解することもできます。

Lua自体はプログラミング言語であり、redis公式は直接レートリミット関連のAPIを提供していませんが、Luaスクリプトの機能をサポートしており、複雑なトークンバケットまたは漏斗アルゴリズムを実装できます。これは分散システムでレートリミットを実現する主要な方法の一つでもあります。

Redisトランザクションと比較したLuaスクリプトの利点:

  • ネットワークオーバーヘッドの削減:Luaスクリプトを使用すると、Redisに複数回リクエストを送信する必要がなく、一度の実行で済み、ネットワーク転送を削減
  • アトミック操作:RedisはLuaスクリプト全体を1つの命令として実行し、アトミックであり、並行処理を心配する必要なし
  • 再利用:Luaスクリプトは一度実行されると、Redisに永続的に保存され、他のクライアントが再利用可能

Luaスクリプトの大まかなロジックは以下の通りです:

-- スクリプト呼び出し時に渡された最初のkey値を取得(限流用のkeyとして使用)
local key = KEYS[1]
-- スクリプト呼び出し時に渡された最初のパラメータ値を取得(限流サイズ)
local limit = tonumber(ARGV[1])

-- 現在のトラフィックサイズを取得
local currentLimit = tonumber(redis.call('get', key) or "0")

-- 限流を超えているか
if currentLimit + 1 > limit then
    -- 戻り値(拒否)
    return 0
else
    -- 超えていない場合、value + 1
    redis.call("INCRBY", key, 1)
    -- 有効期限を設定
    redis.call("EXPIRE", key, 2)
    -- 戻り値(許可)
    return 1
end
  • KEYS[1]を使用して渡されたkeyパラメータを取得
  • ARGV[1]を使用して渡されたlimitパラメータを取得
  • redis.callメソッドを使用して、キャッシュからkeyに関連する値をgetし、nullの場合は0を返す
  • 次に、キャッシュに記録された数値が制限サイズを超えるかどうかを判断し、超えている場合はレートリミットされ、0を返す
  • 超えていない場合は、該当keyのキャッシュ値を+1し、有効期限を1秒後に設定し、キャッシュ値+1を返す

この方法が本記事で推奨する方案であり、具体的な実装は後で詳しく説明します。

5、ゲートウェイ層でのレートリミット

レートリミットは通常ゲートウェイ層で実装されます。例えばNginxOpenrestykongzuulSpring Cloud Gatewayなどです。spring cloud - gatewayゲートウェイのレートリミットの実装原理は、Redis + Luaを基盤としており、組み込みのLuaレートリミットスクリプトを通じて実現されています。

三、Redis + Lua レートリミットの実装

以下では、カスタムアノテーションAOPRedis + Luaを使用してレートリミットを実装します。手順は比較的詳細で、初心者が迅速に始められるように少し詳しく説明します。経験豊富な開発者はご容赦ください。

1、環境準備

springbootプロジェクトの作成アドレス:https://start.spring.io、非常に便利で実用的なツールです。

2、依存関係の追加

pomファイルに以下の依存関係を追加します。重要なのはspring-boot-starter-data-redisspring-boot-starter-aopです。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>21.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
</dependencies>
3、application.propertiesの設定

application.propertiesファイルで事前に構築したredisサービスのアドレスとポートを設定します。

spring.redis.host=127.0.0.1
spring.redis.port=6379
4、RedisTemplateインスタンスの設定
@Configuration
public class RedisLimiterHelper {

    @Bean
    public RedisTemplate<String, Serializable> limitRedisTemplate(LettuceConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Serializable> template = new RedisTemplate<>();
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

レートリミットタイプの列挙クラス

/**
 * @author fu
 * @description レートリミットタイプ
 * @date 2020/4/8 13:47
 */
public enum RateLimitType {

    /**
     * カスタムkey
     */
    CUSTOMER,

    /**
     * リクエスト者のIP
     */
    IP;
}
5、カスタムアノテーション

私たちは@RateLimitアノテーションをカスタマイズします。アノテーションタイプはElementType.METHODで、メソッドに作用します。

periodはリクエスト制限時間範囲を表し、countperiod時間範囲内で許可されるリクエスト数を表します。limitTypeはレートリミットのタイプを表し、リクエストのIPまたはカスタムkeyに基づいています。limitType属性が渡されない場合は、デフォルトでメソッド名がkeyとして使用されます。

/**
 * @author fu
 * @description カスタムレートリミットアノテーション
 * @date 2020/4/8 13:15
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface RateLimit {

    /**
     * 名前
     */
    String name() default "";

    /**
     * key
     */
    String key() default "";

    /**
     * Keyのプレフィックス
     */
    String prefix() default "";

    /**
     * 指定された時間範囲 単位(秒)
     */
    int period();

    /**
     * 一定時間内の最大アクセス回数
     */
    int count();

    /**
     * レートリミットのタイプ(ユーザー定義keyまたはリクエストIP)
     */
    RateLimitType limitType() default RateLimitType.CUSTOMER;
}
6、アスペクトコードの実装
/**
 * @author fu
 * @description レートリミットアスペクトの実装
 * @date 2020/4/8 13:04
 */
@Aspect
@Configuration
public class RateLimitInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(RateLimitInterceptor.class);

    private static final String UNKNOWN = "unknown";

    private final RedisTemplate<String, Serializable> rateLimitRedisTemplate;

    @Autowired
    public RateLimitInterceptor(RedisTemplate<String, Serializable> rateLimitRedisTemplate) {
        this.rateLimitRedisTemplate = rateLimitRedisTemplate;
    }

    /**
     * @param pjp
     * @author fu
     * @description アスペクト
     * @date 2020/4/8 13:04
     */
    @Around("execution(public * *(..)) && @annotation(com.xiaofu.limit.api.RateLimit)")
    public Object interceptor(ProceedingJoinPoint pjp) {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        RateLimit rateLimitAnnotation = method.getAnnotation(RateLimit.class);
        RateLimitType limitType = rateLimitAnnotation.limitType();
        String name = rateLimitAnnotation.name();
        String key;
        int limitPeriod = rateLimitAnnotation.period();
        int limitCount = rateLimitAnnotation.count();

        /**
         * レートリミットタイプに基づいて異なるkeyを取得し、渡されない場合はメソッド名をkeyとして使用
         */
        switch (limitType) {
            case IP:
                key = getIpAddress();
                break;
            case CUSTOMER:
                key = rateLimitAnnotation.key();
                break;
            default:
                key = StringUtils.upperCase(method.getName());
        }

        ImmutableList<String> keys = ImmutableList.of(StringUtils.join(rateLimitAnnotation.prefix(), key));
        try {
            String luaScript = buildLuaScript();
            RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
            Number count = rateLimitRedisTemplate.execute(redisScript, keys, limitCount, limitPeriod);
            logger.info("Access try count is {} for name={} and key = {}", count, name, key);
            if (count != null && count.intValue() <= limitCount) {
                return pjp.proceed();
            } else {
                throw new RuntimeException("You have been dragged into the blacklist");
            }
        } catch (Throwable e) {
            if (e instanceof RuntimeException) {
                throw new RuntimeException(e.getLocalizedMessage());
            }
            throw new RuntimeException("server exception");
        }
    }

    /**
     * @author fu
     * @description Redis Luaレートリミットスクリプトの作成
     * @date 2020/4/8 13:24
     */
    public String buildLuaScript() {
        StringBuilder lua = new StringBuilder();
        lua.append("local c");
        lua.append("\nc = redis.call('get',KEYS[1])");
        // 最大値を超えない場合、直接戻る
        lua.append("\nif c and tonumber(c) > tonumber(ARGV[1]) then");
        lua.append("\nreturn c;");
        lua.append("\nend");
        // カウンターのインクリメントを実行
        lua.append("\nc = redis.call('incr',KEYS[1])");
        lua.append("\nif tonumber(c) == 1 then");
        // 最初の呼び出しからレートリミットを開始し、対応するkeyの有効期限を設定
        lua.append("\nredis.call('expire',KEYS[1],ARGV[2])");
        lua.append("\nend");
        lua.append("\nreturn c;");
        return lua.toString();
    }


    /**
     * @author fu
     * @description IPアドレスの取得
     * @date 2020/4/8 13:24
     */
    public String getIpAddress() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}
7、コントローラ層の実装

@RateLimitアノテーションをレートリミットが必要なインターフェースメソッドに適用します。以下ではメソッドに@RateLimitアノテーションを設定し、10秒間に3つのリクエストしか許可しないようにします。ここでは分かりやすくするためにAtomicIntegerカウンターを使用します。

/**
 * @Author: fu
 * @Description:
 */
@RestController
public class RateLimiterController {

    private static final AtomicInteger ATOMIC_COUNTER_1 = new AtomicInteger();
    private static final AtomicInteger ATOMIC_COUNTER_2 = new AtomicInteger();
    private static final AtomicInteger ATOMIC_COUNTER_3 = new AtomicInteger();

    /**
     * @author fu
     * @description
     * @date 2020/4/8 13:42
     */
    @RateLimit(key = "rateLimitTest", period = 10, count = 3)
    @GetMapping("/rateLimitTest1")
    public int testRateLimiter1() {
        return ATOMIC_COUNTER_1.incrementAndGet();
    }

    /**
     * @author fu
     * @description
     * @date 2020/4/8 13:42
     */
    @RateLimit(key = "customer_rate_limit_test", period = 10, count = 3, limitType = RateLimitType.CUSTOMER)
    @GetMapping("/rateLimitTest2")
    public int testRateLimiter2() {
        return ATOMIC_COUNTER_2.incrementAndGet();
    }

    /**
     * @author fu
     * @description 
     * @date 2020/4/8 13:42
     */
    @RateLimit(key = "ip_rate_limit_test", period = 10, count = 3, limitType = RateLimitType.IP)
    @GetMapping("/rateLimitTest3")
    public int testRateLimiter3() {
        return ATOMIC_COUNTER_3.incrementAndGet();
    }
}
8、テスト

テスト期待結果:連続3回のリクエストは成功し、4回目のリクエストは拒否されます。次に期待どおりの効果かどうかを確認しましょう。リクエストアドレス:http://127.0.0.1:8080/rateLimitTest1、Postmanでテストします。Postmanがない場合はブラウザにURLを直接貼り付けても同じです。

4回目のリクエスト時、アプリケーションは直接リクエストを拒否したことがわかります。これにより、Spring Boot + AOP + Luaレートリミット方案の構築が成功したことを示しています。

まとめ

以上のSpring Boot + AOP + Luaレートリミット実装は比較的簡単であり、皆さんがレートリミットとは何か?簡単なレートリミット機能をどのように作成するか?面接でこの概念を知っていることを目的としています。上記ではいくつかのレートリミット方案が述べられていますが、どの方案を選択するかは具体的なビジネスシナリオに基づいて判断する必要があり、使用するために使用するべきではありません。

タグ: Spring Boot AOP redis lua レートリミット

7月5日 22:53 投稿