Redisを利用したレートリミット実装は一般的ですが、多くの場合は単一ルール(例:1分あたり1回アクセス、60分あたり10回アクセスなど)に限定されています。しかし、実際のシステムでは一つのインターフェースに対して複数のルールを同時に適用する必要がある場合があります。特に分散システム環境では、この課題をどのように解決すればよいでしょうか。ここでは、Redisを使用した分散環境における複数ルールのレートリミット実装方法について解説します。
- 1分間に1回、1時間間に10回など、複数のルールに基づく認証コード送信制限
- 短時間に大量のリクエストが送られる悪意のある攻撃からの保護
- インターフェースへのアクセス回数を指定時間内に制限する方法
一:String構造を使用した固定時間枠におけるユーザーIPごとのインターフェースアクセス回数記録
- Redisキー = プレフィックス : クラス名 : メソッド名
- Redis値 = アクセス回数
リクエストインターセプト処理:
- 初回アクセス時に[Redisキー][Redis値=1][指定された有効期限]を設定
- 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;
}