Redisによる複数ルールレートリミットと重複送信防止の実装方法

Redisを利用したレートリミット実装は一般的ですが、多くの場合は単一ルール(例:1分あたり1回アクセス、60分あたり10回アクセスなど)に限定されています。しかし、実際のシステムでは一つのインターフェースに対して複数のルールを同時に適用する必要がある場合があります。特に分散システム環境では、この課題をどのように解決すればよいでしょうか。ここでは、Redisを使用した分散環境における複数ルールのレートリミット実装方法について解説します。

  • 1分間に1回、1時間間に10回など、複数のルールに基づく認証コード送信制限
  • 短時間に大量のリクエストが送られる悪意のある攻撃からの保護
  • インターフェースへのアクセス回数を指定時間内に制限する方法

一:String構造を使用した固定時間枠におけるユーザーIPごとのインターフェースアクセス回数記録

  • Redisキー = プレフィックス : クラス名 : メソッド名
  • Redis値 = アクセス回数

リクエストインターセプト処理:

  1. 初回アクセス時に[Redisキー][Redis値=1][指定された有効期限]を設定
  2. Redis値を取得し規定回数を超えているか確認、超えていればブロック、超えていなければRedisキーの値を+1

ルール:毎分1000回アクセス許可

  • 現在のRedisキー => Redis値が999であると仮定
  • 大量のリクエストが同時に最初のステップ(Redisアクセス回数取得)を実行すると、全てのスレッドが値999を取得し、制限回数内と判断してブロックされず、実際のアクセス数が1000回を超えてしまう
  • 解決策:メソッド実行の原子性を保証する(ロック、Luaスクリプトの使用)

境界値でのアクセスを考慮する必要があります。

二:Zsetを使用したストレージによる境界値アクセス問題の解決

三:複数ルールレートリミットの実装

①、まず最終的な実装効果を確定(複数のレートリミットルール+重複送信防止の実現)

@RateLimit(
        rules = {
                // 60秒間に10回までアクセス可能
                @LimitRule(count = 10, time = 60, timeUnit = TimeUnit.SECONDS),
                // 120秒間に20回までアクセス可能
                @LimitRule(count = 20, time = 120, timeUnit = TimeUnit.SECONDS)

        },
        // 重複送信防止(5秒間に1回までアクセス可能)
        preventDuplicate = true
)

②、アノテーションの作成

RateLimitアノテーション

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

    /**
     * レートリミットキー
     */
    String key() default RedisKeyConstants.RATE_LIMIT_CACHE_PREFIX;

    /**
     * レートリミットタイプ (デフォルトはIPモード)
     */
    LimitType limitType() default LimitType.IP;

    /**
     * エラーメッセージ
     */
    ResultCode message() default ResultCode.REQUEST_MORE_ERROR;

    /**
     * レートリミットルール(ルールは不変、複数ルール設定可能)
     */
    LimitRule[] rules() default {};

    /**
     * 重複送信防止フラグ
     */
    boolean preventDuplicate() default false;

    /**
     * 重複送信防止のデフォルトルール
     */
    LimitRule preventDuplicateRule() default @LimitRule(count = 1, time = 5);
}

