基于Redis实现常见的四种限流方式
前言
https://blog.csdn.net/lucky_morning/article/details/121619047
本片文章将使用redis中间件结合Spring框架的AOP加自定义注解来无侵入式的实现这四种常见的限流方式。在生产环境中,出于种种考虑,比如服务器并发性能、防止恶意访问等等,我们期望某些接口在单位时间内至多被访问N次,这个时候我们就要使用到限流算法来实现。
一、项目创建,引入相关依赖和配置
1、引入必要Maven依赖
使用idea直接new一个SpringBoot项目,引入spring-web、redis、aop依赖
<!-- Spring AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.5.6</version>
</dependency>
<!-- Redis 相关 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
2、基本配置
新建application.yml配置文件,配置如下
spring:
redis:
host: localhost
port: 6379
password:
application:
name: request-limit-aop
server:
port: 8001
servlet:
context-path: /api/request-limit
二、自定义注解结合AOP的实现
1、新建注解接口类 【RequestLimit】
/**
* <p>
* 限流注解
* </p>
*
* @author wangchen
* @since 2021/11/23
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Order(Ordered.HIGHEST_PRECEDENCE)
public @interface RequestLimit {
/**
* 限流类型 ,具体见枚举类 com.cn.lucky.morning.limit.enmus.RequestLimitType
*/
RequestLimitType type() default RequestLimitType.FIXED_WINDOW;
/**
* 限流访问数
*/
int limitCount() default 100;
/**
* 限流时间段
*/
long time() default 60;
/**
* 限流时间段 时间单位
*/
TimeUnit unit() default TimeUnit.SECONDS;
/**
* 漏出或者生成令牌时间间隔,单位 毫秒 (当type为TOKEN、LEAKY_BUCKET时生效)
*/
long period() default 1000;
/**
* 每次生成令牌数或者漏出水滴数 (当type为TOKEN、LEAKY_BUCKET时生效)
*/
int limitPeriodCount() default 10;
}
2、新建AOP切面类 【RequestLimitAop】
一般限流我们是在接口调用之前进行拦截判断,所以aop里面我们只使用前置切点,AOP详细的注解解析,各位看官就自行了解哦,我这里默认大家都明白Spring注解AOP的基本上实现,所以就不再赘述了
/**
* <p>
* 限流AOP
* </p>
*
* @author wangchen
* @since 2021/11/23
*/
@Aspect
@Component
public class RequestLimitAop {
private static final Logger LOGGER = LoggerFactory.getLogger(RequestLimitAop.class);
/**
* 切入点
*/
@Pointcut(value = "@annotation(com.cn.lucky.morning.limit.annotation.RequestLimit)")
public void aspect() {
// 切入点方法
}
/**
* 前置切点
*
* @param joinPoint 切入点
*/
@Before("aspect()")
public void doBefore(JoinPoint joinPoint) {
LOGGER.info("-------------------------------doBefore begin------------------------------------");
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();
RequestLimit limit = targetMethod.getAnnotation(RequestLimit.class);
LOGGER.info("方法【{}】限流方式:【{}】", signature.getName(), limit.type().getValue());
LOGGER.info("-------------------------------doBefore end------------------------------------");
}
}
3、新增Controller类【LimitController】,自定义注解测试AOP拦截效果
/**
* <p>
* 测试限流控制器
* </p>
*
* @author wangchen
* @since 2021/11/23
*/
@RestController
@RequestMapping
public class LimitController {
/**
* 测试AOP拦截
*
* @return 返回结果
*/
@RequestLimit
@GetMapping("/aop-test")
public String aopTest() {
return "测试AOP拦截效果";
}
}
4、调用接口测试
调用接口地址:http://localhost:8001/api/request-limit/aop-test
三、固定窗口算法
1、算法说明
固定窗口算法算是四种算法中最简单,最暴力的算法了,在单位时间的开始进行计数,当访问次数超过阈值,后续的所有访问均被丢弃,直到下一个单位时间开始,计数被置为0。我们使用RedisTemplate中的原子整型RedisAtomicInteger来实现
2、算法缺陷
假如我们设置一分钟只能访问N次,如果有一个用户在疯狂的恶意请求接口,在一分钟的第一秒疯狂请求,那这一分钟的接口访问额度将会在这一秒被耗光,导致其他正常的用户无法访问到接口
3、代码实现
我们使用Spring中的RedisTemplate的原子整型来实现该功能,在接口第一次触发时,设置增加键值,并将键的有效期设置为单位时间,后续接口访问调用getAndIncrement()方法获取当前值,并将redis中值增加1,然后判断当前值是否超过访问阈值,如果超过则直接丢弃请求。该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码
/**
* FixedWindowRequestLimitServiceImpl
* 固定窗口 限流
*
* @author wangchen
* @group com.cn.lucky.morning.limit.service.impl
* @date 2021/11/24 16:17
*/
@Service
public class FixedWindowRequestLimitServiceImpl implements RequestLimitService {
private static final Logger LOGGER = LoggerFactory.getLogger(FixedWindowRequestLimitServiceImpl.class);
@Autowired
private RedisConnectionFactory factory;
@Override
public boolean checkRequestLimit(RequestLimitDTO dto) {
String key = RedisKeyConstant.RequestLimit.QPS_FIXED_WINDOW + dto.getKey();
RequestLimit limit = dto.getLimit();
RedisAtomicInteger atomicCount = new RedisAtomicInteger(key, factory);
int count = atomicCount.getAndIncrement();
if (count == 0) {
atomicCount.expire(limit.time(), limit.unit());
}
LOGGER.debug("限流配置:{} {} 内允许访问 {} 次", limit.time(), limit.unit(), limit.limitCount());
LOGGER.debug("访问时间【{}】", LocalTime.now());
// 检测是否到达限流值
if (count >= limit.limitCount()) {
String msg = "【" + key + "】限流控制," + limit.time() + " " + limit.unit().name() + "内只允许访问 " + limit.limitCount() + " 次";
LOGGER.debug(msg);
return true;
} else {
LOGGER.debug("未达到限流值,放行 {}/{}", count, limit.limitCount());
return false;
}
}
@Override
public RequestLimitType getType() {
return RequestLimitType.FIXED_WINDOW;
}
}
4、在Controller类中增加接口,使用自定义注解,类型为固定窗口
我们配置接口为5秒内最多调用2次
/**
* 测试AOP拦截 固定窗口限流
*
* @return 返回结果
*/
@RequestLimit(type = RequestLimitType.FIXED_WINDOW, limitCount = 2, time = 5)
@GetMapping("/aop-fixed-window-test")
public String aopFixedWindowTest() {
return "固定窗口限流 - 接口返回";
}
5、调用接口测试
接口调用地址:http://localhost:8001/api/request-limit/aop-fixed-window-test
四、滑动窗口算法
1、算法说明
滑动窗口算法我们以每次接口调用的当前时间,回推单位时间,计算这段时间接口调用的数量是否超出阈值,未超出则放行,超出则丢弃。在计算时,我们每次都以当前调用时间往回推单位时间,就像是一个固定长度的窗口一直在顺着时间滑动,所以叫做滑动窗口。我们这里使用redis的zset来实现,当每一次请求进来的时候,value保持唯一,可以用UUID生成,而score可以用当前时间戳表示,因为redis提供了range方法可以用score来计算区间值数量。
2、算法缺陷
随着访问接口的增多,zset里面堆积的数据将会越来越多
3、代码实现
该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码
/**
* <p>
* 滑动窗口 限流
* </p>
*
* @author wangchen
* @since 2021/11/25
*/
@Service
public class SlideWindowRequestLimitServiceImpl implements RequestLimitService {
private static final Logger LOGGER = LoggerFactory.getLogger(SlideWindowRequestLimitServiceImpl.class);
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public boolean checkRequestLimit(RequestLimitDTO dto) {
String key = RedisKeyConstant.RequestLimit.QPS_SLIDE_WINDOW + dto.getKey();
RequestLimit limit = dto.getLimit();
long current = System.currentTimeMillis();
long duringTime = limit.unit().toMillis(limit.time());
Long count = redisTemplate.opsForZSet().count(key, current - duringTime, current);
// 清除有效期外的数据
redisTemplate.opsForZSet().removeRangeByScore(key, 0, current - duringTime - 1f);
LOGGER.info("限流配置:{} {} 内允许访问 {} 次", limit.time(), limit.unit(), limit.limitCount());
LOGGER.info("访问时间【{}】", LocalTime.now());
// 检测是否到达限流值
if (count != null && count >= limit.limitCount()) {
String msg = "【" + key + "】限流控制," + limit.time() + " " + limit.unit().name() + "内只允许访问 " + limit.limitCount() + " 次";
LOGGER.info(msg);
return true;
} else {
redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), System.currentTimeMillis());
LOGGER.info("未达到限流值,放行 {}/{}", count, limit.limitCount());
return false;
}
}
@Override
public RequestLimitType getType() {
return RequestLimitType.SLIDE_WINDOW;
}
}
4、在Controller类中增加接口,使用自定义注解,类型为滑动窗口
我们将限流配置设置5秒内最多调用2次
/**
* 测试AOP拦截 滑动窗口 限流
*
* @return 返回结果
*/
@RequestLimit(type = RequestLimitType.SLIDE_WINDOW, limitCount = 2, time = 5)
@GetMapping("/aop-slide-window-test")
public String aopSlideWindowTest() {
return "滑动窗口限流 - 接口返回";
}
5、调用接口测试
五、令牌桶算法
1、算法说明
令牌桶算法为我们以一个恒定的速度向一个桶内存放令牌,我们调用接口时从令牌桶内取令牌,如果能取到令牌,那么接口就放行, 如果没有取到令牌,则表示接口调用到达阈值,进行拦截。这里我们使用redis的list来实现,我们使用leftPush和rightPop即左进右出来对list数据进行操作,因为redis的这两个指令是具有原子性的,所以也不存在多线程下限流判断异常的问题
2、代码实现
该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码
/**
* TokenRequestLimitServiceImpl
* 令牌桶 限流
*
* @author wangchen
* @group com.cn.lucky.morning.limit.service.impl
* @date 2021/11/26 12:15
*/
@Service
public class TokenRequestLimitServiceImpl implements RequestLimitService {
private static final Logger LOGGER = LoggerFactory.getLogger(TokenRequestLimitServiceImpl.class);
@javax.annotation.Resource(name = "tokenPushThreadPoolScheduler")
private ThreadPoolTaskScheduler scheduler;
@Value("${request-limit.scan-package:}")
private String scanPackage;
@Autowired
private ResourcePatternResolver resourcePatternResolver;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public boolean checkRequestLimit(RequestLimitDTO dto) {
Object pop = redisTemplate.opsForList().rightPop(RedisKeyConstant.RequestLimit.QPS_TOKEN + dto.getKey());
RequestLimit limit = dto.getLimit();
LOGGER.info("限流配置:每 {} 毫秒 生成 {} 个令牌,最大令牌数:{}", limit.period(), limit.limitPeriodCount(), limit.limitCount());
if (pop == null) {
LOGGER.info("【{}】限流控制,令牌桶中不存在令牌,请求被拦截", dto.getKey());
return true;
} else {
LOGGER.info("【{}】令牌桶存在令牌,未达到限流值,放行", dto.getKey());
return false;
}
}
@Override
public RequestLimitType getType() {
return RequestLimitType.TOKEN;
}
/**
* 定速生成令牌
*/
@PostConstruct
public void pushToken() {
// 扫描出所有使用了自定义注解并且限流类型为令牌算法的方法信息
List<RequestLimitDTO> list = this.getTokenLimitList(resourcePatternResolver, RequestLimitType.TOKEN, scanPackage);
if (list.isEmpty()) {
LOGGER.info("未扫描到使用 令牌限流 注解的方法,结束生成令牌线程");
return;
}
// 每个接口方法更具注解配置信息提交定时任务,生成令牌进令牌桶
list.forEach(limit -> scheduler.scheduleAtFixedRate(() -> {
String key = RedisKeyConstant.RequestLimit.QPS_TOKEN + limit.getKey();
Long tokenSize = redisTemplate.opsForList().size(key);
int size = tokenSize == null ? 0 : tokenSize.intValue();
if (size >= limit.getLimit().limitCount()) {
LOGGER.info("【{}】令牌数量已达最大值【{}】,丢弃新生成令牌", key, size);
return;
}
// 判断添加令牌数量
int addSize = Math.min(limit.getLimit().limitPeriodCount(), limit.getLimit().limitCount() - size);
List<String> addList = new ArrayList<>(addSize);
for (int index = 0; index < addSize; index++) {
addList.add(UUID.randomUUID().toString());
}
redisTemplate.opsForList().leftPushAll(key, addList);
LOGGER.info("【{}】生成令牌丢入令牌桶,当前令牌数:{},令牌桶容量:{}", key, size + addSize, limit.getLimit().limitCount());
}, limit.getLimit().period()));
}
}
3、在Controller类中增加接口,使用自定义注解,类型为令牌桶算法
我们配置为令牌桶容量为2,每三秒钟生成一个令牌
/**
* 测试AOP拦截 令牌桶 限流
*
* @return 返回结果
*/
@RequestLimit(type = RequestLimitType.TOKEN, limitCount = 2, limitPeriodCount = 1, period = 3000)
@GetMapping("/aop-token-test")
public String aopTokenTest() {
return "【" + RequestLimitType.TOKEN.getValue() + "】限流 - 接口返回";
}
4、调用接口测试
六、漏桶算法
1、算法说明
漏桶算法刚好和令牌桶算法相逆,漏桶算法为以恒定的速度从桶中漏出水滴,而我们调用接口为向桶中加水滴,当桶中容量未满时,则表示接口调用量未达到阈值,放行,若桶中容量已满,无法增加水滴进入,则表示拦截请求
2、代码实现
该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码
/**
* LeakyBucketRequestLimitServiceImpl
* 漏桶算法 限流
*
* @author wangchen
* @group com.cn.lucky.morning.limit.service.impl
* @date 2021/11/26 17:49
*/
@Service
public class LeakyBucketRequestLimitServiceImpl implements RequestLimitService {
private static final Logger LOGGER = LoggerFactory.getLogger(LeakyBucketRequestLimitServiceImpl.class);
@javax.annotation.Resource(name = "leakyBucketPopThreadPoolScheduler")
private ThreadPoolTaskScheduler scheduler;
@Value("${request-limit.scan-package:}")
private String scanPackage;
@Autowired
private ResourcePatternResolver resourcePatternResolver;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public boolean checkRequestLimit(RequestLimitDTO dto) {
String key = RedisKeyConstant.RequestLimit.QPS_LEAKY_BUCKET + dto.getKey();
Long size = redisTemplate.opsForList().size(key);
if (size != null && size >= dto.getLimit().limitCount()) {
LOGGER.info("【{}】限流控制,漏桶中容量已满,请求被拦截", dto.getKey());
return true;
} else {
LOGGER.info("【{}】漏桶中容量未满,放行,当前水滴容量:{},漏桶容量:{}", dto.getKey(), size, dto.getLimit().limitCount());
redisTemplate.opsForList().leftPush(key, UUID.randomUUID().toString());
return false;
}
}
/**
* 定数流出令牌
*/
@PostConstruct
public void popToken() {
List<RequestLimitDTO> list = this.getTokenLimitList(resourcePatternResolver, RequestLimitType.LEAKY_BUCKET, scanPackage);
if (list.isEmpty()) {
LOGGER.info("未扫描到使用 漏桶限流 注解的方法,结束生成令牌线程");
return;
}
list.forEach(limit -> scheduler.scheduleAtFixedRate(() -> {
String key = RedisKeyConstant.RequestLimit.QPS_LEAKY_BUCKET + limit.getKey();
redisTemplate.opsForList().trim(key, limit.getLimit().limitPeriodCount(), -1);
LOGGER.info("【{}】漏出 {} 个水滴", key, limit.getLimit().limitPeriodCount());
}, limit.getLimit().period()));
}
@Override
public RequestLimitType getType() {
return RequestLimitType.LEAKY_BUCKET;
}
}
3、在Controller类中增加接口,使用自定义注解,类型为漏桶算法
我们配置为漏桶容量为2,每隔三秒漏出一滴水
/**
* 测试AOP拦截 漏桶 限流
*
* @return 返回结果
*/
@RequestLimit(type = RequestLimitType.LEAKY_BUCKET, limitCount = 2, limitPeriodCount = 1, period = 3000)
@GetMapping("/aop-leaky-bucket-test")
public String aopLeakyBucketTest() {
return "【" + RequestLimitType.LEAKY_BUCKET.getValue() + "】限流 - 接口返回";
}