为什么限流
在衡量Web系统性能的指标中,最重要的参数是QPS,它表示系统每秒最多能够处理的请求数量,当存在需要处理的流量(Query数)使得系统即使以极限QPS处理也无法在短时间内处理完所有流量,则称这个段时间内系统处于承压状态。
服务处理能力有限
有时由于硬件资源、服务并发处理能力有限,后端服务必须对访问流量加以限制,以过滤掉超过自身处理能力的访问
大部分系统无法长时间处于高压状态,一旦承压程度超过一定的阈值,系统性能表现会急剧下降,而为了解决这一问题所普遍采用的方案是在系统中实现限流策略,始终保持系统压力处于合理范围从而让系统整体效率最高。
恶意的流量访问
线上服务运行过程中,或多或少都会接收到恶意的流量访问,巨量的DDOS攻击甚至会导致服务连锁式崩塌,限流可以过滤大量恶意请求从而为后端服务提供一定程度的保护。
API服务收费
对于一些Open API服务,例如百度地图开放平台、腾讯AI开放平台、滴滴AI开放平台都提供了基于QPS的计费策略,该计费策略下同样需要超过限制的请求进行限制。
常用的限流算法
1.计数器(固定窗口)算法
介绍
计数器算法是使用计数器在周期内累加访问次数,当达到设定的限流值时,触发限流策略。下一个周期开始时,进行清零,重新计数。
假设1min内服务器的负载能力为100,因此一个周期的访问量限制在100,然而在第一个周期的最后5秒和下一个周期的开始5秒时间段内,分别涌入100的访问量,虽然没有超过每个周期的限制量,但是整体上10秒内已达到200的访问量,已远远超过服务器的负载能力,由此可见,计数器算法方式限流对于周期比较长的限流,存在很大的弊端。
实现
引入pom依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.0</version>
</dependency>
<!--引入日志依赖 抽象层 与 实现层-->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.21</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.21</version>
</dependency>
java代码如下:
@Slf4j
public class FixedWindowLimiter {
//本地缓存,以时间戳为key,以原子类计数器为value
private LoadingCache<Long, AtomicLong> counter =
CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.build(new CacheLoader<Long, AtomicLong>() {
@Override
public AtomicLong load(Long seconds) throws Exception {
return new AtomicLong(0);
}
});
private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);
//设置限流阈值为15
private long limit = 15;
/**
* 固定时间窗口
* 每隔5s,计算时间窗口内的请求数量,判断是否超出限流阈值
*/
private void fixWindow() {
scheduledExecutorService.scheduleWithFixedDelay(() -> {
try {
// time windows 5 s
long time = System.currentTimeMillis() / 5000;
//每秒发送随机数量的请求
int reqs = (int) (Math.random() * 5) + 1;
long num = counter.get(time).addAndGet(reqs);
log.info("time=" + time + ",num=" + num);
if (num > limit) {
log.info("限流了,num=" + num);
}
} catch (Exception e) {
log.error("fixWindow error", e);
} finally {
}
}, 0, 1000, TimeUnit.MILLISECONDS);
}
}
2.滑动窗口算法
介绍
滑动窗口算法是将时间周期分为N个小周期,分别记录每个小周期内访问次数,并且根据时间滑动删除过期的小周期。
当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确,此算法可以很好的解决固定窗口算法的临界问题。
滑动时间算法有一个问题就是在一定范围内,比如 60s 内只能有 10 个请求,当第一秒时就到达了 10 个请求,那么剩下的 59s 只能把所有的请求都给拒绝掉。
实现
我们可以通过借助 Redis来实现,实现在 pom.xml 添加 Jedis 框架的引用,配置如下:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
</dependency>
java代码如下:
import redis.clients.jedis.Jedis;
public class RedisLimit {
// Redis 操作客户端
static Jedis jedis = new Jedis("127.0.0.1", 6379);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 15; i++) {
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("正常执行请求:" + i);
} else {
System.out.println("被限流:" + i);
}
}
// 休眠 4s
Thread.sleep(4000);
// 超过最大执行时间之后,再从发起请求
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("休眠后,正常执行请求");
} else {
System.out.println("休眠后,被限流");
}
}
/**
* 限流方法(滑动时间算法)
* @param key 限流标识
* @param period 限流时间范围(单位:秒)
* @param maxCount 最大运行访问次数
* @return
*/
private static boolean isPeriodLimiting(String key, int period, int maxCount) {
long nowTs = System.currentTimeMillis(); // 当前时间戳
// 删除非时间段内的请求数据(清除老访问数据,比如 period=60 时,标识清除 60s 以前的请求记录)
jedis.zremrangeByScore(key, 0, nowTs - period * 1000);
long currCount = jedis.zcard(key); // 当前请求次数
if (currCount >= maxCount) {
// 超过最大请求次数,执行限流
return false;
}
// 未达到最大请求数,正常执行业务
jedis.zadd(key, nowTs, "" + nowTs); // 请求记录 +1
return true;
}
}
3 .漏桶算法
介绍
漏桶算法是访问请求到达时直接放入漏桶,如当前容量已达到上限(限流值),则进行丢弃(触发限流策略)。漏桶以固定的速率进行释放访问请求(即请求通过),直到漏桶为空。
漏桶算法的实现步骤是,先声明一个队列用来保存请求,这个队列相当于漏斗,当队列容量满了之后就放弃新来的请求,然后重新声明一个线程定期从任务队列中获取一个或多个任务进行执行,这样就实现了漏桶算法。
实现
public class LeakyBucketLimiter {
private int capacity;
private int rate;
private volatile int water = 0;
private volatile long lastTime = 0L;
private Lock lock = new ReentrantLock();
public LeakyBucketLimiter(int rate) {
this.rate = rate;
this.capacity = rate;
}
public boolean tryAcquire() {
try {
lock.lock();
long now = System.currentTimeMillis();
// 匀速漏出
int outWater = Math.round((now - lastTime) / 1000L * rate);
if (outWater > 0) {
lastTime = now;
}
water = Math.max(0, water - outWater);
if (water < capacity) {
water++;
return true;
}
} finally {
lock.unlock();
}
return false;
}
}
4 .令牌桶算法
介绍
令牌桶算法是程序以r(r=时间周期/限流值)的速度向令牌桶中增加令牌,直到令牌桶满,请求到达时向令牌桶请求令牌,如获取到令牌则通过请求,否则触发限流策略。
实现
我们可以使用 Google 开源的 guava 包,很方便的实现令牌桶算法,首先在 pom.xml 添加 guava 引用,配置如下:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
java代码如下:
import com.google.common.util.concurrent.RateLimiter;
import java.time.Instant;
/**
* Guava 实现限流
*/
public class RateLimiterExample {
public static void main(String[] args) {
// 每秒产生 10 个令牌(每 100 ms 产生一个)
RateLimiter rt = RateLimiter.create(10);
for (int i = 0; i < 11; i++) {
new Thread(() -> {
// 获取 1 个令牌
rt.acquire();
System.out.println("正常执行方法,ts:" + Instant.now());
}).start();
}
}
}