LimitRuleアノテーション:


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

    /**
     * 制限回数
     */
    long count() default 10;

    /**
     * 制限時間
     */
    long time() default 60;

    /**
     * 時間単位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

}

③、RateLimitアノテーションのインターセプト

  • Redisストレージ方式の確定
    Redisキー = プレフィックス : クラス名 : メソッド名
    Redisスコア = タイムスタンプ
    Redis値 = 分散環境で一意となる任意の値
  • Redisキー生成メソッドの作成
public String generateCombinedKey(RateLimit rateLimit, JoinPoint joinPoint) {
    StringBuilder keyBuilder = new StringBuilder(rateLimit.key());
    // 異なるレートリミットタイプで異なるプレフィックスを使用
    switch (rateLimit.limitType()) {
        // パラメータによるレートリミット指定も追加可能
        case IP:
            keyBuilder.append(IpUtils.getClientIp(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest())).append(":");
            break;
        case USER_ID:
            SysUserDetails currentUser = SecurityUtils.getCurrentUser();
            if (!ObjectUtils.isEmpty(currentUser)) keyBuilder.append(currentUser.getUserId()).append(":");
            break;
        case GLOBAL:
            break;
    }
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    Class<?> targetClass = method.getDeclaringClass();
    keyBuilder.append(targetClass.getSimpleName()).append("-").append(method.getName());
    return keyBuilder.toString();
}

④、Luaスクリプトの作成(Redisにイベントを追加する2つの方法)

Ⅰ:UUID(同様の特性を持つ他の値も可)をZsetの値として使用

  • パラメータ説明:
    KEYS[1] = プレフィックス : ? : クラス名 : メソッド名
    KEYS[2] = 一意ID
    KEYS[3] = 現在時刻
    ARGV = [回数、単位時間、回数、単位時間, 回数, 単位時間 …]
  • Javaから分散環境で一意となる値を渡す

-- 1. パラメータ取得
local cacheKey = KEYS[1]
local uniqueId = KEYS[2]
local currentTimestamp = tonumber(KEYS[3])
-- 2. 配列の最大値をTTLの最大値とする
local maxExpireTime = -1;
-- 3. 配列を走査しレートリミットルールを超えているか確認
for i = 1, #ARGV, 2 do
    local ruleCount = tonumber(ARGV[i])
    local ruleTime = tonumber(ARGV[i + 1])
    -- 3.1 単位時間内のアクセス回数を判定
    local accessCount = redis.call('ZCOUNT', cacheKey, currentTimestamp - ruleTime, currentTimestamp)
    -- 3.2 規定回数を超えているか判定
    if tonumber(accessCount) >= ruleCount then
        return true
    end
    -- 3.3 要素の最大値を判定し、最終的な有効期限として設定
    if ruleTime > maxExpireTime then
        maxExpireTime = ruleTime
    end
end
-- 4. Redisに現在時刻を追加
redis.call('ZADD', cacheKey, currentTimestamp, uniqueId)
-- 5. キャッシュの有効期限を更新
redis.call('PEXPIRE', cacheKey, maxExpireTime)
-- 6. データ過多を防ぐため、最大時間制限以前のデータを削除
redis.call('ZREMRANGEBYSCORE', cacheKey, 0, currentTimestamp - maxExpireTime)
return false

Ⅱ、タイムスタンプをZsetの値として使用

  • パラメータ説明
    KEYS[1] = プレフィックス : ? : クラス名 : メソッド名
    KEYS[2] = 現在時刻
    ARGV = [回数、単位時間、回数、単位時間, 回数, 単位時間 …]
  • 時刻に基づいて値を生成、同一ミリ秒での同じ時刻値追加問題を考慮
    以下は2番目の実装方式で、高並行の場合は効率が低い。値はタイムスタンプで追加されるが、アクセス量が多いと常にredis.call('ZADD', key, currentTime, currentTime)が呼び出される。ただし、値の競合がない場合は、UUIDを生成するよりも良い。

-- 1. パラメータ取得
local cacheKey = KEYS[1]
local currentTimestamp = KEYS[2]
-- 2. 配列の最大値をTTLの最大値とする
local maxExpireTime = -1;
-- 3. 配列を走査し範囲外か確認
for i = 1, #ARGV, 2 do
    local ruleCount = tonumber(ARGV[i])
    local ruleTime = tonumber(ARGV[i + 1])
    -- 3.1 単位時間内のアクセス回数を判定
    local accessCount = redis.call('ZCOUNT', cacheKey, currentTimestamp - ruleTime, currentTimestamp)
    -- 3.2 規定回数を超えているか判定
    if tonumber(accessCount) >= ruleCount then
        return true
    end
    -- 3.3 要素の最大値を判定し、最終的な有効期限として設定
    if ruleTime > maxExpireTime then
        maxExpireTime = ruleTime
    end
end
-- 4. キャッシュの有効期限を更新
redis.call('PEXPIRE', cacheKey, maxExpireTime)
-- 5. データ過多を防ぐため、最大時間制限以前のデータを削除
redis.call('ZREMRANGEBYSCORE', cacheKey, 0, currentTimestamp - maxExpireTime)
-- 6. Redisに現在時刻を追加 (複数スレッドが同一ミリ秒で同じ値を追加しRedisでカウント漏れが発生する問題の解決)
-- 6.1 maxRetries 最大再試行回数 retries 現在の再試行回数
local maxRetries = 5
local retryCount = 0
while true do
    local result = redis.call('ZADD', cacheKey, currentTimestamp, currentTimestamp)
    if result == 1 then
        -- 6.2 追加成功ならループを抜ける
        break
    else
        -- 6.3 追加失敗なら値を+1して再試行
        retryCount = retryCount + 1
        if retryCount >= maxRetries then
            -- 6.4 最大試行回数を超えた場合、乱数追加戦略を採用
            local randomOffset = math.random(1, 1000)
            currentTimestamp = currentTimestamp + randomOffset
        else
            currentTimestamp = currentTimestamp + 1
        end
    end
end

return false

⑤、AOPインターセプタの実装


@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private RedisScript<Boolean> rateLimitScript;

/**
 * レートリミット処理
 * XXX レートリミット要件が高い場合は、Redisでルールを保存・検証するか、ミドルウェアを使用する
 *
 * @param joinPoint   joinPoint
 * @param rateLimit   レートリミットアノテーション
 */
