The SpringBoot project uses Redis to limit the interface flow of user IP

1. Ideas

The main purpose of using interface current limiting is to improve the stability of the system and prevent the interface from being maliciously attacked (a large number of requests in a short period of time).

For example, if an interface is required to request no more than 1000 times within 1 minute, how should the code be designed?

Here are two ideas. If you want to see the code, you can directly turn to the following code part.

1.1 Fixed time period (old idea)

1.1.1 Idea description

The idea of ​​this solution is: use Redis to record the number of times a user IP accesses a certain interface within a fixed period of time, where:

  • Redis key : user IP + interface method name

  • Redis value : the current number of interface visits.

When the user visits the interface for the first time in the near future, a key containing the user IP and interface method name is set in Redis, and the value is initialized to 1 (indicating the first visit to the current interface). At the same time, set the expiration time of the key (for example, 60 seconds).

A front-end and back-end separated blog based on Spring Boot + MyBatis Plus + Vue 3.2 + Vite + Element Plus, including a background management system that supports functions such as articles, categories, tag management, and dashboards.

  • GitHub address: https://github.com/weiwosuoai/WeBlog

  • Gitee Address: https://gitee.com/AllenJiang/WeBlog

 
 

After that, as long as the key has not expired, every time the user accesses the interface, the value will be incremented by one time.

Before the user accesses the interface each time, the current interface access times are obtained from Redis. If the number of accesses is found to be greater than the specified number (for example, more than 1000 times), the interface access failure indicator will be returned to the user.

 

1.1.2 Defects in thinking

The disadvantage of this solution is that the current limiting time period is fixed.

For example, if an interface is required to request no more than 1000 times within 1 minute, observe the following process:

 

picture

picture

It can be found that there is only an interval of 2 seconds between 00:59 and 01:01, but the interface has been accessed 1000+999=1999 times, which is twice the number of current limiting times (1000 times)!

Therefore, in this solution, the setting of the number of current limit times may not work, and it may still cause a large number of visits in a short period of time.

1.2 Sliding window (new idea)

1.2.1 Idea description

In order to avoid the increase of short-term visits caused by key expiration in solution 1, we can change our thinking, that is, change the fixed time period to dynamic:

Assume that a certain interface is only allowed to be accessed 5 times within 10 seconds. Every time a user accesses an interface, record the time point (time stamp) of the current user's access, and calculate the total number of times the user accessed the interface in the previous 10 seconds. If the total number of times is greater than the current limit number, the user is not allowed to access the interface. In this way, it can be guaranteed that the number of user visits will not exceed 1000 at any time.

As shown in the figure below, assuming that the user accesses the interface at 0:19, and the number of visits in the first 10 seconds is checked to be 5 times, the visit is allowed.

 

Assuming that the user accesses the interface at 0:20, and the number of visits in the first 10 seconds is 6 (5 times beyond the current limit), this visit is not allowed.

 

1.2.2 Implementation of Redis part

1) Which Redis data structure to choose

The first is to decide which Redis data structure to use. Every time a user visits, a key needs to be used to record the time point of the user's visit, and these time points need to be used for range checking.

2) Why choose zSet data structure

In order to be able to achieve range checking, consider using the zSet ordered collection in Redis.

The command to add a zSet element is as follows:

ZADD [key] [score] [member]

It has a key attribute score, through which the priority of the current member can be recorded.

So we can set the score as the timestamp of the user access interface to facilitate range checking through the score. The key records the user IP and interface method name. As for the setting of the member, it has no effect. A member records the time point when the user accesses the interface. Therefore, member can also be set as a timestamp.

3) How does zSet perform range checking (check the number of accesses in the previous few seconds)

The idea is to delete all members before a specific time interval, and the remaining members are the total number of visits within the time interval. Then count the number of members in the current key.

① Delete all members before a specific time interval.

zSet has the following commands to delete [min~max]members whose score range is between:

Zremrangebyscore [key] [min] [max]

Assuming that the current limit time is set to 5 seconds, and the current system timestamp is obtained when the current user accesses the interface currentTimeMill, then the deleted score range can be set as:

min = 0
max = currentTimeMill - 5 * 1000

It is equivalent to deleting all the members before 5 seconds, leaving only the key within the first 5 seconds.

② Count the number of existing members in a specific key.

zSet has the following commands to count the total number of members of a key:

 ZCARD [key]

The total number of members of the key counted is the number of times the current interface has been accessed. If the number is greater than the current limiting times, it means that the current access should be limited.

2. Code implementation

It is mainly realized in the form of annotation + AOP.

A front-end and back-end separated blog based on Spring Boot + MyBatis Plus + Vue 3.2 + Vite + Element Plus, including a background management system that supports functions such as articles, categories, tag management, and dashboards.

  • GitHub address: https://github.com/weiwosuoai/WeBlog

  • Gitee Address: https://gitee.com/AllenJiang/WeBlog

 
 

2.1 The idea of ​​fixed time period

A lua script is used.

  • Reference: https://blog.csdn.net/qq_43641418/article/details/127764462

