Springboot2.x + AOP + Lua分布式接口限流实践
问题的起源
在直播服务中,提供了两个接口给C端,通过学生ID查询当前的直播列表。为了减少请求数据库的竞争,我们对着此接口进行限流,超过流量的请求被直接切断对数据库的调用,返回异常提示。
限流技术在平台中也是异常重要的一个措施,尤其是对网关的调用。我知道的有以下几种比较好实现方式:
- 阿里开源限流神器Sentinel
- 可以采用令牌桶的方法,实现方式是Guava RateLimiter,简单有效,在结合统一配置中心(apollo),可以动态调整限流阈值。
以上是一些成熟的方案,但是实现它需要额外的服务器,在资源有限的情况下,我们考虑使用redis lua脚本的方式来实现接口限流,虽然此种方式缺点很明显,不能动态调整限流的阈值,也没有管理和监控页面,在不久的将来可能会被成熟的方案替换,但是以下代码的实现思路还是非常不错的。
定义接口限流注解
其中name和value互为别名,所以需要使用 AnnotationUtils.findAnnotation 去获取注解,这样 @AliasFor 注解才能发挥作用(原理是AOP)
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface RequestLimit { @AliasFor("name")
String value() default ""; @AliasFor("value")
String name() default ""; /** * 允许访问的最大次数 */ int count() default Integer.MAX_VALUE; /** * 时间段,单位为毫秒,默认值1秒 */ long time() default 1000;
}
切面通知实现类
优先使用注解的name(value)作为限流接口的KEY,如果name为空,则获取当前方法的字符串来作为限流接口的KEY(不建议,导致KEY长度过长)
@Slf4j @Aspect @Component @DependsOn("redisScript") @ConditionalOnClass({DefaultRedisScript.class, StringRedisTemplate.class})
public class RequestLimitAspect { @Autowired private DefaultRedisScript<Boolean> redisScript; @Autowired private StringRedisTemplate stringRedisTemplate; @Pointcut("@annotation(com.gaodun.storm.vod.common.annotation.RequestLimit)")
public void pointcut() {
} @Before("pointcut()")
public void doBefore(JoinPoint joinPoint) { try { // 获取封装了署名信息的对象,在该对象中可以获取到目标方法名,所属类的Class等信息 Signature signature = joinPoint.getSignature(); //拦截的方法名称 String methodName = signature.getName(); //拦截的放参数类型 Class[] parameterTypes = ((MethodSignature) signature).getMethod().getParameterTypes(); Method method = joinPoint.getSignature().getDeclaringType().getMethod(methodName, parameterTypes); // 必须要用AnnotationUtils,才能获取到 name 和 value上@AliasFor(互为别名)的作用 // AOP原理 RequestLimit requestLimit = AnnotationUtils.findAnnotation(method, RequestLimit.class); if (Objects.isNull(requestLimit)) { return; } String name = requestLimit.name(); if (StrUtil.isBlank(name)) { // 一个描述此方法的字符串 name = method.toGenericString(); } String key = CacheConstants.getRequestLimitKey(name); if (log.isDebugEnabled()) { log.debug("限流接口的KEY:[{}]", key); } Boolean allow = stringRedisTemplate.execute( redisScript, Collections.singletonList(key), String.valueOf(requestLimit.count()), //limit String.valueOf(requestLimit.time())); //expire if (Objects.equals(Boolean.FALSE, allow)) { throw new BusinessException(StatusCode.REQUEST_EXCEED_LIMIT); } } catch (NoSuchMethodException e) { log.error("{}", e.getMessage(), e); } } }
redis之lua脚本
lua脚本
local key = KEYS[1]
local value = 1
local limit = tonumber(ARGV[1])
local expire = ARGV[2]
if redis.call("SET", key, value, "NX", "PX", expire) then return 1 else if redis.call("INCR", key) <= limit then return 1 end if redis.call("TTL", key) == -1 then redis.call("PEXPIRE", key, expire)
end
end
return 0
当你使用阿里云集群版Redis的情况下,执行此脚本会出现:
Exception:Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: ERR bad lua script for redis cluster, all the keys that the script uses should be passed using the KEYS array, and KEYS should not be in expression
详细见Lua脚本支持与限制
修改lua脚本如下:
local value = 1
local limit = tonumber(ARGV[1])
local expire = ARGV[2]
if redis.call("SET", KEYS[1], value, "NX", "PX", expire) then return 1 else if redis.call("INCR", KEYS[1]) <= limit then return 1 end if redis.call("TTL", KEYS[1]) == -1 then redis.call("PEXPIRE", KEYS[1], expire)
end
end
return 0
使用DefaultRedisScript加载lua脚本
应该在应用上下文中配置一个DefaultRedisScript 的单例,避免在每个脚本执行的时候重复创建脚本的SHA1
@Bean("redisScript") public DefaultRedisScript<Boolean> redisScript() {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("/META-INF/scripts/request_limit.lua")));
redisScript.setResultType(Boolean.class); return redisScript;
}
验证RequestLimit注解是否配置正确
通过以上方式,你会发现当我在接口上使用RequestLimit注解时,设置相同的name(你有可能会主动检查,设置不同),那么使用同样name的接口共用了一个KEY,这个不是我们想要的。为了避免这个问题,在项目启动时验证RequestLimit注解是否配置正确,具体实现如下:
@Slf4j @Configuration public class RequestLimitConfiguration implements ApplicationContextAware { @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { // 验证RequestLimit注解是否配置正确 if (Objects.nonNull(applicationContext)) { Set<String> requestLimitSet = new HashSet<>(); String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames(); for (String beanDefinitionName : beanDefinitionNames) { Object bean = applicationContext.getBean(beanDefinitionName); Method[] methods = bean.getClass().getDeclaredMethods(); for (Method method : methods) { RequestLimit requestLimit = AnnotationUtils.findAnnotation(method, RequestLimit.class); if (Objects.isNull(requestLimit)) { continue; } String name = requestLimit.name(); if (StrUtil.isBlank(name)) { continue; } if (requestLimitSet.contains(name)) { throw new RuntimeException("request-limit[" + name + "] naming conflicts."); } else { requestLimitSet.add(name); log.info("Generating unique request-limit operation named: {}", name); } } } } } }
那么配置相同的name,启动项目报错如下:
at org.springframework.boot.devtools.restart.RestartLauncher.run(RestartLauncher.java:49) [spring-boot-devtools-2.1.2.RELEASE.jar:2.1.2.RELEASE] Caused by: java.lang.RuntimeException: request-limit[b] naming conflicts. at com.gaodun.storm.vod.common.config.RequestLimitConfiguration.setApplicationContext(RequestLimitConfiguration.java:47)
注解的使用
@ApiOperation(value = "分页获取已结束列表") @RequestLimit(name = "f", count = 100) // 接口限量,qps=100,这个参数目前是拍脑袋设置的 @GetMapping("/xxx/xxx") public BusinessResponse<Void> finished() { return BusinessResponse.ok(); }
压测验证
57.3/157.3 = 36.42% ,符合预期结果。
最新评论
mat插件可以检测内存数据
标识接口?
序列化serializabel就是一个标识
就差一个MAC了
mark
除了预置sql查询字段,其他我竟然都没用过
可以,这个问题遇到过
mybatis多个参数: 1. 注解(最常用) 2. 转化为对象或MAP 3. 按顺序(这个最蠢,写的代码看得费劲) 单个参数需要注意得: 1.基本数据类型随便写 2.数组用array,l