SpringBoot + Redis 实现API接口限流

目录

了解Redis

需求&为什么需要接口限流

实现方案

方案一:固定时间段

思路:

实现:

(一)拦截器

 (二)AOP

缺陷:

方案二:滑动窗口

思路:

实现:


了解Redis

Redis(Remote Dictionary Server)是一个开源的高性能键值对存储数据库。它支持多种数据结构,包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等。Redis的特点包括:

  1. 内存存储:Redis将数据存储在内存中,因此读写速度非常快,适用于对性能有较高要求的场景。

  2. 持久化:Redis支持持久化将内存中的数据保存到硬盘上,以便在服务器重启后能够恢复数据。

  3. 数据结构多样:Redis不仅仅支持简单的键值对存储,还支持丰富的数据结构,例如列表、集合、有序集合等,使其具备更多的功能和用途。

  4. 高并发:Redis是单线程模型,通过使用异步I/O和非阻塞I/O来支持高并发。

  5. 多语言支持:Redis支持多种编程语言的客户端,如Java、Python、C#等,便于开发人员在不同平台上使用。

  6. 发布/订阅:Redis支持发布/订阅模式,允许客户端订阅一个或多个频道并接收对应频道的消息。

  7. 事务支持:Redis支持事务,可以在一个事务中执行多个命令,并保证这些命令的原子性。

由于Redis具有高性能、灵活的数据结构和丰富的功能,它被广泛用于缓存、消息队列、计数器、实时排行榜、会话管理等多种应用场景。

需求&为什么需要接口限流

需求:针对相同IP,60s的接口请求次数不能超过10000次

接口限流是为了保护系统和服务,防止因为过多的请求而导致系统过载、性能下降甚至崩溃。以下是进行接口限流的几个主要原因:

  1. 防止恶意攻击:接口限流可以防止恶意用户或者攻击者通过大量的请求来攻击系统,保护系统的稳定性和安全性。

  2. 保护系统资源:对于一些计算密集型或者资源消耗较大的接口,限制请求的频率可以避免服务器资源被过度消耗,保障其他正常请求的处理。

  3. 避免雪崩效应:当某个服务不可用或者响应时间过长时,如果没有限流措施,大量请求可能会涌入后端,导致更多的请求失败,产生雪崩效应。

  4. 提升系统性能:限流可以控制并发请求数,避免过多的请求导致服务器负载过高,从而提升系统的整体性能和响应速度。

  5. 提供公平资源分配:通过限流,可以实现对不同用户或者不同服务请求的公平分配,避免某些请求占用过多资源而影响其他请求。

综上所述,进行接口限流是保护系统和提升性能的重要手段,对于高并发的系统尤为重要。通过合理设置限流策略,可以有效地平衡资源利用和系统稳定性,提供更好的用户体验。

实现方案

方案一:固定时间段

思路:

当用户在第一次访问该接口时,向Redis中设置一个包含了用户IP和接口方法名的key,value的值初始化为1(表示第一次访问当前接口),同时设置该key的过期时间(60秒),只要此Redis的key没有过期,每次访问都将value的值自增1次,用户每次访问接口前,先从Redis中拿到当前接口访问次数,如果发现访问次数大于规定的次数(超过10000次),则向用户返回接口访问失败的标识。

实现:

(一)拦截器

1、添加Redis依赖:首先在pom.xml文件中添加Spring Data Redis依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2、 配置Redis连接信息:在application.propertiesapplication.yml中配置Redis的连接信息,包括主机、端口、密码等。

3、创建限流拦截器:在项目中创建一个限流拦截器,用于对用户IP进行接口限流。拦截器可以实现HandlerInterceptor接口,并重写preHandle方法进行限流逻辑。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;

public class RateLimitInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String ipAddress = getIpAddress(request);
        String uri = request.getRequestURI().replace("/","_");
        String key = "apiVisits:" + uri + ":" + ipAddress;

        // 判断是否已经达到限流次数
        String value = redisTemplate.opsForValue().get(key);
        // key 不存在,则是第一次请求设置过期时间
        if(StringUtils.isBlank(value)){
            redisTemplate.opsForValue().increment(key, 1);
            redisTemplate.expire(key, time, TimeUnit.SECONDS);
            return true;
        }
        if (value != null && Integer.parseInt(value) > 10) {
            response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
            return false;
        }

        // 未达到限流次数,自增
        redisTemplate.opsForValue().increment(key, 1);
        return true;
    }

    private String getIpAddress(HttpServletRequest request) {
        // 从请求头或代理头中获取真实IP地址
        String ipAddress = request.getHeader("X-Forwarded-For");
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getRemoteAddr();
        }
        return ipAddress;
    }
}