2.1.1 Notes on current limiting
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {

    /**
     * 限流时间,单位秒
     */
    int time() default 5;

    /**
     * 限流次数
     */
    int count() default 10;
}
2.1.2 Define lua script

resources/luaCreate a new one under limit.lua:

-- 获取redis键
local key = KEYS[1]
-- 获取第一个参数(次数)
local count = tonumber(ARGV[1])
-- 获取第二个参数(时间)
local time = tonumber(ARGV[2])
-- 获取当前流量
local current = redis.call('get', key);
-- 如果current值存在,且值大于规定的次数,则拒绝放行(直接返回当前流量)
if current and tonumber(current) > count then
    return tonumber(current)
end
-- 如果值小于规定次数,或值不存在,则允许放行,当前流量数+1  (值不存在情况下,可以自增变为1)
current = redis.call('incr', key);
-- 如果是第一次进来,那么开始设置键的过期时间。
if tonumber(current) == 1 then 
    redis.call('expire', key, time);
end
-- 返回当前流量
return tonumber(current)
2.1.3 Inject Lua execution script

The key code is limitScript()the method

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        // 使用Jackson2JsonRedisSerialize 替换默认序列化(默认采用的是JDK序列化)
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        redisTemplate.setKeySerializer(jackson2JsonRedisSerializer);
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        return redisTemplate;
    }


    /**
     * 解析lua脚本的bean
     */
    @Bean("limitScript")
    public DefaultRedisScript<Long> limitScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/limit.lua")));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}
2.1.3 Define Aop aspect class
@Slf4j
@Aspect
@Component
public class RateLimiterAspect {
 @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private RedisScript<Long> limitScript;

 @Before("@annotation(rateLimiter)")
    public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
        int time = rateLimiter.time();
        int count = rateLimiter.count();

        String combineKey = getCombineKey(rateLimiter.type(), point);
        List<String> keys = Collections.singletonList(combineKey);
        try {
            Long number = (Long) redisTemplate.execute(limitScript, keys, count, time);
            // 当前流量number已超过限制,则抛出异常
            if (number == null || number.intValue() > count) {
             throw new RuntimeException("访问过于频繁,请稍后再试");
            }
            log.info("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, number.intValue(), combineKey);
        } catch (Exception ex) {
            ex.printStackTrace();
            throw new RuntimeException("服务器限流异常,请稍候再试");
        }
    }
    
    /**
     * 把用户IP和接口方法名拼接成 redis 的 key
     * @param point 切入点
     * @return 组合key
     */
    private String getCombineKey(JoinPoint point) {
        StringBuilder sb = new StringBuilder("rate_limit:");
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        sb.append( Utils.getIpAddress(request) );
        
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Class<?> targetClass = method.getDeclaringClass();
        // keyPrefix + "-" + class + "-" + method
        return sb.append("-").append( targetClass.getName() )
                .append("-").append(method.getName()).toString();
    }
}

2.2 Sliding window idea

2.2.1 Current Limiting Notes
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {

    /**
     * 限流时间,单位秒
     */
    int time() default 5;

    /**
     * 限流次数
     */
    int count() default 10;
}
2.2.2 Define Aop aspect class
@Slf4j
@Aspect
@Component
public class RateLimiterAspect {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 实现限流(新思路)
     * @param point
     * @param rateLimiter
     * @throws Throwable
     */
    @SuppressWarnings("unchecked")
    @Before("@annotation(rateLimiter)")
    public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
        // 在 {time} 秒内仅允许访问 {count} 次。
        int time = rateLimiter.time();
        int count = rateLimiter.count();
        // 根据用户IP(可选)和接口方法,构造key
        String combineKey = getCombineKey(rateLimiter.type(), point);
        
        // 限流逻辑实现
        ZSetOperations zSetOperations = redisTemplate.opsForZSet();
        // 记录本次访问的时间结点
        long currentMs = System.currentTimeMillis();
        zSetOperations.add(combineKey, currentMs, currentMs);
        // 这一步是为了防止member一直存在于内存中
        redisTemplate.expire(combineKey, time, TimeUnit.SECONDS);
        // 移除{time}秒之前的访问记录(滑动窗口思想)
        zSetOperations.removeRangeByScore(combineKey, 0, currentMs - time * 1000);
        
        // 获得当前窗口内的访问记录数
        Long currCount = zSetOperations.zCard(combineKey);
        // 限流判断
        if (currCount > count) {
            log.error("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, currCount, combineKey);
            throw new RuntimeException("访问过于频繁,请稍后再试!");
        }
    }

    /**
     * 把用户IP和接口方法名拼接成 redis 的 key
     * @param point 切入点
     * @return 组合key
     */
    private String getCombineKey(JoinPoint point) {
        StringBuilder sb = new StringBuilder("rate_limit:");
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        sb.append( Utils.getIpAddress(request) );
        
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Class<?> targetClass = method.getDeclaringClass();
        // keyPrefix + "-" + class + "-" + method
        return sb.append("-").append( targetClass.getName() )
                .append("-").append(method.getName()).toString();
    }
}

Guess you like

Origin blog.csdn.net/qq_21137441/article/details/132144505