Aop限流实现解决方案


01、限流

在业务场景中,为了限制某些业务的并发,造成接口的压力,需要增加限流功能。

02、限流的成熟解决方案

  • guava (漏斗算法 + 令牌算法) (单机限流)
  • redis + lua + ip 限流(比较推荐)(分布式限流)
  • nginx 限流 (源头限流)

03、 限流的目的

  • 保护服务的资源泄露
  • 解决服务器的高可压,减少服务器并发

04、安装redis服务

(1)安装redis
wget http://download.redis.io/releases/redis-6.0.6.tar.gz
tar xzf redis-6.0.6.tar.gz
cd redis-6.0.6
make
(2)修改redis.conf
daemonize yes
# bind 127.0.0.1
protected-mode no
requirepass 123456
(3)如果你之前启动过redis服务器,请麻烦一定要先检查,把服务杀掉,在启动
ps -ef | grep redis
kill redispid
(4)然后重启服务,一定指定配置文件启动
./src/redis-server ./redis.conf

05、创建springboot项目整合redis

(1)导入依赖

<?xml version="1.0" encoding="UTF-8"?>

    4.0.0
    com.qbb.limit
    redis-lua-limit
    0.0.1-SNAPSHOT
    redis-lua-limit
    Demo project for Spring Boot

    
        org.springframework.boot
        spring-boot-starter-parent
        2.6.1
         
    
    
        1.8
    
    
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.boot
            spring-boot-starter-aop
        
        
            org.springframework.boot
            spring-boot-starter-data-redis
        
        
            org.projectlombok
            lombok
        
        
            org.springframework.boot
            spring-boot-starter-test
            test
        
        
            com.google.guava
            guava
            31.0.1-jre
        
    

    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
                
                    
                        
                            org.projectlombok
                            lombok
                        
                    
                
            
        
    

(2)修改配置文件

server:
  port:9001
spring:
  redis:
    host: 192.168.137.72
    port: 6379
    database: 0
    lettuce:
      pool:
        max-active: 20
        max-wait: -1
        max-idle: 5
        min-idle: 0
  application:
    name: redis-lua-limit

(3)创建一个Redis配置类

说明一下:为什么要创建一个redis配置类,直接用SpringBoot自动装配的RedisTemplate不行么?
主要原因是:springboot本身在RedisAutoConfiguration里面已经初始化好了RedisTemplate。但是这个RedisTemplate序列化key的时候是以Object的类型进行序列化,所以看到 "\xac\xed\x00\x05t\x00\x14age11111111111111111" 字符串不友好。所以就写一个配置类进行覆盖了。

package com.qbb.limit.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author QiuQiu&LL (个人博客:https://www.cnblogs.com/qbbit)
 * @version 1.0
 * @date 2022-05-16  23:34
 * @Description:
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 1: 开始创建一个redistemplate
        RedisTemplate redisTemplate = new RedisTemplate<>();
        // 2:开始redis连接工厂跪安了
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        // 创建一个json的序列化方式
        GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        // 设置key用string序列化方式
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        // 设置value用jackjson进行处理
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        // hash也要进行修改
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        // 默认调用
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

}

(4)定义限流lua脚本(这个可以使用我下面提供的,也可以在网上直接百度,如果感兴趣也可以自己研究研究)

在resources目录下的lua文件夹下,新建一个iplimite.lua文件,文件内容如下:

-- 为某个接口的请求IP设置计数器,比如:127.0.0.1请求接口
-- KEYS[1] = 127.0.0.1 也就是用户的IP
-- ARGV[1] = 过期时间 30m
-- ARGV[2] = 限制的次数
local limitCount = redis.call('incr',KEYS[1]);
if limitCount == 1 then
    redis.call("expire",KEYS[1],ARGV[2])
end
-- 如果次数还没有过期,并且还在规定的次数内,说明还在请求同一接口
if limitCount > tonumber(ARGV[1]) then
    return false
end

return true

(5)在config包中创建一个LuaConfig的Lua限流脚本配置类

lua配置类主要是去加载lua文件的内容,放到内存中。方便redis去读取和控制。

package com.qbb.limit.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;

/**
 * @author QiuQiu&LL (个人博客:https://www.cnblogs.com/qbbit)
 * @version 1.0
 * @date 2022-05-16  23:45
 * @Description:
 */
