实践应用:Spring Boot优雅解决分布式限流

分布式限流

单机版中我们了解到 AtomicIntegerRateLimiterSemaphore 这几种解决方案,但它们也仅仅是单机的解决手段,在集群环境下就透心凉了,后面又讲述了 Nginx 的限流手段,可它又属于网关层面的策略之一,并不能解决所有问题。例如供短信接口,你无法保证消费方是否会做好限流控制,所以自己在应用层实现限流还是很有必要的。

具体代码

导入依赖

pom.xml 中添加上 starter-webstarter-aopstarter-data-redis 的依赖即可,习惯了使用 commons-lang3guava 中的一些工具包…

 <dependencies>
        <!-- 默认就内嵌了Tomcat 容器,如需要更换容器也极其简单-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>21.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>

属性配置

在 application.yml资源文件中添加 redis 相关的配置项

server:
  port:8080

spring:
  redis:
    database: 0      # Redis数据库索引(默认为0)
    host: 127.0.0.1  # Redis服务器地址
    port: 6379       # Redis服务器端口
    password:   # Redis服务器连接密码(默认为空)
    timeout: 0 # 连接超时时间(毫秒)
    lettuce:
      pool:
        max-active: 8  # 连接池最大连接数(使用负值表示没有限制)
        max-wait: -1   # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-idle: 8    # 连接池中的最大空闲连接
        min-idle: 0    # 连接池中的最小空闲连接

Limit 注解

创建一个 Limit 注解,不多说注释都给各位写齐全了….

import java.lang.annotation.*;

/**
 * 限流注解
 */

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Limit {
    /**
     * 资源的名字
     *
     * @return String
     */
    String name() default "";
    /**
     * 资源的key
     *
     * @return String
     */
    String key() default "";
    /**
     * Key的prefix
     *
     * @return String
     */
    String prefix() default "";
    /**
     * 给定的时间段
     * 单位秒
     *
     * @return int
     */
    int period();
    /**
     * 最多的访问限制次数
     *
     * @return int
     */
    int count();
    /**
     * 类型
     *
     * @return LimitType
     */
    LimitType limitType() default LimitType.CUSTOMER;
}

LimitType枚举类

public enum LimitType {
    /**
     * 自定义key
     */
    CUSTOMER,
    /**
     * 根据请求者IP
     */
    IP;
}

RedisTemplate

默认情况下 spring-boot-data-redis 为我们提供了StringRedisTemplate 在这里插入代码片但是满足不了其它类型的转换,所以还是得自己去定义其它类型的模板….

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
        // 设置序列化
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(
                Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置redisTemplate
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(lettuceConnectionFactory);
        RedisSerializer<?> stringSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringSerializer);// key序列化
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);// value序列化
        redisTemplate.setHashKeySerializer(stringSerializer);// Hash key序列化
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);// Hash value序列化
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

}

Limit 拦截器(AOP)

熟悉 Redis 的朋友都知道它是线程安全的,我们利用它的特性可以实现分布式锁、分布式限流等组件,在实践应用:Spring Boot轻松搞定重复提交(分布式锁)中讲述了分布式锁的实现,限流相比它稍微复杂一点,官方虽然没有提供相应的API,但却提供了支持 Lua 脚本的功能,我们可以通过编写 Lua 脚本实现自己的API,同时他是满足原子性的….

下面核心就是调用 execute 方法传入我们的 Lua 脚本内容,然后通过返回值判断是否超出我们预期的范围,超出则给出错误提示。

import com.dhx.annotation.Limit;
import com.dhx.annotation.LimitType;
import com.dhx.exception.RrException;
import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

@Aspect
@Configuration
public class LimitInterceptor {
    private static final Logger logger = LoggerFactory.getLogger(LimitInterceptor.class);

    private final RedisTemplate<String, Object> limitRedisTemplate;
    private static final String UNKNOWN = "unknown";


    @Autowired
    public LimitInterceptor(RedisTemplate<String, Object> redisTemplate) {
        this.limitRedisTemplate = redisTemplate;
    }

    @Around("execution(public  * *(..))&&@annotation(com.dhx.annotation.Limit)")
    public Object interceptor(ProceedingJoinPoint pjp) {
        MethodSignature   signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();

        Limit limitAnnotation  = method.getAnnotation(Limit.class);
        LimitType limitType = limitAnnotation.limitType();
        String name = limitAnnotation.name();
        String key;
        int limitPeriod = limitAnnotation.period();
        int limitCount = limitAnnotation.count();
        switch (limitType) {
            case IP:
                key = getIpAddress();
                break;
            case CUSTOMER:
                // TODO 如果此处想根据表达式或者一些规则生成 请看 一起来学Spring Boot | 第二十三篇:轻松搞定重复提交(分布式锁)
                key = limitAnnotation.key();
                break;
            default:
                key = StringUtils.upperCase(method.getName());
        }

        ImmutableList<String> keys = ImmutableList.of(StringUtils.join(limitAnnotation.prefix(), key));
        try {
            String luaScript = buildLuaScript();
            RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
            Long count = limitRedisTemplate.execute(redisScript, keys, limitCount, limitPeriod);
            logger.info("Access try count is {} for name={} and key = {}", count, name, key);
            if (count != null && count.intValue() <= limitCount) {
                return pjp.proceed();
            } else {
                throw new RrException("小同志,你访问的太频繁了");
            }
        } catch (Throwable e) {
            if (e instanceof RrException) {
                throw new RrException(e.getLocalizedMessage());
            }
            throw new RuntimeException("server exception");
        }
    }

