效果展示
写了个图表实时修改限流方案及其展示负载量
达到设置的负载上限10之后将会触发控流,直接返回服务器忙。
大体思路:
1.程序启动时扫描所有接口,然后存入数据库,将数据库中的所有接口的控流方案(每秒通过上限)存入redis中
2.使用AOP切入点,切入注解@PostMapping和@GetMapping,在@Before内执行控流逻辑
3.控流逻辑:{
*窗口算法
*令牌桶算法 }
首先应用redis依赖
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
扫描接口,存入每个接口的控流方案存入数据库和redis
SpringBoot提供的一种简单的实现方案就是添加一个model并实现CommandLineRunner接口,实现功能的 代码放在实现的run方法中 也就是项目一启动之后,就立即需要执行的动作
@Slf4j
@Component
public class AddInterFace implements CommandLineRunner {
@Resource private WebApplicationContext applicationContext;
@Resource private RedisTemplate redisTemplate;
/**
* 程序启动时执行,扫描所有接口,并插入数据库和redis
* @param args
* @throws Exception
*/
@Override
public void run(String... args) throws Exception {}
}
在run方法中执行扫描接口
使用applicationContext.getBeansWithAnnotation(RestController.class)方法获取有@RestController的类对象
Map<String, Object> controllers = applicationContext.getBeansWithAnnotation(RestController.class);
其中 key为类名,value为对象
遍历每个controller层,拿到controller层下的url
for (Map.Entry<String, Object> entry : controllers.entrySet()) {
Object value = entry.getValue();
System.out.println("拿到controller:"+entry.getKey()+",拿到value:"+value);
Class<?> aClass = AopUtils.getTargetClass(value);
// System.out.println("拿到Class:"+aClass);
String classAnontationName="";
RequestMapping annotation = aClass.getAnnotation(RequestMapping.class);
if (annotation!=null){
classAnontationName=String.join(",",annotation.value());
}
}
得到@RequestMapping("/userInfo")中的参数:“/userInfo”
获取@PostMapping和@GetMapping中的内容,并将数据库中不存在的接口生成默认限流方案存入数据库
List<Method> declaredMethods = Arrays.asList(aClass.getDeclaredMethods());
for (int i = 0; i < declaredMethods.size() ; i++) {
// 下面开始根据注解类型进行输出统计
GetMapping getMapping = declaredMethods.get(i).getAnnotation(GetMapping.class);
PostMapping postMapping = declaredMethods.get(i).getDeclaredAnnotation(PostMapping.class);
if (getMapping!=null){
String join = String.join(",", getMapping.value());
// System.out.println("Get相关的:"+classAnontationName+ join);
//获取到接口,插入数据库
String name = classAnontationName + join;
FlowCtrl flowCtrl = new FlowCtrl();
flowCtrl.setInterfaceName(name);
flowCtrl.setRequestMethod("GET");
flowCtrl.setTime(0);
flowCtrl.setFlowNum(0);
if (allInterFace.contains(name)) {
//当前接口已在数据库中存在
continue;
}
addList.add(name);
flowCtrlMapper.insert(flowCtrl);
}
if (postMapping!=null){
String join = String.join(",", postMapping.value());
// System.out.println("Post相关的:"+classAnontationName+join);
String name = classAnontationName + join;
// postInterFaceName.add(classAnontationName+join);
FlowCtrl flowCtrl = new FlowCtrl();
flowCtrl.setInterfaceName(name);
flowCtrl.setRequestMethod("POST");
flowCtrl.setTime(0);
flowCtrl.setFlowNum(0);
if (allInterFace.contains(name)) {
//当前接口已在数据库中存在
continue;
}
addList.add(name);
flowCtrlMapper.insert(flowCtrl);
}
}
将数据库中的所有控流方案存入redis
List<FlowCtrl> interFaceListInfo = flowCtrlMapper.selectList(null);
redisTemplate.opsForValue().set("flowCtrls",JSONObject.toJSONString(interFaceListInfo).toString());
这样我们就把所有的接口的控流方案存入数据库和redis中了
使用切面,在接口运行前判断限流
使用aop,切入点为postmapping注解和getmapping注解
@Aspect
@Order(5)
@Slf4j
@Component
public class AspectTest {
@Resource
private RedisTemplate redisTemplate;
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)|| @annotation(org.springframework.web.bind.annotation.GetMapping)")
public void requestTest() {}
}
在@Before中执行限流操作 用于指定。@Before:【advice】的类型,表示该【advice】在切点方法之前执行
拿到当前请求的url
@Before("requestTest()")
public void before(JoinPoint joinPoint){
//获取请求url
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
System.out.println(request.getRequestURI());
String interFaceName =request.getRequestURI();
}
从redis中获取当前接口控流方案
log.info(interFaceName);
Object ctrls = redisTemplate.opsForValue().get("flowCtrls");
List<FlowCtrl> flowCtrls = JSONObject.parseArray(ctrls.toString(), FlowCtrl.class);
if (flowCtrls.isEmpty()){
return;
}
Optional<FlowCtrl> first = flowCtrls.stream().filter(flowCtrl -> flowCtrl.getInterfaceName().equals(interFaceName)).findFirst();
if (first==null){
return;
}
//当前控流方案
FlowCtrl ctrl = first.get();
窗口限流
简而言之就是对单位时间内每次请求做记数,记录的请求数大于控流阈值时就执行控流
比如我们需要在5秒内限定20个请求,那么我们在setnx的时候可以设置过期时间5,当请求的setnx数量达到20时候即达到了限流效果。
代码实现:
Object value = redisTemplate.opsForValue().get(interFaceName);//从redis中获取当前是否有限流存在
Integer flowNum=ctrl.getFlowNum();//限流量
Integer time = ctrl.getTime();//限流时间
String nowTime = DateTimeHelper.formatDateTimetoString(new Date(), DateTimeHelper.FMT_yyyyMMddHHmmss);
log.info("限流:"+time+"分钟 "+flowNum+"次请求");
if (value!=null) {
Integer onlineNum = Integer.valueOf(value.toString());
log.info("有key:"+interFaceName+" value"+onlineNum );
if (onlineNum>=flowNum){
System.out.println("限流");
log.info(nowTime);
throw new IllegalMonitorStateException();//限流,抛出错误
}else {
Long expire = redisTemplate.getExpire(interFaceName, TimeUnit.MILLISECONDS);
redisTemplate.opsForValue().set(interFaceName,onlineNum+1,expire, TimeUnit.MILLISECONDS);
Long thenExpire = redisTemplate.getExpire(interFaceName, TimeUnit.MILLISECONDS);
log.info(nowTime);
log.info("key:"+(onlineNum+1));
}
}else {
log.info("无key");
redisTemplate.opsForValue().set(interFaceName,1,time, TimeUnit.SECONDS);
log.info(nowTime);
}
使用lua脚本
/**
* 使用lua脚本
* flowNum 限流量
* time 限流时限
*/
String luaScript = buildLuaScript();
RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
ImmutableList<String> keys = ImmutableList.of(StringUtils.join(interFaceName+":lua"));
Number count = redisTemplate.execute(redisScript, keys,flowNum, time);
log.info(count+":lua脚本");
if (count!=null&&count.intValue()<=flowNum){
log.info("通过");
}else {
log.info("限流");
throw new IllegalMonitorStateException();
}
/**
*
* @description 编写 redis Lua 限流脚本
* @date 2022/12/1 17:48 马上下班了,lua脚本下次在更新吧T_T
*/
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();
}
令牌桶限流
每隔一段时间生成一定数量的令牌,请求拿到令牌后才能通过,没能拿到令牌的请求过滤掉
- 使用定时任务添加令牌
下面的代码为每十秒运行一次,每次运行是检查令牌是否到上限2,没到上限则添加到上限,到上限则不再添加
@Component
@Slf4j
@EnableScheduling
public class SchduleTast implements SchedulingConfigurer {
@Resource private RedisTemplate redisTemplate;
private String cron="*/10 * * * * ?";
private Integer flowContrlNum=2;
public String getCron() {
return cron;
}
public void setFlowContrlNum(Integer Num){this.flowContrlNum=Num;}
public void setCron(String cron) {
this.cron = cron;
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.addTriggerTask(new Runnable() {
@Override
public void run() {
try {
log.info(DateTimeHelper.formatDateTimetoString(new Date(),DateTimeHelper.FMT_yyyyMMddHHmmss));
log.info("添加令牌");
String key="limit_Test";
// 1S的速率往令牌桶中添加UUID,只为保证唯一性
// redisTemplate.opsForList().rightPush("limit_Test", UUID.randomUUID().toString());
Long tokenSize = redisTemplate.opsForList().size(key);
int size = tokenSize == null ? 0 : tokenSize.intValue();
if (size>=flowContrlNum){
log.info("【{}】令牌数量已达最大值【{}】,丢弃新生成令牌", key, size);
return;
}
// 判断添加令牌数量
int addSize =flowContrlNum-size;
List<String> addList = new ArrayList<>(addSize);
for (int index = 0; index < addSize; index++) {
addList.add(UUID.randomUUID().toString());
}
redisTemplate.opsForList().leftPushAll(key, addList);
log.info("【{}】生成令牌丢入令牌桶,当前令牌数:{},令牌桶容量:{}", key, size + addSize, flowContrlNum);
} catch (Exception e) {
e.printStackTrace();
}
}
}, new Trigger() {
@Override
public Date nextExecutionTime(TriggerContext triggerContext) {
System.out.println("-----------"+cron);
if ("".equals(cron) || cron == null)
return null;
CronTrigger trigger = new CronTrigger(cron);// 定时任务触发,可修改定时任务的执行周期
Date nextExecDate = trigger.nextExecutionTime(triggerContext);
return nextExecDate;
}
});
}
}
- 切面中
获取不到令牌则限流
/**
* 令牌桶算法
*/
Long tokenNum = redisTemplate.opsForList().size("limit_Test");
log.info("令牌数量:"+tokenNum);
if (tokenNum>0){
//通过,并且令牌减少1
redisTemplate.opsForList().leftPop("limit_Test");
}else {
//未获取到令牌,执行限流操作
log.info("限流");
}
总结
当笔记记录一下两种简单实现限流的方案,但选哪种还要结合具体的业务场景。