分布式高并发系统常见的用来保护系统的三把利器:缓存、降级、限流。
有天深夜发现公司的点评后台系统数据库cpu打到96%以上,排查发现是有人使用脚本恶意访问咱们的系统。
正常情况下,调用量每秒1次左右,但是根据监控系统发现恶意请求访问的接口每秒调用20次左右,并且调用的接口是慢接口,导致cpu使用飙升。为了保护系统,除了缓存和降级外,我们采用限流来针对这种恶意请求做限制,保证正常用户的使用,抵御恶意请求。
限流
什么是限流呢?限流是限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则通过拒绝服务的方式保证整体系统的可用性。
根据限流作用范围,可以分为单机限流和分布式限流;
根据限流方式,又分为计数器、滑动窗口、漏桶限令牌桶限流
计数器
计数器是一种最简单限流算法,其原理就是:在一段时间间隔内,对请求进行计数,与阀值进行比较判断是否需要限流,一旦到了时间临界点,将计数器清零。
比如在1秒钟内对请求限制为50次
限流逻辑:
1.在程序中设置一个变量 count,当来一个请求我就将这个数 +1,同时记录请求时间。
2.当下一个请求来的时候判断 count 的计数值是否超过设定的频次50,以及当前请求的时间和第一次请求时间是否在 1 秒钟内。
3.如果在 1 秒钟内并且超过设定的频次则证明请求过多,后面的请求就拒绝掉。
4.如果该请求与第一个请求的间隔时间大于计数周期,且 count 值还在限流范围内,就重置 count。
不足:边界情况处理,假设有个用户在第 1秒内的最后几毫秒瞬间发送 40 个请求,当 1 秒结束后 counter 清零了,他在下一秒的前几毫秒时候又发送 40个请求。相当于在连续的1秒内不止发送了50个请求,但是我们的限流没限制住。
滑动窗口
滑动窗口是针对计数器存在的临界点缺陷,所谓滑动窗口(Sliding window)是一种流量控制技术,这个词出现在 TCP 协议中。滑动窗口把固定时间片进行划分,并且随着时间的流逝,进行移动,固定数量的可以移动的格子,进行计数并判断阀值。
限流逻辑:
1.其实计数器就是滑动窗口,只不过只有一个窗格而已。
2.想让限流做的更精确只需要划分更多的窗格就可以了,为了更精确我们也不知道到底该设置多少个格子。
3.格子的数量影响着滑动窗口算法的精度,依然有时间片的概念,无法根本解决临界点问题。
不足:无法根本解决临界点问题。
漏桶
漏桶算法(Leaky Bucket),原理就是一个固定容量的漏桶,按照固定速率流出水滴。
用过水龙头都知道,打开龙头开关水就会流下滴到水桶里,而漏桶指的是水桶下面有个漏洞可以出水,如果水龙头开的特别大那么水流速就会过大,这样就可能导致水桶的水满了然后溢出。
漏桶算法有以下特点::
1.漏桶具有固定容量,出水速率是固定常量(流出请求)
2.如果桶是空的,则不需流出水滴
3.可以以任意速率流入水滴到漏桶(流入请求)
4.如果流入水滴超出了桶的容量,则流入的水滴溢出(新请求被拒绝)
不足:漏桶限制的是常量流出速率(即流出速率是一个固定常量值),所以最大的速率就是出水的速率,不能出现突发流量。
令牌桶
令牌桶算法(Token Bucket)是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。
漏桶算法有以下特点::
1.令牌按固定的速率被放入令牌桶中
2.桶中最多存放 B 个令牌,当桶满时,新添加的令牌被丢弃或拒绝
3.如果桶中的令牌不足 N 个,则不会删除令牌,且请求将被限流(丢弃或阻塞等待)
我们有一个固定的桶,桶里存放着令牌(token)。一开始桶是空的,系统按固定的时间(rate)往桶里添加令牌,直到桶里的令牌数满,多余的请求会被丢弃。当请求来的时候,从桶里移除一个令牌,如果桶是空的则拒绝请求或者阻塞。
允许一定程度突发流量,是比较好的限流算法
实现
上面介绍了限流算法,下面介绍几种常见限流算法的使用
基于redis的计数器限流
定义限流注解
@Inherited
@Documented
@Target({ElementType.FIELD, ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimit {
/**
* 指定时间范围 请求次数
*/
int maxCount() default 50;
/**
* 请求次数的指定时间范围 秒数(redis数据过期时间)
*/
int second() default 1;
}
限流切面
@Slf4j
@Aspect
@Component
public class AccessLimitAspect {
@ApolloJsonValue("${app.service.limit.teacherId:[]}")
private List teacherIds;
@Autowired
private RedissonClient redissonClient;
@Autowired
private HttpServletRequest httpServletRequest;
@SneakyThrows
@Around("@annotation(accessLimit)")
public Object doLimit(ProceedingJoinPoint proceedingJoinPoint, AccessLimit accessLimit) {
String employeeId = httpServletRequest.getHeader("employeeId");
log.info("employeeId:{}", employeeId);
Assert.notNull(employeeId, "employeeId must not be null!");
if (teacherIds.contains(employeeId)) {
log.info("request limit start, employeeId {}", employeeId);
// 获取注解内容信息
int seconds = accessLimit.second();
int maxCount = accessLimit.maxCount();
// 存储key
String key = employeeId;
// 已经访问的次数
Integer count = redissonClient.get(key);
log.info("已经访问的次数:{}", count);
if (null == count || -1 == count) {
redissonClient.set(key, 1, seconds, TimeUnit.SECONDS);
}
if (count < maxCount) {
redissonClient.increment(key);
}
if (count >= maxCount) {
log.warn("请求过于频繁请稍后再试");
return null;
}
log.info("request limit end, employeeId {}", employeeId);
}
return proceedingJoinPoint.proceed();
}
}
基于redis的令牌桶限流
定义限流注解
@Inherited
@Documented
@Target({ElementType.FIELD, ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimit {
}
限流切面
@Slf4j
@Aspect
@Component
public class AccessLimitAspect {
@ApolloJsonValue("${app.service.limit.teacherId:[]}")
private List teacherIds;
@Autowired
private RedissonClient redissonClient;
@Autowired
private HttpServletRequest httpServletRequest;
@SneakyThrows
@Around("@annotation(accessLimit)")
public Object doLimit(ProceedingJoinPoint proceedingJoinPoint, AccessLimit accessLimit) {
String employeeId = httpServletRequest.getHeader("employeeId");
log.info("employeeId:{}", employeeId);
Assert.notNull(employeeId, "employeeId must not be null!");
if (teacherIds.contains(employeeId)) {
log.info("request limit start, employeeId {}", employeeId);
//使用redisson限流器,每秒限制50次请求
RRateLimiter limiter = redissonClient.getRateLimiter("limit_teacher");
limiter.trySetRate(RateType.OVERALL, 50, 1, RateIntervalUnit.SECONDS);
limiter.acquire();
log.info("request limit end, employeeId {}", employeeId);
}
return proceedingJoinPoint.proceed();
}
@Around("@within(cn.tinman.clouds.jojoread.admin.limit.AccessLimit)")
public Object limit(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
//方法上的降级注解优先于类上的
AccessLimit limit = AnnotationUtils.findAnnotation(signature.getMethod(), AccessLimit.class);
if (Objects.isNull(limit)) {
limit = AnnotationUtils.findAnnotation(joinPoint.getTarget().getClass(), AccessLimit.class);
}
Assert.notNull(limit, "@AccessLimit must not be null!");
return doLimit(joinPoint, limit);
}
}
基于Guava 的令牌桶限流(单机)
限制 QPS 为 2,也就是每隔 500ms 生成一个令牌
RateLimiter rateLimiter = RateLimiter.create(2);
for (int i = 0; i < 10; i++) {
String time = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME);
System.out.println(time + ":" + rateLimiter.tryAcquire());
Thread.sleep(250);
}
程序每隔 250ms 获取一次令牌,所以两次获取中只有一次会成功。
18:19:06.797557:true
18:19:07.061419:false
18:19:07.316283:true
18:19:07.566746:false
18:19:07.817035:true
18:19:08.072483:false
总结
限流主要应用场景有:
- 电商系统(特别是6.18、双11、双12等)中的秒杀活动,使用限流防止使用软件恶意刷单;
- 基础api接口限流:例如天气信息获取,IP对应城市接口,百度、腾讯等对外提供的基础接口,都是通过限流来实现免费与付费直接的转换。
- 系统广泛调用的api接口,严重消耗网络、内存等资源,需要合理限流。
除了针对服务器进行限流,我们也可以对容器进行限流,比如 Tomcat、Nginx 等限流手段。
- Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;
- Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。
限流算法
redis实现限流
guava的令牌桶限流