@Configuration
public class LuaConfig {
    /**
     * 将lua脚本的内容加载出来放入到DefaultRedisScript
     *
     * @return
     */
    @Bean
    public DefaultRedisScript ipLimitLua() {
        DefaultRedisScript defaultRedisScript = new DefaultRedisScript<>();
        defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/iplimite.lua")));
        defaultRedisScript.setResultType(Boolean.class);
        return defaultRedisScript;
    }
}

(6)自定义一个限流注解(为什么要用注解,两个字:方便)

package com.qbb.limit.aop;

import java.lang.annotation.*;

/**
 * @author QiuQiu&LL (个人博客:https://www.cnblogs.com/qbbit)
 * @version 1.0
 * @date 2022-05-16  23:57
 * @Description:
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccessLimiter {
    // 每timeout限制请求的个数
    int limit() default 10;

    // 时间,单位默认是秒
    int timeout() default 1;
}

(7)创建一个获取用户访问IP的工具类(网上百度的)

package com.qbb.limit.utils;

import javax.servlet.http.HttpServletRequest;

/**
 * @author QiuQiu&LL (个人博客:https://www.cnblogs.com/qbbit)
 * @version 1.0
 * @date 2022-05-17  0:01
 * @Description:
 */
public class RequestUtils {

    public static String getIpAddr(HttpServletRequest request) {
        if (request == null) {
            return "unknown";
        }
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Forwarded-For");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
    }
}

(8)定义核心限流AOP切面类

package com.qbb.limit.core;

import com.google.common.collect.Lists;
import com.qbb.limit.aop.AccessLimiter;
import com.qbb.limit.utils.RequestUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.lang.reflect.Method;

/**
 * @author QiuQiu&LL (个人博客:https://www.cnblogs.com/qbbit)
 * @version 1.0
 * @date 2022-05-17  0:06
 * @Description:
 */
@Component
@Aspect
@Slf4j
public class LimiterAspect {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private DefaultRedisScript ipLimiterLuaScript;
    @Autowired
    private DefaultRedisScript ipLimitLua;

    // 1: 切入点
    @Pointcut("@annotation(com.qbb.limit.aop.AccessLimiter)")
    public void limiterPointcut() {
    }

    @Before("limiterPointcut()")
    public void limiter(JoinPoint joinPoint) {
        log.info("限流进来了.......");
        // 1:获取方法的签名作为key
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String classname = methodSignature.getMethod().getDeclaringClass().getName();
        String packageName = methodSignature.getMethod().getDeclaringClass().getPackage().getName();
        log.info("classname:{},packageName:{}", classname, packageName);
        // 4: 读取方法的注解信息获取限流参数
        AccessLimiter annotation = method.getAnnotation(AccessLimiter.class);
        // 5:获取注解方法名
        String methodNameKey = method.getName();
        // 6:获取服务请求的对象
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = requestAttributes.getRequest();
        HttpServletResponse response = requestAttributes.getResponse();
        String userIp = RequestUtils.getIpAddr(request);
        log.info("用户IP是:.......{}", userIp);
        // 7:通过方法反射获取注解的参数
        Integer limit = annotation.limit();
        Integer timeout = annotation.timeout();
        String redisKey = method + ":" + userIp;
        // 8: 请求lua脚本
        Boolean acquired = stringRedisTemplate.execute(ipLimitLua, Lists.newArrayList(redisKey), limit.toString(), timeout.toString());
        // 如果超过限流限制
        if (!acquired) {
            // 抛出异常,然后让全局异常去处理
            response.setCharacterEncoding("UTF-8");
            response.setContentType("text/html;charset=UTF-8");

            try (PrintWriter writer = response.getWriter();) {
                // 解决报错:getWriter() has already been called for this response] with root cause
                writer.print("

客官你慢点,请稍后在试一试!!!

"); writer.flush(); } catch (Exception ex) { throw new RuntimeException("客官你慢点,请稍后在试一试!!!"); } } } }

(9)编写测试代码

package com.qbb.limit.controller;

import com.qbb.limit.aop.AccessLimiter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author QiuQiu&LL (个人博客:https://www.cnblogs.com/qbbit)
 * @version 1.0
 * @date 2022-05-17  0:16
 * @Description:
 */
@RestController
public class HelloController {
    @GetMapping("/hello")
    @AccessLimiter(timeout = 1, limit = 3) // 1秒钟超过3次限流
    public String index() {
        // 分布锁
        return "success";
    }

    @GetMapping("/hello2")
    public String index2() {
        return "success";
    }
}

访问一次没问题

快速刷新试试