目录
一、高并发系统三大利器
二、限流算法
2.1 固定窗口算法(Fixed Window)
2.2 滑动窗口算法(Sliding Window)
2.3 漏桶算法(Leaky Bucket)
2.4 令牌桶算法(Token Bucket)
2.5 漏桶和令牌桶算法的对比
三、限流算法应用场景
3.1 Google Guava
3.2 SpringCloud Gateway
3.3 Alibab Sentinel
3.4 Nginx限流模块
保护高并发系统的三大利器:限流、熔断降级、缓存。
限流是指在面临瞬时巨大流量访问系统时(商品秒杀等)为了保证系统的可用性的一个限制手段。
熔断降级一般一起使用,是为了在某些大流量业务场景(双11双12等)下保证核心业务可用的解决方案。(降级是主动的,熔断是被动的。熔断是指当上游服务调用下游服务出现不可用时,暂时切断请求,防止系统雪崩;降级是指某些情况下保证核心业务,将边缘业务服务暂时关闭。)
缓存是为了缓解数据库的查询压力,对某些热点数据和核心业务数据添加缓存层进行访问,高并发系统常使用Redis作为缓存层。(做数据冗余,以空间换时间)
对于一个微服务架构,要考虑的是服务内调用和对外提供服务两个方面。
服务内调用可以使用线程池进行资源隔离,对调用方进行限流,服务降级等方案保证服务的可用。(例如:Hystrix熔断器不仅可以满足熔断降级,也可以设置调用时的线程数作为资源隔离和服务内限流的解决方案)
对外提供服务可以使用应用网关或者全局网关对系统整体进行限流。(例如:SpringGateway作应用网关通过Filter过滤器做一些限流操作;Nginx作为全局网关和反向代理服务器对应用网关进行负载均衡和限流)
对于微服务架构,整体的限流要安排在全局网关,这里全局网关可以使用Sentinel、Nginx、Kong等。其底层实现实际是基于限流算法。
固定窗口算法也称为计数器算法,就是通过维护一个单位时间内(比如每秒)的计数值,每当一个请求通过时,就将计数值加1,当计数值超过预先设定的阈值时,就拒绝单位时间内的其他请求。如果单位时间已经结束,则将计数器清零,开启下一轮的计数。
比如每秒限制100个请求,Java代码如下:
// 固定窗口算法Java代码实现
public class FixedWindow {
private long time = new Date().getTime();
private Integer count = 0; // 计数器
private final Integer max = 100; // 请求阈值
private final Integer interval = 1000; // 窗口大小(固定窗格)
public boolean trafficMonitoring() {
long nowTime = new Date().getTime();
if (nowTime < time + interval) {
// 在时间窗口内
count++;
return max > count;
} else {
time = nowTime; // 开启新的窗口
count = 1; // 初始化计数器,这个请求属于当前新开的窗口
return true;
}
}
}
但这里出现了一个问题,我们设定1秒内允许通过的请求阈值是100,如果有用户在时间窗口的最后几毫秒发送了100个请求,紧接着又在下一个时间窗口开始时发送了100个请 求,那么这个用户其实在一秒内成功请求了200次,显然超过了阈值但并不会被限流,其实这就是流量突刺问题,那么临界值问题要怎么解决呢?
计数器滑动窗口法就是为了解决上述固定窗口计数存在的问题而诞生,滑动窗口是基于时间来划分窗口的。(相当于对固定窗口进行了划分)
前面说了固定窗口存在临界值问题,要解决这种临界值问题,显然只用一个窗口是解决不了问题的。假设我们仍然设定1秒内允许通过的请求是200个,但是在 这里我们需要把1秒的时间分成多格,假设分成5格(格数越多,流量过渡越平滑),每格窗口的时间大小是200毫秒,每过200毫秒,就将窗口向前移动一格。
滑动窗口限流法其实就是计数器固定窗口算法的一个变种。流量的过渡是否平滑依赖于我们设置的窗口格数也就是统计时间间隔,格数越多,统计越精确。但是窗口分隔越多,维护时间窗口的消耗也越大,并不能很好的解决流量突刺的问题。
为了更好的解决流量突刺问题,可以使用漏桶算法。漏桶算法这个名字就很形象,算法内部有一个容器,类似生活用到的漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。
因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求, 没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满 了,那么新进来的请求就丢弃。
在算法实现方面,可以准备一个队列,用来保存请求,另外通过一个线程池定期从队列中获取请求并执行,可以一次性获取多个并发执行。
但这种算法也存在缺陷:无法应对短时间的突发流量。
令牌桶算法是对漏桶算法的一种改进,桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率(令牌产生的速度是固定的)的同时还允许一定程度的突发调用。
在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。
令牌桶算法会按照一定的速率生成令牌放入令牌桶,访问要进入系统时,需要 从令牌桶获取令牌,有令牌的可以进入,没有的被抛弃。由于令牌桶的令牌是源源不断生成的,当访问量小时,可以留存令牌达到令牌桶的上限,这样当短时间 的突发访问量来时,积累的令牌数可以处理这个问题。当访问量持续大量流入 时,由于生成令牌的速率是固定的,最后也就变成了类似漏斗算法的固定流量处理。
漏桶 | 令牌桶 | |
算法实现 | 固定速率流出请求,流入请求达到桶容量时拒绝新的请求。 | 固定速率添加令牌,处理请求需要看桶中的令牌数量,数量为0拒绝新的请求。 |
突发访问 | 不允许,只支持平滑访问 | 允许一定程度的突发流量 |
限流算法在多个框架中都有实现,这里介绍几种常用的框架和代码实现。目前主流的限流算法就是漏桶和令牌桶。
Google Guava 类似 Apache Commons 工具集。Guava工程包含了若干被Google的 Java项目广泛依赖 的核心库,例如:集合 [collections] 、缓存 [caching] 、原生类型支持 [primitives support] 、并发库 [concurrency libraries] 、通用注解 [common annotations] 、字符串处理 [string processing] 、I/O 等等。
Guava提供了一个限流工具RateLimiter。
RateLimiter使用的是令牌桶算法,RateLimiter会按照一定的 频率往桶里扔令牌,线程拿到令牌才能执行,比如你希望自己的应用程序QPS不 要超过1000,那么RateLimiter设置1000的速率后,就会每秒往桶里扔1000个 令牌,RateLimiter经常用于限制对一些物理资源或者逻辑资源的访问速率。
Java代码实现:
// 模拟RateLimiter限流
public class TestRateLimiter {
public static void main(String[] args) {
// 每秒2个许可
RateLimiter rateLimiter = RateLimiter.create(2.0);
List tasks = new ArrayList();
for (int i = 0; i < 10; i++) {
tasks.add(new UserRequest(i));
}
ExecutorService threadPool = Executors.newCachedThreadPool();
for (Runnable runnable : tasks) {
System.out.println("等待时间:" + rateLimiter.acquire());
threadPool.execute(runnable);
}
}
// 模拟用户请求
private static class UserRequest implements Runnable {
private int id;
public UserRequest(int id) {
this.id = id;
}
@Override
public void run() {
System.out.println("userQuestID:"+id);
}
}
}
这里要注意和Semaphore(信号量) 做一个区分。Semaphore 限制了并发访问的数量而不是使用速率。
Spring cloud gateway官方提供了基于令牌桶的限流算法,基于内部的过滤器工厂RequestRateLimiterGatewayFilterFactory实现。 目前RequestRateLimiterGatewayFilterFactory的实现依赖于 Redis,所以我们 还要引入Redis的相关依赖。接下来看一下具体的实现步骤。
application.yml:
spring:
application:
name: gateway-limiter
cloud:
gateway:
routes:
- id: limit_route
uri: http://127.0.0.1:80/get
# 谓词:当且仅当请求时的时间After配置的时间时,才会转发到用户微服务
predicates:
- After=2021-02-26T00:00:00+08:00[Asia/Shanghai]
filters:
- name: RequestRateLimiter
args:
key-resolver: '#{@userKeyResolver}'
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 300
redis:
host: localhost
port: 6379
database: 0
在上面的配置文件,配置了 redis的信息,并配置了RequestRateLimiter的限流 过滤器,该过滤器需要配置三个参数:
配置类KeyResolverConfiguration.java:(这里用到了响应式编程中的Mono)
// 限流配置类
@Configuration
public class KeyResolverConfiguration {
/**
* 接口限流:
* 获取请求地址的uri作为限流key。
*/
@Bean
public KeyResolver pathKeyResolver() {
return new KeyResolver() {
@Override
public Mono resolve(ServerWebExchange exchange) {
return Mono.just(exchange.getRequest().getPath().toString());
}
};
}
/**
* 用户限流:
* 获取请求用户id作为限流key。
*/
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("userId"));
}
/**
* IP限流:
* 获取请求用户ip作为限流key。
*/
@Bean
public KeyResolver hostAddrKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
}
}
Sentinel是阿里开源的限流框架,可以替代SpringCLoud Gateway作为应用网关来使用。其内部实现了服务限流和熔断降级,也可以替代Hystrix,并且提供了Dashboard仪表盘Web界面。
Sentinel无须复杂的配置,只需要整合现有框架在Dashboard仪表盘中对相应的资源进行限流。
Sentinel 中有两个关键的概念:
资源:它可以是 Java 应用程序中的任何内容,例如,由应用程序提供的服务,或 由应用程序调用的其它应⽤提供的服务,甚至可以是⼀段代码。我们请求 的API接口就是资源。
规则:围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。
Sentinel的限流有两个控制维度:QPS和线程数。
Sentinel中处理流程是一个责任链,不同功能的逻辑抽象成不同的ProcessorSlot组合在一起,比如有限流的FlowSlot、打日志的LogSot、数据统计的StatisticSlot。重点看限流的com.alibaba.csp.sentinel.slots.block.flow.FlowSlot
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
boolean prioritized, Object... args) throws Throwable {
// 是否触发限流检查
checkFlow(resourceWrapper, context, node, count, prioritized);
// 继续往下一个节点走
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
public void checkFlow(Function> ruleProvider, ResourceWrapper resource,
Context context, DefaultNode node, int count, boolean prioritized) throws BlockException {
Collection rules = ruleProvider.apply(resource.getName());
for (FlowRule rule : rules) { // 多个限流规则检查
if (!canPassCheck(rule, context, node, count, prioritized)) {
throw new FlowException(rule.getLimitApp(), rule);
}
}
}
// canPassCheck -> passLocalCheck
private static boolean passLocalCheck(FlowRule rule, Context context, DefaultNode node, int acquireCount,
boolean prioritized) {
return rule.getRater().canPass(selectedNode, acquireCount, prioritized);
}
canPass校验中是对限流规则的校验。分别有几个实现类:
当系统长期处于空闲的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮,比如电商网站的秒杀模块。通过 Warm Up 模式(预热模式),让通过的流量缓慢增加,经过设置的预热时间 以后,到达系统处理请求速率的设定值。 Warm Up 模式默认会从设置的 QPS 阈值的 1/3 开始慢慢往上增加至QPS 设置值。
具体的实现过程就不再分析了, 其原理就是前面所介绍的算法原理。
Nginx众所周知是模块化的,便于功能的丰富和拓展,其中的限流功能也是对应相应的模块。
Nginx 提供两种限流方式,一是控制速率,二是控制并发连接数。类似于Sentinel中的QPS和线程数。
至于限流模块的使用童鞋们可以百度或者查看官方文档:
ngx_http_limit_req_module
ngx_http_limit_conn_module