我們當然要限制用戶訪問頻率,因爲用戶可能生氣,並狂點我們的網站或應用。
他也可能很壞,使用一些爬蟲試圖拖垮我們的服務器。
所以怎麼實現呢?
本文使用 springboot,並將用戶的信息和訪問頻率記錄到 redis 中,如果你沒有使用 redis,也不影響,你可以參考着自己實現,比如存儲到內存或數據庫中。
想想這個需求,從第一性原理出發
用戶可能沒有登錄,或者已經登錄了。
如果用戶登錄了,我們就根據用戶名來限制,否則,就根據IP或者其它設備碼來限制,本文假設使用IP。
我們希望它足夠簡單,可以在多個方法上使用,而不需要編寫額外的代碼。所以我們要使用接口切面。
接口
1public @interface RequestRateLimit {
2
3 /**
4 * 限流的key,比如限制用戶註冊,限制用戶發送郵件,等等,一般是方法名
5 * @return
6 */
7 String key() default "";
8
9 /**
10 * 限流模式,默認單機
11 * @return
12 */
13 RateType type() default RateType.PER_CLIENT;
14
15 /**
16 * 限流速率,1次/分鐘
17 * @return
18 */
19 long rate() default 1;
20
21 /**
22 * 限流速率,每分鐘
23 * @return
24 */
25 long rateInterval() default 60 * 1000;
26
27 /**
28 * 限流速率單位
29 * @return
30 */
31 RateIntervalUnit timeUnit() default RateIntervalUnit.MILLISECONDS;
32
33}
切面
你可以直接拷貝這些代碼並測試。
1public class RequestRateLimitAspect {
2
3 private RedissonClient redisson;
4 private final UserService userService;
5
6 /**
7 * 根據自定義註解獲取切點
8 *
9 * @param RequestRateLimit 註解接口
10 */
11 @Pointcut("@annotation(RequestRateLimit)")
12 public void findAnnotationPointCut(RequestRateLimit RequestRateLimit) {
13 }
14
15 @Around(value = "findAnnotationPointCut(requestRateLimit)", argNames = "joinPoint,requestRateLimit")
16 public Object around(ProceedingJoinPoint joinPoint, RequestRateLimit requestRateLimit) throws Throwable {
17 UserEntity user = userService.getCurrentRequestUser(); // 只是封裝了 SecurityContextHolder.getContext().getAuthentication().getPrincipal();
18 String realIp = "";
19 if (user == null) {
20 RequestAttributes ra = RequestContextHolder.getRequestAttributes();
21 ServletRequestAttributes sra = (ServletRequestAttributes) ra;
22 if (null != sra) {
23 HttpServletRequest request = sra.getRequest();
24 realIp = request.getHeader("His-Real-IP");
25 if (notValidIp(realIp)) {
26 realIp = request.getHeader("His-Real-IP2");
27 if (notValidIp(realIp)) {
28 realIp = request.getRemoteAddr();
29 }
30 }
31 }
32 }
33 if (user == null && notValidIp(realIp)) {
34 return R.failed(EMPTY_USER, "未找到您的任何登錄信息");
35 }
36 // 限流攔截器
37 String key = user == null || StrUtil.isBlank(user.getUserName()) ? realIp : user.getUserName();
38 key = key + "::" + joinPoint.getSignature().getName();
39 RRateLimiter limiter = getRateLimiter(requestRateLimit, key);
40 if (limiter.tryAcquire(1)) {
41 return joinPoint.proceed();
42 } else {
43 log.info("rate-limit: {} {} {}", user == null ? "" : user.getUserName(), realIp, joinPoint.getSignature());
44 return R.failed(REACH_REQUEST_LIMIT, String.format("請求過於頻繁,請於以下時間後重試:%s %s", requestRateLimit.rateInterval(), requestRateLimit.timeUnit().name().toLowerCase()));
45 }
46 }
47
48 private boolean notValidIp(String ip) {
49 return StrUtil.isBlank(ip) || ip.startsWith("172.1"); // docker bridge ip
50 }
51
52 /**
53 * 獲取限流攔截器
54 *
55 * @param limit 在要限流的方法上的配置
56 * @param defaultKey 在redis中的存儲的key
57 * @return 限流器
58 */
59 private RRateLimiter getRateLimiter(RequestRateLimit limit, String defaultKey) {
60 RRateLimiter rRateLimiter = redisson.getRateLimiter(StrUtil.isBlank(limit.key()) ? RATE_LIMITER + "::" + defaultKey : limit.key()); // RATE_LIMITER 隨意起名,比如可以使用你的項目名稱,只是爲了在redis中好區分
61 // 設置限流
62 if (rRateLimiter.isExists()) {
63 RateLimiterConfig existed = rRateLimiter.getConfig();
64 // 判斷配置是否更新,如果更新,重新加載限流器配置
65 if (!Objects.equals(limit.rate(), existed.getRate())
66 || !Objects.equals(limit.timeUnit().toMillis(limit.rateInterval()), existed.getRateInterval())
67 || !Objects.equals(limit.type(), existed.getRateType())) {
68 rRateLimiter.delete();
69 rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
70 expireByConfig(rRateLimiter, limit);
71 }
72 } else {
73 rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
74 expireByConfig(rRateLimiter, limit);
75 }
76
77 return rRateLimiter;
78 }
79
80 private void expireByConfig(RRateLimiter rRateLimiter, RequestRateLimit limit) {
81 // ttl 設置爲 rateLimit 配置時間 + 5s
82 long limitDuration = limit.timeUnit().toMillis(limit.rateInterval()) + 5000;
83 // 設置過期時間,從現在算起 + 以上計算的時間。超時時間到後會刪除一下幾個key
84 // 1) "{rr_limiter::username}:value:***********"
85 // 2) "{rr_limiter::username}:permits:***********"
86 // 3) "rr_limiter::username"
87 rRateLimiter.expire(Instant.now().plusMillis(limitDuration));
88 }
89}
使用
1 @GetMapping("/info")
2 @RequestRateLimit(rate = 2, rateInterval = 1, timeUnit = RateIntervalUnit.MINUTES) // 1 分鐘允許請求 2 次
3 public R getInfo() {
4 // ...
5 }
當用戶請求 /info 接口的時候,redis 中就會存儲一個 RATE_LIMITER::his_user_name::com.package.getInfo 這樣的 key。當該用戶在1分鐘內請求該接口超過2次,那麼他將會收到報錯,並且 getInfo 方法並不會執行。
注意該註解無法作用於 @Cacheable 註釋的方法上。
更多
我們可以實現一個自定義的頻率限制,可以限制任意的方法,比如發送給運維人員的緊急郵件,如果同一主題發送過了,在5分鐘內不要再次發送。
1public @interface CustomRateLimit {
2 /**
3 * key 的前綴,用於一組相同功能限流的標記
4 * @return
5 */
6 String prefix() default "";
7
8 /**
9 * 限流的 key,要求不爲空,支持從參數中讀取
10 * @return
11 */
12 String key() default "#key";
13
14 /**
15 * 限流模式,默認單機
16 * @return
17 */
18 RateType type() default RateType.PER_CLIENT;
19
20 /**
21 * 限流速率,1次/分鐘
22 * @return
23 */
24 long rate() default 1;
25
26 /**
27 * 限流速率,每分鐘
28 * @return
29 */
30 long rateInterval() default 60 * 1000;
31
32 /**
33 * 限流速率單位
34 * @return
35 */
36 RateIntervalUnit timeUnit() default RateIntervalUnit.MILLISECONDS;
37
38}
39
40public class CustomRateLimitAspect {
41
42 private final RedissonClient redisson;
43 /**
44 * 根據自定義註解獲取切點
45 *
46 * @param CustomRateLimit 註解接口
47 */
48 @Pointcut("@annotation(CustomRateLimit)")
49 public void findAnnotationPointCut(CustomRateLimit CustomRateLimit) {
50 }
51
52 @Around(value = "findAnnotationPointCut(customRateLimit)", argNames = "joinPoint,customRateLimit")
53 public Object around(ProceedingJoinPoint joinPoint, CustomRateLimit customRateLimit) throws Throwable {
54 // 限流攔截器
55 String key = getKey(joinPoint, customRateLimit);
56 RRateLimiter limiter = getRateLimiter(customRateLimit, key);
57 if (limiter.tryAcquire(1)) {
58 return joinPoint.proceed();
59 } else {
60 log.info("skip method cause violate rate limit, key is {}", key);
61 return R.failed(REACH_REQUEST_LIMIT, String.format("請求過於頻繁,請於以下時間後重試:%s %s", customRateLimit.rateInterval(), customRateLimit.timeUnit().name().toLowerCase()));
62 }
63 }
64
65 /**
66 * 獲取限流攔截器
67 *
68 * @param limit 在要限流的方法上的配置
69 * @return 限流器
70 */
71 private RRateLimiter getRateLimiter(CustomRateLimit limit, String key) {
72 RRateLimiter rRateLimiter = redisson.getRateLimiter(CUSTOM_RATE_LIMITER_PREFIX + "::" + limit.prefix() + "::" + key);
73 // 設置限流
74 if (rRateLimiter.isExists()) {
75 RateLimiterConfig existed = rRateLimiter.getConfig();
76 // 判斷配置是否更新,如果更新,重新加載限流器配置
77 if (!Objects.equals(limit.rate(), existed.getRate())
78 || !Objects.equals(limit.timeUnit().toMillis(limit.rateInterval()), existed.getRateInterval())
79 || !Objects.equals(limit.type(), existed.getRateType())) {
80 rRateLimiter.delete();
81 rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
82 expireByConfig(rRateLimiter, limit);
83 }
84 } else {
85 rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
86 expireByConfig(rRateLimiter, limit);
87 }
88
89 return rRateLimiter;
90 }
91
92 private void expireByConfig(RRateLimiter rRateLimiter, CustomRateLimit limit) {
93 long limitDuration = limit.timeUnit().toMillis(limit.rateInterval()) + 5000;
94 rRateLimiter.expire(Instant.now().plusMillis(limitDuration));
95 }
96
97 // el表達式支持
98 private String getKey(JoinPoint joinPoint, CustomRateLimit customRateLimit) {
99 ExpressionParser expressionParser = new SpelExpressionParser();
100 Expression expression = expressionParser.parseExpression(customRateLimit.key());
101 CodeSignature methodSignature = (CodeSignature) joinPoint.getSignature();
102 String[] sigParamNames = methodSignature.getParameterNames();
103 EvaluationContext context = new StandardEvaluationContext();
104 Object[] args = joinPoint.getArgs();
105 for (int i = 0; i < sigParamNames.length; i++) {
106 context.setVariable(sigParamNames[i], args[i]);
107 }
108 return (String) expression.getValue(context);
109 }
110}
使用
1 @Override
2 @CustomRateLimit(prefix = Constants.Cache.EMAIL_RATE_LIMITER, rateInterval = 5, timeUnit = RateIntervalUnit.MINUTES) // 5分鐘最多一次
3 public void sendToMaintainersWithFrequencyLimit(String key, String subject, String... content) {
4 sendToMaintainers("[緊急通知]", subject, content);
5 }