SpringBoot limits interface access frequency – these mistakes must not be made

Recently, I am building a system for ordinary users based on SpringBoot. In order to ensure the stability of the system and prevent malicious attacks, I want to control the frequency of users accessing each interface. In order to realize this function, you can design an annotation, and then use AOP to check the access frequency of the current ip before calling the method. If the frequency exceeds the set frequency, an error message will be returned directly.

Common design mistakes

Before I start to introduce the specific implementation, I will list several common wrong designs that I found on the Internet.

1. Fixed window

Someone designed a current limiting scheme that only allows 1000 visits per minute, as shown in the figure below, only 1000 visits are allowed between 01:00s-02:00s. The biggest problem with this design is that the request may be between 01:59s-02:00s 1,000 requests were made between 02:00s and 1,000 requests were made between 02:00s and 02:01s. In this case, 2,000 requests were made between 01:59s and 02:01s with an interval of 0.02s. Obviously, this The design is wrong.

2. Cache time update error

When I was researching this problem, I found that there is a very common way to limit traffic on the Internet. The idea is based on redis. Every time a user’s request comes in, it will use the user’s ip and request url as the key to judge the access. Whether the number of times exceeds the limit, if so, return an error, otherwise, add 1 to the value corresponding to the key in redis, and reset the expiration time of the key to the access period specified by the user. The core code is as follows:

java copy code// 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 & amp; & amp; !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,"The request is too frequent!");
}

// 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;
    }

The big problem here is that The cache expiration time of the key will be updated every time, which is equivalent to prolonging each counting cycle in disguise. Maybe we want to control users to only visit 5 times in a minute, But if the user only visited three times in the first minute and three times in the next minute, in the above implementation, it is likely to return an error at the sixth visit, but this is problematic, because the user did visit in two minutes None of them exceeded the corresponding access frequency threshold.

For key refresh, you can refer to the official redis document. Every refresh will update the key expiration time.

Correct design based on sliding window

Within the specified time T, only N occurrences are allowed. We can regard this specified time T as a sliding time window (fixed width). We use the score of Redis’s zset basic data type to circle this sliding time window. In the process of actually operating zset, we only need to keep the data within this sliding time window, and leave other data alone.

For example, in the above example, it is assumed that the user’s request is to control the access frequency to 3 times within 60s. Then I will always only count the number of visits within 60 seconds from the current time forward. As time goes by, the entire window will continue to move forward, and requests outside the window will not be counted, ensuring that only the current 60 seconds will be counted forever the request.

Why choose Redis zset?

In order to count the access frequency within a fixed time interval, if it is a stand-alone program, it may be enough to use concurrentHashMap, but if it is a distributed program, we need to introduce corresponding distributed components for counting statistics, and Redis zset can just satisfy us demand.

The members in Redis zset (ordered set) are arranged in an orderly manner. It is the same as the set set in that each member in the set is of string type and does not allow repetition; the biggest difference between them is that there are The ordered set is ordered, and the set is unordered. This is because each member in the ordered set is associated with a score (score value) of type double (double precision floating point number). Redis realizes the collection through the score. Sort of members.

Redis creates an ordered set with the following command:

sql copy code ZADD key score member [score member ...]

There are three important parameters here,

  • key: Specify a key name;
  • score: score value, used to describe member, it is the key to achieve sorting;
  • member: The member (element) to add.

When the key does not exist, a new sorted set will be created and the score/member (score/member) will be added to the sorted set; when the key exists, but the key is not of type zset, the addition cannot be completed at this time The operation of the member will return an error message at the same time.

In our scenario, the key is the user ip + request uri, and the score is directly expressed in milliseconds of the current time. As for the member, it is not important, and the same value as the score can be used.

What is the current limiting process like?

The whole process is as follows:

  1. First, the user’s request comes in, the user ip and uri form the key, the timestamp is the value, and put it into zset
  2. Update the cache expiration time of the current key. This step is mainly to regularly clean up cold data, which is different from the common error design 2 I mentioned above.
  3. Delete data records outside the window.
  4. Count the total number of records in the current window.
  5. If the number of records is greater than the threshold, an error will be returned directly, otherwise the user request will be processed normally.

Current limiting based on SpringBoot and AOP

This part mainly introduces the specific implementation logic.

Define annotations and processing logic

The first is to define an annotation to facilitate subsequent use of different limit frequencies for different interfaces.

java copy code/**
 * Annotation of interface access frequency, by default only 5 visits per minute
 */
@Documented
@Target(ElementType. METHOD)
@Retention(RetentionPolicy. RUNTIME)
public @interface RequestLimit {
  
    // limit time unit: second (default: one minute)
    long period() default 60;
  
    // number of requests allowed (default: 5)
    long count() default 5;
  
}

In the implementation of logic, we define an aspect function to intercept user requests. The specific implementation process is consistent with the current limiting process described above, mainly involving the operation of redis zset.

javaCopy code
@Aspect
@Component
@Log4j2
public class RequestLimitAspect {

    @Autowired
    RedisTemplate redisTemplate;

    // Cut-off point
    @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("Interface interception: {} request exceeds limit frequency [{} times/{}s], IP is {}", uri, limitCount, period, ip);
            throw new AuroraRuntimeException(ResponseCode. TOO_FREQUENT_VISIT);
        }

        // execute the user request
        return joinPoint. proceed();
    }

}

Using annotations for current limiting control

Here I define an interface class for testing, use the above annotation to complete the current limit, and allow users to visit 3 times per minute.

java copy code @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);
    }
  
}

Then I access the interface on different machines, and I can see that the current limit of different machines is isolated, and each machine can only access three times within a period. expected effect.

yaml copy code 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: Interface interception: /user/test request exceeds limit frequency [3 times/60s], IP is 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 : Interface interception: /user/test request exceeds limit frequency [3 times/60s], IP is 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 : Interface interception: /user/test request exceeds limit frequency [3 times/60s], IP is 192.168 .31.114
2023-05-21 11:24:26.779 ERROR 99636 --- [nio-8080-exec-4] c.v.c.a.annotation.RequestLimitAspect : Interface interception: /user/test request exceeds limit frequency [3 times/60s], IP is 192.168 .31.114
2023-05-21 11:24:29.344 ERROR 99636 --- [nio-8080-exec-5] c.v.c.a.annotation.RequestLimitAspect : Interface interception: /user/test request exceeds limit frequency [3 times/60s], IP is 192.168 .31.114