记录折腾的那点事
在折腾的道路上永不止步

Springboot2.x + aop + Lua分布式接口限流实践

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();  } 

压测验证

Springboot2.x + aop + Lua分布式接口限流实践

57.3/157.3 = 36.42% ,符合预期结果。

Springboot2.x + aop + Lua分布式接口限流实践

赞(0)
未经允许不得转载:ghMa » Springboot2.x + aop + Lua分布式接口限流实践
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址