Certainly we want to limit the frequency of user visits, because users could get angry, and angry people will click on our app like crazy.
They could also be very bad and use some crawler to try to bring down our server.
So how to do that?
In this article, we use springboot and log the user’s information and access frequency into redis, you can also store it into memory or database.
Think about this story, from first principles
The user may not be logged in, or may already be logged in.
If the user is logged in, we limit him based on the username, otherwise, we use IP or some unique device code.
Ip is used in this article.
We want it to be simple enough to be used on multiple methods without writing additional code. So we are going to use interface cutouts.
The @interface
1public @interface RequestRateLimit {
2
3 /**
4 * the key, we use it to distinguish user request objects, like /info1 /info2
5 * @return
6 */
7 String key() default "";
8
9 /**
10 * just ignore this for now
11 * @return
12 */
13 RateType type() default RateType.PER_CLIENT;
14
15 /**
16 * frequency, like once per minute
17 * @return
18 */
19 long rate() default 1;
20
21 /**
22 * interval, like every one minute
23 * @return
24 */
25 long rateInterval() default 60 * 1000;
26
27 /**
28 * interval unit, like minute
29 * @return
30 */
31 RateIntervalUnit timeUnit() default RateIntervalUnit.MILLISECONDS;
32
33}
The Aspect
You can just copy this code and test it.
1public class RequestRateLimitAspect {
2
3 private RedissonClient redisson;
4 private final UserService userService;
5
6 @Pointcut("@annotation(RequestRateLimit)")
7 public void findAnnotationPointCut(RequestRateLimit RequestRateLimit) {
8 }
9
10 @Around(value = "findAnnotationPointCut(requestRateLimit)", argNames = "joinPoint,requestRateLimit")
11 public Object around(ProceedingJoinPoint joinPoint, RequestRateLimit requestRateLimit) throws Throwable {
12 UserEntity user = userService.getCurrentRequestUser(); // just SecurityContextHolder.getContext().getAuthentication().getPrincipal();
13 String realIp = "";
14 if (user == null) {
15 RequestAttributes ra = RequestContextHolder.getRequestAttributes();
16 ServletRequestAttributes sra = (ServletRequestAttributes) ra;
17 if (null != sra) {
18 HttpServletRequest request = sra.getRequest();
19 realIp = request.getHeader("His-Real-IP");
20 if (notValidIp(realIp)) {
21 realIp = request.getHeader("His-Real-IP2");
22 if (notValidIp(realIp)) {
23 realIp = request.getRemoteAddr();
24 }
25 }
26 }
27 }
28 if (user == null && notValidIp(realIp)) {
29 return R.failed(EMPTY_USER, "login info not found");
30 }
31 // the real logic
32 String key = user == null || StrUtil.isBlank(user.getUserName()) ? realIp : user.getUserName();
33 key = key + "::" + joinPoint.getSignature().getName();
34 RRateLimiter limiter = getRateLimiter(requestRateLimit, key);
35 if (limiter.tryAcquire(1)) {
36 return joinPoint.proceed();
37 } else {
38 log.info("rate-limit: {} {} {}", user == null ? "" : user.getUserName(), realIp, joinPoint.getSignature());
39 return R.failed(REACH_REQUEST_LIMIT, String.format("too often, please retry after:%s %s", requestRateLimit.rateInterval(), requestRateLimit.timeUnit().name().toLowerCase()));
40 }
41 }
42
43 private boolean notValidIp(String ip) {
44 return StrUtil.isBlank(ip) || ip.startsWith("172.1"); // docker bridge ip
45 }
46
47 private RRateLimiter getRateLimiter(RequestRateLimit limit, String defaultKey) {
48 RRateLimiter rRateLimiter = redisson.getRateLimiter(StrUtil.isBlank(limit.key()) ? RATE_LIMITER + "::" + defaultKey : limit.key()); // RATE_LIMITER is a constant, change it to whatever you want
49 if (rRateLimiter.isExists()) {
50 RateLimiterConfig existed = rRateLimiter.getConfig();
51 if (!Objects.equals(limit.rate(), existed.getRate())
52 || !Objects.equals(limit.timeUnit().toMillis(limit.rateInterval()), existed.getRateInterval())
53 || !Objects.equals(limit.type(), existed.getRateType())) {
54 rRateLimiter.delete();
55 rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
56 expireByConfig(rRateLimiter, limit);
57 }
58 } else {
59 rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
60 expireByConfig(rRateLimiter, limit);
61 }
62
63 return rRateLimiter;
64 }
65
66 private void expireByConfig(RRateLimiter rRateLimiter, RequestRateLimit limit) {
67 long limitDuration = limit.timeUnit().toMillis(limit.rateInterval()) + 5000;
68 rRateLimiter.expire(Instant.now().plusMillis(limitDuration));
69 }
70}
Usage
1 @GetMapping("/info")
2 @RequestRateLimit(rate = 2, rateInterval = 1, timeUnit = RateIntervalUnit.MINUTES) // Maximum of two requests per minute
3 public R getInfo() {
4 // ...
5 }
When a user requests the /info method, a key such as RATE_LIMITER::his_user_name::com.package.getInfo
is stored in redis.
if the user requests the /info method more than twice in a minute, he will receive an error and the getInfo method will not execute.
Note that this annotation does not work on methods annotated with @Cacheable.
More
We can implement a customized frequency limit that can restrict arbitrary methods, such as urgent emails sent to Ops staff, if the same subject has been sent, don’t send it again within 5 minutes.
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 }