最近、SpringBootをベースとした一般ユーザー向けのシステムを構築しているのですが、システムの安定性確保と悪意のある攻撃を防ぐために、ユーザーが各インターフェースにアクセスする頻度を制御したいと考えています。この機能を実現するには、アノテーションを設計し、メソッドを呼び出す前に AOP を使用して現在の IP のアクセス頻度を確認し、設定した頻度を超えた場合は直接エラー メッセージを返すようにします。
よくある設計ミス
具体的な実装を紹介する前に、インターネットで見つけたよくある間違った設計をいくつか挙げておきます。
1.固定窓
誰かが、1 分あたり 1000 回のアクセスのみを許可する電流制限スキームを設計しました。下の図に示すように、01:00 秒から 02:00 秒までの間は 1000 回のアクセスのみが許可されます。この設計の最大の問題は、リクエストが 01:00 の間に発生する可能性があることです。 :59s-02:00s 02:00 秒の間に 1,000 件のリクエストが行われ、02:00 秒から 02:01 秒の間に 1,000 件のリクエストが行われました。この場合、0.02 秒間隔で 01:59 秒から 02:01 秒の間に 2,000 件のリクエストが行われました。明らかに、この設計は間違っています。
2. キャッシュ時間更新エラー
この問題を調査していたとき、インターネット上のトラフィックを制限する非常に一般的な方法があることがわかりました。このアイデアは Redis に基づいており、ユーザーのリクエストが受信されるたびに、ユーザーの IP とリクエスト URL をキーとして使用します。回数が制限を超えているかどうかを判断し、超えている場合はエラーを返し、超えていない場合はredisのキーに対応する値に1を加算し、キーの有効期限を指定されたアクセス期間にリセットします。ユーザー。コアコードは次のとおりです。
// core logic
int limit = accessLimit.limit();
long sec = accessLimit.sec();
String key = IPUtils.getIpAddr(request) + request.getRequestURI();
Integer maxLimit =null;
Object value =redisService.get(key);
if(value!=null && !value.equals("")) {
maxLimit = Integer.valueOf(String.valueOf(value));
}
if (maxLimit == null) {
redisService.set(key, "1", sec);
} else if (maxLimit < limit) {
Integer i = maxLimit+1;
redisService.set(key, i.toString(), sec);
} else {
throw new BusinessException(500,"请求太频繁!");
}
// redis related
public boolean set(final String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
ここでの大きな問題は、キーのキャッシュ有効期限が毎回更新されることです。これは、各カウント サイクルを偽装して延長することに相当します。ユーザーが 1 分間に 5 回のみアクセスするように制御したいのかもしれませんが、ユーザーが前の 1 分間に 3 回のみ訪問し、次の 1 分間に 3 回訪問しました 上記の実装では、6 回目の訪問でエラーが返される可能性がありますが、ユーザーは対応する訪問を超えていないため、これは問題です2 分以内の頻度のしきい値。
キーの更新については、公式 Redis ドキュメントを参照してください。更新するたびにキーの有効期限が更新されます。
引き違い窓をベースにした適切なデザイン
指定时间T内,只允许发生N次。我们可以将这个指定时间T,看成一个滑动时间窗口(定宽)。我们采用Redis的zset基本数据类型的score来圈出这个滑动时间窗口。在实际操作zset的过程中,我们只需要保留在这个滑动时间窗口以内的数据,其他的数据不处理即可。
比如在上面的例子里面,假设用户的要求是60s内访问频率控制为3次。那么我永远只会统计当前时间往前倒数60s之内的访问次数,随着时间的推移,整个窗口会不断向前移动,窗口外的请求不会计算在内,保证了永远只统计当前60s内的request。
为什么选择Redis zset ?
为了统计固定时间区间内的访问频率,如果是单机程序,可能采用concurrentHashMap就够了,但是如果是分布式的程序,我们需要引入相应的分布式组件来进行计数统计,而Redis zset刚好能够满足我们的需求。
Redis zset(有序集合)中的成员是有序排列的,它和 set 集合的相同之处在于,集合中的每一个成员都是字符串类型,并且不允许重复;而它们最大区别是,有序集合是有序的,set 是无序的,这是因为有序集合中每个成员都会关联一个 double(双精度浮点数)类型的 score (分数值),Redis 正是通过 score 实现了对集合成员的排序。
Redis 使用以下命令创建一个有序集合:
ZADD key score member [score member ...]
这里面有三个重要参数,
- key:指定一个键名;
- score:分数值,用来描述 member,它是实现排序的关键;
- member:要添加的成员(元素)。
当 key 不存在时,将会创建一个新的有序集合,并把分数/成员(score/member)添加到有序集合中;当 key 存在时,但 key 并非 zset 类型,此时就不能完成添加成员的操作,同时会返回一个错误提示。
在我们这个场景里面,key就是用户ip+request uri
,score直接用当前时间的毫秒数表示,至于member不重要,可以也采用和score一样的数值即可。
限流过程是怎么样的?
整个流程如下:
- まず、ユーザーのリクエストが届き、ユーザーの IP と URI がキーを形成し、タイムスタンプが値となり、それを zset に入れます。
- 現在のキーのキャッシュ有効期限を更新します。この手順は主にコールド データを定期的にクリーンアップすることであり、上で説明した一般的なエラーの設計 2 とは異なります。
- ウィンドウ外のデータレコードを削除します。
- 現在のウィンドウ内のレコードの総数をカウントします。
- レコード数がしきい値より大きい場合は、エラーが直接返されます。それ以外の場合、ユーザーのリクエストは通常どおり処理されます。
SpringBoot と AOP に基づく電流制限
このパートでは主に具体的な実装ロジックを紹介します。
注釈と処理ロジックを定義する
1 つ目は、異なるインターフェイスに対して異なる制限周波数を後で使用できるようにするための注釈を定義することです。
/**
* 接口访问频率注解,默认一分钟只能访问5次
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
// 限制时间 单位:秒(默认值:一分钟)
long period() default 60;
// 允许请求的次数(默认值:5次)
long count() default 5;
}
ロジックの実装では、ユーザーのリクエストをインターセプトするアスペクト関数を定義します。具体的な実装プロセスは、主に redis zset の操作を含む、上記の電流制限プロセスと一致します。
@Aspect
@Component
@Log4j2
public class RequestLimitAspect {
@Autowired
RedisTemplate redisTemplate;
// 切点
@Pointcut("@annotation(requestLimit)")
public void controllerAspect(RequestLimit requestLimit) {}
@Around("controllerAspect(requestLimit)")
public Object doAround(ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable {
// get parameter from annotation
long period = requestLimit.period();
long limitCount = requestLimit.count();
// request info
String ip = RequestUtil.getClientIpAddress();
String uri = RequestUtil.getRequestUri();
String key = "req_limit_".concat(uri).concat(ip);
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
// add current timestamp
long currentMs = System.currentTimeMillis();
zSetOperations.add(key, currentMs, currentMs);
// set the expiration time for the code user
redisTemplate.expire(key, period, TimeUnit.SECONDS);
// remove the value that out of current window
zSetOperations.removeRangeByScore(key, 0, currentMs - period * 1000);
// check all available count
Long count = zSetOperations.zCard(key);
if (count > limitCount) {
log.error("接口拦截:{} 请求超过限制频率【{}次/{}s】,IP为{}", uri, limitCount, period, ip);
throw new AuroraRuntimeException(ResponseCode.TOO_FREQUENT_VISIT);
}
// execute the user request
return joinPoint.proceed();
}
}
電流制限制御に注釈を使用する
ここでは、テスト用のインターフェイス クラスを定義し、上記のアノテーションを使用して現在の制限を完了し、ユーザーが 1 分あたり 3 回アクセスできるようにします。
@Log4j2
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/test")
@RequestLimit(count = 3)
public GenericResponse<String> testRequestLimit() {
log.info("current time: " + new Date());
return new GenericResponse<>(ResponseCode.SUCCESS);
}
}
次に、異なるマシンのインターフェイスにアクセスすると、異なるマシンの電流制限が分離されており、各マシンは期間内に 3 回しかアクセスできないことがわかります。期待される効果。
2023-05-21 11:23:15.733 INFO 99636 --- [nio-8080-exec-1] c.v.c.a.api.controller.UserController : current time: Sun May 21 11:23:15 CST 2023
2023-05-21 11:23:21.848 INFO 99636 --- [nio-8080-exec-3] c.v.c.a.api.controller.UserController : current time: Sun May 21 11:23:21 CST 2023
2023-05-21 11:23:23.044 INFO 99636 --- [nio-8080-exec-4] c.v.c.a.api.controller.UserController : current time: Sun May 21 11:23:23 CST 2023
2023-05-21 11:23:25.920 ERROR 99636 --- [nio-8080-exec-5] c.v.c.a.annotation.RequestLimitAspect : 接口拦截:/user/test 请求超过限制频率【3次/60s】,IP为0:0:0:0:0:0:0:1
2023-05-21 11:23:28.761 ERROR 99636 --- [nio-8080-exec-6] c.v.c.a.annotation.RequestLimitAspect : 接口拦截:/user/test 请求超过限制频率【3次/60s】,IP为0:0:0:0:0:0:0:1
2023-05-21 11:24:12.207 INFO 99636 --- [io-8080-exec-10] c.v.c.a.api.controller.UserController : current time: Sun May 21 11:24:12 CST 2023
2023-05-21 11:24:19.100 INFO 99636 --- [nio-8080-exec-2] c.v.c.a.api.controller.UserController : current time: Sun May 21 11:24:19 CST 2023
2023-05-21 11:24:20.117 INFO 99636 --- [nio-8080-exec-1] c.v.c.a.api.controller.UserController : current time: Sun May 21 11:24:20 CST 2023
2023-05-21 11:24:21.146 ERROR 99636 --- [nio-8080-exec-3] c.v.c.a.annotation.RequestLimitAspect : 接口拦截:/user/test 请求超过限制频率【3次/60s】,IP为192.168.31.114
2023-05-21 11:24:26.779 ERROR 99636 --- [nio-8080-exec-4] c.v.c.a.annotation.RequestLimitAspect : 接口拦截:/user/test 请求超过限制频率【3次/60s】,IP为192.168.31.114
2023-05-21 11:24:29.344 ERROR 99636 --- [nio-8080-exec-5] c.v.c.a.annotation.RequestLimitAspect : 接口拦截:/user/test 请求超过限制频率【3次/60s】,IP为192.168.31.114
公式アカウント[MALAOSI]に注目して、最も分かりやすいオリジナルのテクニカルドライグッズについてのみ話してください。