    /**
     * 限流 脚本
     *
     * @return lua脚本
     */
    public String buildLuaScript() {
        StringBuilder lua = new StringBuilder();
        lua.append("local c");
        lua.append("\nc = redis.call('get',KEYS[1])");
        // 调用不超过最大值,则直接返回
        lua.append("\nif c and tonumber(c) > tonumber(ARGV[1]) then");
        lua.append("\nreturn c;");
        lua.append("\nend");
        // 执行计算器自加
        lua.append("\nc = redis.call('incr',KEYS[1])");
        lua.append("\nif tonumber(c) == 1 then");
        // 从第一次调用开始限流,设置对应键值的过期
        lua.append("\nredis.call('expire',KEYS[1],ARGV[2])");
        lua.append("\nend");
        lua.append("\nreturn c;");
        return lua.toString();
    }

    public String getIpAddress() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        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("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

控制层

在接口上添加 @Limit() 注解,如下代码会在 Redis 中生成过期时间为 100skey = test 的记录,特意定义了一个 AtomicInteger 用作测试…

@RestController
public class LimiterController {

    private static final AtomicInteger ATOMIC_INTEGER = new AtomicInteger();

    @Limit(key = "test", period = 100, count = 10)
    @GetMapping("/test")
    public int testLimiter() {
        // 意味著 100S 内最多允許訪問10次
        return ATOMIC_INTEGER.incrementAndGet();
    }
}

主函数

就一个普通的不能在普通的主函数类了

@SpringBootApplication
public class SpringBootlication {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootlication.class, args);
    }
}

统一的前端返回格式

/**
 * 页面响应entity
 */
public class Result extends HashMap<String, Object> {

	private static final long serialVersionUID = 1L;

	public Result() {
		put("code", 0);
	}

	public static Result error() {
		return error(500, "未知异常,请联系管理员");
	}

	public static Result error(String msg) {
		return error(500, msg);
	}

	public static Result error(int code, String msg) {
		Result r = new Result();
		r.put("code", code);
		r.put("msg", msg);
		return r;
	}
	public static Result error(Object msg) {
		Result r = new Result();
		r.put("msg", msg);
		return r;
	}
	public static Result ok(Object msg) {
		Result r = new Result();
		r.put("msg", msg);
		return r;
	}


	public static Result ok(Map<String, Object> map) {
		Result r = new Result();
		r.putAll(map);
		return r;
	}

	public static Result ok() {
		return new Result();
	}

	@Override
	public Result put(String key, Object value) {
		super.put(key, value);
		return this;
	}
}

自定义异常

/**
 *  自定义异常
 */
public class RrException extends RuntimeException {
	
	private static final long serialVersionUID = 1L;
	
    private String msg;
    
    private int code = 500;
    
    public RrException(String msg) {
		super(msg);
		this.msg = msg;
	}
	
	public RrException(String msg, Throwable e) {
		super(msg, e);
		this.msg = msg;
	}
	
	public RrException(String msg, int code) {
		super(msg);
		this.msg = msg;
		this.code = code;
	}
	
	public RrException(String msg, int code, Throwable e) {
		super(msg, e);
		this.msg = msg;
		this.code = code;
	}

	public String getMsg() {
		return msg;
	}

	public void setMsg(String msg) {
		this.msg = msg;
	}

	public int getCode() {
		return code;
	}

	public void setCode(int code) {
		this.code = code;
	}
}

统一的异常处理方式

可以看我的这篇文章实践应用:Spring Boot项目传参校验最佳实践

/**
 *  异常处理器
 */
@RestControllerAdvice
public class RrExceptionHandler {
	
	private Logger logger = LoggerFactory.getLogger(getClass());

	/**
	 * 自定义异常
	 */
	@ExceptionHandler(RrException.class)
	public Result handleRRException(RrException e){
		Result r = new Result();
		r.put("code", e.getCode());
		r.put("msg", e.getMessage());
		return r;
	}

	@ExceptionHandler(DuplicateKeyException.class)
	public Result handleDuplicateKeyException(DuplicateKeyException e){
		logger.error(e.getMessage(), e);
		return Result.error("数据库中已存在该记录");
	}

	@ExceptionHandler(Exception.class)
	public Result handleException(Exception e){
		logger.error(e.getMessage(), e);
		return Result.error();
	}
}

测试

在这里插入图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值