SpringBoot限流
常见的限流算法:
- 令牌桶:允许突发流量,控制平均速率。
- 漏桶:平滑流量,控制输出速率。
- 计数桶:简单的固定时间窗口限流,但存在临界问题。
- 滑动窗口:改进了固定窗口的临界问题,更精确地控制流量。
接口限流的常见实现方式:
- Redis+Lua
- Guava
算法原理
令牌桶
原理:
- 令牌桶算法通过在固定速率下向桶中添加令牌来控制请求流量。每次请求需要从桶中获取一个令牌,如果令牌足够,请求通过;否则,拒绝请求。
- 桶有一个最大容量,超出容量的令牌会被丢弃。请求的速率限制由令牌生成的速率决定,而短期内可以处理的最大请求量由桶的容量决定。
特点:
- 允许突发流量,当有足够的令牌时,可以处理短时间内的高峰请求。
- 控制的是平均请求速率,同时允许一定的突发请求。
适用场景:
- 适用于需要控制请求速率的场景,同时允许在短时间内处理较多请求。
漏桶
原理:
- 漏桶算法可以想象成一个固定容量的桶,水(请求)以任意速度流入桶中,但水只能以固定的速率流出桶外。超出桶容量的请求会被丢弃。
- 请求以固定的速率处理,不允许突发请求。
特点:
- 可以将突发请求平滑化,输出流量是固定速率的。
- 保证请求流量的稳定性,不允许突发流量。
适用场景:
- 适用于严格要求流量平稳的场景,如带宽控制。
计数桶
原理:
- 计数桶算法在固定时间窗口内统计请求的次数。如果在当前时间窗口内的请求次数超过预设的阈值,后续的请求会被拒绝。
- 这个算法实现相对简单,但在时间窗口边界处可能出现瞬时的流量高峰问题。
特点:
- 简单易实现,但会出现临界问题:在窗口边界处容易出现“瞬时双倍”流量(即两个时间窗口切换时,流量峰值可能达到两倍于阈值的情况)。
适用场景:
- 适用于简单的流量控制场景,但不适合对流量平稳性有严格要求的情况。
滑动窗口
原理:
- 滑动窗口算法改进了固定窗口算法的临界问题,通过将时间窗口进一步细分为多个小的时间片段。每个时间片段内的请求次数被单独计数,总 的请求次数是当前时间窗口内所有时间片段的总和。
- 窗口随着时间滑动,这样可以更精确地控制请求速率。
特点:
- 相较于固定窗口算法,更精确地控制请求速率,减小了临界问题。
- 实现复杂度较高,需要更多的存储和计算资源。
适用场景:
- 适用于需要精确控制流量且需要避免流量突发的场景,如API网关限流。
下面是在分布式和单机场景下的注解实现方式:
Redis+Lua(分布式)
定义限流注解:
/**
* @author houyunfei
* Redis+lua实现接口限流
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface RedisLimit {
/**
* 资源的key,唯一
* 作用:不同的接口,不同的流量控制
*/
String key() default "";
/**
* 最多的访问限制次数
*/
long permitsPerSecond() default 2;
/**
* 过期时间也可以理解为单位时间,单位秒,默认60
*/
long expire() default 60;
/**
* 得不到令牌的提示语
*/
String msg() default "系统繁忙,请稍后再试.";
}
定义注解的实现类:
@Slf4j
@Aspect
@Component
public class RedisLimitAop {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Pointcut("@annotation(com.yunfei.codegenie.common.annotation.RedisLimit)")
private void check() {
}
private DefaultRedisScript<Long> redisScript;
@PostConstruct
public void init() {
redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Long.class);
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/rateLimiter.lua")));
}
@Before("check()")
public void before(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 拿到RedisLimit注解,如果存在则说明需要限流
RedisLimit redisLimit = method.getAnnotation(RedisLimit.class);
if (redisLimit != null) {
// 获取redis的key
String key = redisLimit.key();
String className = method.getDeclaringClass().getName();
String name = method.getName();
String limitKey = RedisKey.getKey(RedisKey.REDIS_LIMIT_KEY, key, className, name);
if (key == null || key.isEmpty()) {
key = limitKey;
}
long limit = redisLimit.permitsPerSecond();
long expire = redisLimit.expire();
List<String> keys = new ArrayList<>();
keys.add(key);
Long count = stringRedisTemplate.execute(redisScript, keys, String.valueOf(limit), String.valueOf(expire));
log.info("Access try count is {} for key={}", count, key);
ThrowUtils.throwIf(count != null && count == 0, Code.REDIS_LIMIT_ERROR, redisLimit.msg());
}
}
}
对应的lua脚本,放在resources/lua/rateLimiter.lua
:
--获取KEY java中传递的参数
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local currentLimit = tonumber(redis.call('get', key) or "0")
if currentLimit + 1 > limit
then return 0
else
-- 自增长 1
redis.call('INCRBY', key, 1)
-- 设置过期时间
redis.call('EXPIRE', key, ARGV[2])
return currentLimit + 1
end
使用:1s内只允许访问两次
@PostMapping("/demo/queryPage")
@Operation(summary = "分页查询示例数据 @author houyunfei")
@RedisLimit(key = "queryDemoByPage", permitsPerSecond = 2, expire = 1, msg = "系统繁忙,请稍后再试.")
public BaseResponse<PageResult<DemoVo>> queryDemoByPage(@Valid @RequestBody DemoQueryForm demoQueryForm) {
return demoService.queryDemoByPage(demoQueryForm);
}
Guava(单机)
依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.2.1-jre</version>
</dependency>
定义注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Limit {
/**
* 资源的key,唯一
* 作用:不同的接口,不同的 流量控制
*/
String key() default "";
/**
* 最多的访问限制次数
*/
double permitsPerSecond();
/**
* 获取令牌最大等待时间
*/
long timeout();
/**
* 获取令牌最大等待时间,单位(例:分钟/秒/毫秒) 默认:毫秒
*/
TimeUnit timeunit() default TimeUnit.MILLISECONDS;
/**
* 得不到令牌的提示语
*/
String msg() default "系统繁忙,请稍后再试.";
}
定义切面类:
@Slf4j
@Aspect
@Component
public class LimitAop {
/**
* 不同的接口,不同的流量控制
* map的key为 Limiter.key
*/
private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();
@Around("@annotation(com.yunfei.codegenie.common.annotation.Limit)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 拿limit的注解
Limit limit = method.getAnnotation(Limit.class);
if (limit != null) {
// key作用:不同的接口,不同的流量控制
String key = limit.key();
RateLimiter rateLimiter = null;
// 验证缓存是否有命中key
if (!limitMap.containsKey(key)) {
// 创建令牌桶
rateLimiter = RateLimiter.create(limit.permitsPerSecond());
limitMap.put(key, rateLimiter);
log.info("新建了令牌桶={},容量={}", key, limit.permitsPerSecond());
}
rateLimiter = limitMap.get(key);
// 拿令牌
boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());
// 拿不到命令,直接返回异常提示
ThrowUtils.throwIf(!acquire, Code.CURRENT_LIMIT, limit.msg());
}
return joinPoint.proceed();
}
}
使用:每秒发放1个令牌,timeout为0,获取不到令牌立即报错
@PostMapping("/demo/queryPage")
@Operation(summary = "分页查询示例数据 @author houyunfei")
@Limit(key = "queryDemoByPage", permitsPerSecond = 1, timeout = 0, msg = "系统繁忙,请稍后再试.")
public BaseResponse<PageResult<DemoVo>> queryDemoByPage(@Valid @RequestBody DemoQueryForm demoQueryForm) {
return demoService.queryDemoByPage(demoQueryForm);
}