一、限流的概述
1.1 为什么要进行限流?
很多做服务接口的人或多或少的遇到这样的场景,由于业务应用系统的负载能力有限,为了防止非预期的请求对系统压力过大而拖垮业务应用系统。 也就是面对大流量时,服务接口的流量控制策略:分流、降级、限流等。
1.瞬时流量过高,服务被压垮?
2.恶意用户高频光顾,导致服务器宕机?
3.消息消费过快,导致数据库压力过大,性能下降甚至崩溃?
4.网页爬虫 ,对于这些情况我们需要对用户的访问进行限流访问
1.2 什么是限流?
限流是对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机。
在高并发系统中,出于系统保护角度考虑,通常会对流量进行限流。
在分布式系统中,高并发场景下,为了防止系统因突然的流量激增而导致的崩溃,同时保证服务的高可用性和稳定性,限流是最常用的手段。
1.3 限流和熔断有什么区别?
限流发生在流量进来之前,超过的流量进行限制。
熔断是一种应对故障的机制,发生在流量进来之后,如果系统发生故障或者异常,熔断会自动切断请求,防止故障进一步扩展,导致服务雪崩。
1.4 限流和削峰有什么区别?
削峰是对流量的平滑处理,通过缓慢地增加请求的处理速率来避免系统瞬时过载,大概就是水库,把流量储存起来,慢慢流。
限流大概就是闸口,拒绝超出的流量。
1.5 有哪些限流算法?
常见的四种限流算法,分别是:固定窗口算法、滑动窗口算法、漏桶算法、令牌桶算法。
常见的限流算法作用:
- 固定窗口算法:在固定时间窗口内限制请求数量。
- 滑动日志算法:使用滑动窗口实现更平滑的限流。
- 漏桶算法:控制数据流入速率,平滑突发流量。
- 令牌桶算法:允许一定程度的突发流量,但限制平均速率。
1.6 典型的限流场景
- API 网关限流:在微服务架构中,API 网关通常是系统对外的唯一入口,需要限制单个用户或IP在一定时间内的请求次数,以保护后端服务不受恶意请求或突发流量的冲击。
- 分布式系统中的服务限流:在分布式系统中,各个服务之间可能会有调用关系,通过限流可以控制服务间的调用频率,避免服务间因为调用过于频繁而造成的服务过载。
- 微服务间的接口限流:微服务架构中,服务间通过接口进行通信,对接口进行限流可以保证服务之间的通信不会因为过量的请求而变得不可用。
- 营销活动限流:在开展营销活动时,为了防止活动页面被恶意刷票或访问量过大而崩溃,需要对接口进行限流,确保活动能够在可控的范围内进行。
- 用户高频操作限流:对于用户频繁操作的接口,如登录、发帖、评论等,需要限制用户在短时间内完成的操作次数,防止用户恶意操作或频繁操作导致系统资源耗尽。
- 秒杀活动限流:在秒杀活动中,为了防止用户在短时间内提交过多的秒杀请求,导致系统无法处理,需要对参与秒杀的接口进行限流。
- 后端服务保护限流:对于一些敏感操作或计算密集型的后端服务,通过限流可以避免因请求过多而使得服务响应变慢或崩溃。
- 防止分布式拒绝服务攻击:在遭受分布式拒绝服务(DDoS)攻击时,限流策略能够有效地减轻攻击带来的压力,确保关键业务的稳定性。
- 用户体验优化:对于一些需要高响应速度的服务,通过限流可以避免过多的请求积压,确保用户能够获得良好的服务体验。
二、限流算法
2.1 固定窗口
固定窗口又称固定窗口(又称计数器算法,Fixed Window)限流算法,是最简单的限流算法。
实现原理:在指定周期内累加访问次数,当访问次数达到设定的阈值时,触发限流策略,当进入下一个时间周期时进行访问次数的清零。
如图所示,我们要求3秒内的请求不要超过150次:
public class FixedWindowRateLimiter{
Logger logger = LoggerFactory.getLogger(FixedWindowRateLimiter.class);
//时间窗口大小,单位毫秒
long windowSize;
//允许通过的请求数
int maxRequestCount;
//当前窗口通过的请求数
AtomicInteger counter = new AtomicInteger(0);
//窗口右边界
long windowBorder;
public FixedWindowRateLimiter(long windowSize, int maxRequestCount) {
this.windowSize = windowSize;
this.maxRequestCount = maxRequestCount;
this.windowBorder = System.currentTimeMillis() + windowSize;
}
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
if (windowBorder < currentTime) {
logger.info("window reset");
do {
windowBorder += windowSize;
} while (windowBorder < currentTime);
counter = new AtomicInteger(0);
}
if(counter.intValue() < maxRequestCount){
counter.incrementAndGet();
logger.info("tryAcquire success");
return true;
}else{
logger.info("tryAcquire fail");
return false;
}
}
}
优点:实现简单,容易理解
缺点:
1.限流不够平滑。例如:限流是每秒3个,在第一毫秒发送了3个请求,达到限流,窗口剩余时间的请求都将会被拒绝,体验不好。
2.无法处理窗口边界问题。因为是在某个时间窗口内进行流量控制,所以可能会出现窗口边界效应,即在时间窗口的边界处可能会有大量的请求被允许通过,从而导致突发流量。即:如果第2到3秒内产生了150次请求,而第3到4秒内产生了150次请求,那么其实在第2秒到第4秒这两秒内,就已经发生了300次请求了,远远大于我们要求的3秒内的请求不要超过150次这个限制,如下图所示:
2.2 滑动窗口
滑动窗口为固定窗口的改良版,解决了固定窗口在窗口切换时会受到两倍于阈值数量的请求。在滑动窗口算法中,窗口的起止时间是动态的,窗口的大小固定。这种算法能够较好地处理窗口边界问题,但是实现相对复杂,需要记录每个请求的时间戳。
实现原理:滑动窗口在固定窗口的基础上,将时间窗口进行了更精细的分片,将一个窗口分为若干个等份的小窗口,每次仅滑动一小块的时间。每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阈值。其中,Sentinel就是采用滑动窗口算法来实现限流的。如图所示:
核心步骤:
1.把3秒钟划分为3个小窗,每个小窗限制请求不能超过50秒。
2.比如我们设置,3秒内不能超过150个请求,那么这个窗口就可以容纳3个小窗,并且随着时间推移,往前滑动。每次请求过来后,都要统计滑动窗口内所有小窗的请求总量。
public class SlidingWindowRateLimiter{
Logger logger = LoggerFactory.getLogger(FixedWindowRateLimiter.class);
//时间窗口大小,单位毫秒
long windowSize;
//分片窗口数
int shardNum;
//允许通过的请求数
int maxRequestCount;
//各个窗口内请求计数
int[] shardRequestCount;
//请求总数
int totalCount;
//当前窗口下标
int shardId;
//每个小窗口大小,毫秒
long tinyWindowSize;
//窗口右边界
long windowBorder;
public SlidingWindowRateLimiter(long windowSize, int shardNum, int maxRequestCount){
this.windowSize = windowSize;
this.shardNum = shardNum;
this.maxRequestCount = maxRequestCount;
this.shardRequestCount = new int[shardNum];
this.tinyWindowSize = windowSize / shardNum;
this.windowBorder = System.currentTimeMillis();
}
public synchronized boolean tryAcquire(){
long currentTime = System.currentTimeMillis();
if(windowBorder < currentTime){
logger.info("window reset");
do {
shardId = (++shardId) % shardNum;
totalCount -= shardRequestCount[shardId];
shardRequestCount[shardId] = 0;
windowBorder += tinyWindowSize;
} while(windowBorder < currentTime);
}
if(totalCount < maxRequestCount){
logger.info("tryAcquire success:{}", shardId);
shardRequestCount[shardId]++;
totalCount++;
return true;
}
else {
logger.info("tryAcquire fail");
return false;
}
}
}
优点:解决了固定窗口算法的窗口边界问题,避免突发流量压垮服务器。
缺点:还是存在限流不够平滑的问题。例如:限流是每秒3个,在第一毫秒发送了3个请求,达到限流,剩余窗口时间的请求都将会被拒绝,体验不好。
2.3 漏桶算法
漏桶限流算法是一种常用的流量整形(Traffic Shaping)和流量控制(Traffic Policing)的算法,它可以有效地控制数据的传输速率以及防止网络拥塞。
主要的作用:
- 控制数据注入网络的速度。
- 平滑网络上的突发流量
漏桶是一个很形象的比喻,外部请求就像是水一样不断注入水桶中,而水桶已经设置好了最大出水速率,漏桶会以这个速率匀速放行请求,而当水超过桶的最大容量后则被丢弃。不管上面的水流速度有多块,漏桶水滴的流出速度始终保持不变。消息中间件就采用的漏桶限流的思想。
如图所示:
核心步骤:
- 一个固定容量的漏桶,按照固定速率出水(处理请求);
- 当流入水(请求数量)的速度过大会直接溢出(请求数量超过限制则直接拒绝)。
- 桶里的水(请求)不够则无法出水(桶内没有请求则不处理)。
public class LeakyBucketRateLimiter{
Logger logger = LoggerFactory.getLogger(LeakyBucketRateLimiter.class);
//桶的容量
int capacity;
//桶中现存水量
AtomicInteger water = new AtomicInteger();
//开始漏水时间
long leakTimestamp;
//水流出的速率,即每秒允许通过的请求数
int leakRate;
public LeakyBucketRateLimiter(int capacity, int leakRate) {
this.capacity = capacity;
this.leakRate = leakRate;
}
public synchronized boolean tryAcquire(){
//桶中没有水, 重新开始计算
if (water.get() == 0) {
logger.info("start leaking");
leakTimestamp = System.currentTimeMillis();
water.incrementAndGet();
return water.get() < capacity;
}
//先漏水,计算剩余水量
long currentTime = System.currentTimeMillis();
int leakedWater = (int) ((currentTime - leakTimestamp) / 1000 * leakRate);
logger.info("lastTime:{}, currentTime:{}. LeakedWater:{}", leakTimestamp, currentTime, leakedWater);
//可能时间不足,则先不漏水
if (leakedWater != 0) {
int leftWater = water.get() - leakedWater;
//可能水已漏光。设为0
water.set(Math.max(0, leftWater));
leakTimestamp = System.currentTimeMillis();
}
logger.info("剩余容量:{}", capacity - water.get());
if (water.get() < capacity) {
logger.info("tryAcquire sucess");
water.incrementAndGet();
return true;
} else {
logger.info("tryAcquire fail");
return false;
}
}
}
优点:
- 平滑流量。由于漏桶算法以固定的速率处理请求,可以有效地平滑和整形流量,避免流量的突发和波动(类似于消息队列的削峰填谷的作用)。
- 防止过载。当流入的请求超过桶的容量时,可以直接丢弃请求,防止系统过载。
缺点:
- 无法处理突发流量:由于漏桶的出口速度是固定的,无法处理突发流量。例如,即使在流量较小的时候,也无法以更快的速度处理请求。
- 可能会丢失数据:如果入口流量过大,超过了桶的容量,那么就需要丢弃部分请求。在一些不能接受丢失请求的场景中,这可能是一个问题。
- 不适合速率变化大的场景:如果速率变化大,或者需要动态调整速率,那么漏桶算法就无法满足需求。
- 资源利用率:不管当前系统的负载压力如何,所有请求都得进行排队,即使此时服务器的负载处于相对空闲的状态,这样会造成系统资源的浪费。
由于漏桶的缺陷比较明显,所以在实际业务场景中,使用的比较少。
2.4 令牌桶算
令牌桶算法是基于漏桶算法的一种改进,主要在于令牌桶算法能够在限制服务调用的平均速率的同时,还能够允许一定程度内的突发调用。
实现原理:
- 系统以固定的速率向桶中添加令牌;
- 当有请求到来时,会尝试从桶中移除一个令牌,如果桶中有足够的令牌,则请求可以被处理或数据包可以被发送;
- 如果桶中没有令牌,那么请求将被拒绝;
- 桶中的令牌数不能超过桶的容量,如果新生成的令牌超过了桶的容量,那么新的令牌会被丢弃。
- 令牌桶算法的一个重要特性是,它能够应对突发流量。当桶中有足够的令牌时,可以一次性处理多个请求,这对于需要处理突发流量的应用场景非常有用。但是又不会无限制的增加处理速率导致压垮服务器,因为桶内令牌数量是有限制的。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
class TokenBucket {
private final int maxTokens; // 令牌桶的最大容量
private final int rate; // 令牌生成速率
private AtomicInteger currentTokens; // 当前可用的令牌数
private long lastCheckTime; // 最后一次检查时间
public TokenBucket(int maxTokens, int rate) {
this.maxTokens = maxTokens;
this.rate = rate;
this.currentTokens = new AtomicInteger(maxTokens);
this.lastCheckTime = System.currentTimeMillis();
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
// 计算时间差
long elapsedTime = now - lastCheckTime;
// 根据生成速率更新令牌数
int tokensToAdd = (int)(elapsedTime * rate / 1000);
currentTokens.getAndUpdate(value -> Math.min(value + tokensToAdd, maxTokens));
lastCheckTime = now;
// 检查是否可以获取一个令牌
if (currentTokens.get() > 0) {
currentTokens.decrementAndGet(); // 获取令牌
return true; // 请求通过
}
return false; // 请求被拒绝
}
public static void main(String[] args) throws InterruptedException {
TokenBucket bucket = new TokenBucket(5, 1); // 最大5令牌,每秒生成1令牌
for (int i = 0; i < 10; i++) {
if (bucket.tryAcquire()) {
System.out.println("请求 " + i + " 通过!");
} else {
System.out.println("请求 " + i + " 被拒绝!");
}
TimeUnit.MILLISECONDS.sleep(300); // 每300ms发送一次请求
}
}
}
SpringBoot应用
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ApiController {
private final TokenBucket tokenBucket = new TokenBucket(5, 1);
@GetMapping("/api/resource")
public ResponseEntity<String> getResource() {
if (tokenBucket.tryAcquire()) {
return ResponseEntity.ok("请求通过");
} else {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("请求过多,请稍后重试");
}
}
}
优点:
- 可以处理突发流量:令牌桶算法可以处理突发流量。当桶满时,能够以最大速度处理请求。这对于需要处理突发流量的应用场景非常有用。
- 限制平均速率:在长期运行中,数据的传输率会被限制在预定义的平均速率(即生成令牌的速率)。
- 灵活性:与漏桶算法相比,令牌桶算法提供了更大的灵活性。例如,可以动态地调整生成令牌的速率。
缺点:
- 可能导致过载:如果令牌产生的速度过快,可能会导致大量的突发流量,这可能会使网络或服务过载。
- 需要存储空间:令牌桶需要一定的存储空间来保存令牌,可能会导致内存资源的浪费。
- 实现稍复杂:相比于计数器算法,令牌桶算法的实现稍微复杂一些。
三、java应用实现API限流
在Java中实现API限流,可以使用Guava库中的RateLimiter类,或者使用Spring Boot中的@RateLimiter注解。以下是两种方法的简单示例:
Guava RateLimiter 使用示例
<!-- guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.1-jre</version>
</dependency>
package cn.juwatech.util;
import com.google.common.util.concurrent.RateLimiter;
public class RateLimiting {
private static final RateLimiter RATE_LIMITER = RateLimiter.create(5.0); // 每秒5个请求
public static void performAction() {
if (RATE_LIMITER.tryAcquire()) {
// 执行请求处理
} else {
// 请求过多,进行相应处理,如重试或返回错误
}
}
}