@Before(value = "@annotation(rateLimit)")
public void doBefore(JoinPoint joinPoint, RateLimit rateLimit) {
    // 1. キーを生成
    String combinedKey = generateCombinedKey(rateLimit, joinPoint);
    try {
        // 2. スクリプトを実行しレートリミットが必要か判定
        Boolean isLimited = redisTemplate.execute(rateLimitScript,
                CollectionUtil.toList(combinedKey, String.valueOf(System.currentTimeMillis())),
                (Object[]) extractRules(rateLimit));
        // 3. レートリミットが必要か判定
        if (Boolean.TRUE.equals(isLimited)) {
            log.error("IP: '{}' がリクエストをブロックしました RedisKey: '{}'",
                    IpUtils.getClientIp(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest()),
                    combinedKey);
            throw new BusinessException(rateLimit.message());
        }
    } catch (BusinessException e) {
        throw e;
    } catch (Exception e) {
        e.printStackTrace();
    }
}

/**
 * ルール情報を抽出
 *
 * @param rateLimit ルール情報を含むアノテーション
 * @return ルール配列
 */
private Long[] extractRules(RateLimit rateLimit) {
    int capacity = rateLimit.rules().length << 1;
    // 1. 引数配列を構築
    Long[] ruleParams = new Long[rateLimit.preventDuplicate() ? capacity + 2 : capacity];
    // 3. 配列要素を記録
    int index = 0;
    // 2. 重複送信防止ルールをRedisに追加する必要があるか判定
    if (rateLimit.preventDuplicate()) {
        LimitRule duplicateRule = rateLimit.preventDuplicateRule();
        ruleParams[index++] = duplicateRule.count();
        ruleParams[index++] = duplicateRule.timeUnit().toMillis(duplicateRule.time());
    }
    LimitRule[] rules = rateLimit.rules();
    for (LimitRule rule : rules) {
        ruleParams[index++] = rule.count();
        ruleParams[index++] = rule.timeUnit().toMillis(rule.time());
    }
    return ruleParams;
}

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

6月25日 16:04 投稿