4、注册拦截器:在配置类中注册自定义的限流拦截器。

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**");
    }
}

 (二)AOP

以注解+切面的方式实现,将需要进行限流的API加上注解即可

1、创建注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentLimiting {
    
    /**
     * 缓存key
     */
    String key() default "apiVisits:";

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

    /**
     * 限流次数
     */
    int count() default 10;
}

2、创建AOP切面

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class CurrentLimitingAspect {

    private final RedisTemplate redisTemplate;

    /**
     * 带有注解的方法之前执行
     */
    @SuppressWarnings("unchecked")
    @Before("@annotation(currentLimiting)")
    public void doBefore(JoinPoint point, CurrentLimiting currentLimiting) throws Throwable {
        int time = currentLimiting.time();
        int count = currentLimiting.count();
        // 将接口方法和用户IP构建Redis的key
        String key = getCurrentLimitingKey(currentLimiting.key(), point);
        
        // 判断是否已经达到限流次数
        String value = redisTemplate.opsForValue().get(key);
        if (value != null && Integer.parseInt(value) > count) {
            log.error("接口限流,key:{},count:{},currentCount:{}", key, count, value);
            throw new RuntimeException("访问过于频繁,请稍后再试!");
        }
        // 未达到限流次数,自增
        redisTemplate.opsForValue().increment(key, 1);
        // key 不存在,则是第一次请求设置过期时间
        if(StringUtils.isBlank(value)){
            redisTemplate.expire(key, time, TimeUnit.SECONDS);
        }
    }

    /**
     * 组装 redis 的 key
     */
    private String getCurrentLimitingKey(String prefixKey,JoinPoint point) {
        StringBuilder sb = new StringBuilder(prefixKey);
        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();
        return sb.append("_").append( targetClass.getName() )
                .append("_").append(method.getName()).toString();
    }
}

缺陷:

当在10:00访问接口,这个时候向Reids写入一条数据访问次数为1,在10:59的时候突然访问了9999次,然后redis过期,在11:00访问了9999次,这样出现的问题就是在10:59到11:00之间访问了9999+9999次。故以固定时间段的方式进行限流可能会不起作用,会存在Reids过期的临界点内造成大量的用户访问。

方案二:滑动窗口

思路:

由于方案一的时间是固定的,我们可以把固定的时间段改成动态的,也就是在用户每次访问接口时,记录当前用户访问的时间点(时间戳),并计算前一分钟内用户访问该接口的总次数。如果总次数大于限流次数,则不允许用户访问该接口。这样就能保证在任意时刻用户的访问次数不会超过10000次。

实现:

1、创建注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentLimiting {
    
    /**
     * 缓存key
     */
    String key() default "apiVisits:";

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

    /**
     * 限流次数
     */
    int count() default 10;
}

2、创建AOP切面

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class CurrentLimitingAspect {

    private final RedisTemplate redisTemplate;

    /**
     * 带有注解的方法之前执行
     */
    @SuppressWarnings("unchecked")
    @Before("@annotation(currentLimiting)")
    public void doBefore(JoinPoint point, CurrentLimiting currentLimiting) throws Throwable {
        int time = currentLimiting.time();
        int count = currentLimiting.count();
        // 将接口方法和用户IP构建Redis的key
        String key = getCurrentLimitingKey(currentLimiting.key(), point);
        
        // 使用Zset的 score 设置成用户访问接口的时间戳
        ZSetOperations zSetOperations = redisTemplate.opsForZSet();

        // 当前时间戳
        long currentTime = System.currentTimeMillis();
        zSetOperations.add(key, currentTime, currentTime);

        // 设置过期时间防止key不消失
        redisTemplate.expire(key, time, TimeUnit.SECONDS);

        // 移除 time 秒之前的访问记录,动态时间段
        zSetOperations.removeRangeByScore(key, 0, currentTime - time * 1000);
        
        // 获得当前时间窗口内的访问记录数
        Long currentCount = zSetOperations.zCard(key);
        // 限流判断
        if (currentCount > count) {
            log.error("接口限流,key:{},count:{},currentCount:{}", key, count, currentCount);
            throw new RuntimeException("访问过于频繁,请稍后再试!");
        }
    }

    /**
     * 组装 redis 的 key
     */
    private String getCurrentLimitingKey(String prefixKey,JoinPoint point) {
        StringBuilder sb = new StringBuilder(prefixKey);
        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();
        return sb.append("_").append( targetClass.getName() )
                .append("_").append(method.getName()).toString();
    }
}

猜你喜欢

转载自blog.csdn.net/weixin_43820024/article/details/131912773