限流算法
常见的限流算法有四种:
- 计数器算法
- 滑动窗口算法
- 漏桶算法
- 令牌桶算法
实际应用上,计数器算法很少使用,其他三种根据对突发请求的处理可大体上分为两类:
- 滑动窗口、令牌桶
- 只要没有超过限制(窗口内请求数未达上限、存在令牌)就进行接收
- 漏桶
- 溢出的突发请求会被拒绝
计数器算法
又被称为固定窗口算法
- 从第一次接收到请求开始计数,维护一个有效期为一分钟的计数器
- 后续每接收到一个请求计数器加一
- 当到达最大请求数时,拒绝执行后续请求
- 计数器过期后,重复第一步
实现
import java.util.concurrent.atomic.AtomicInteger;
public class CounterRateLimiter {
private static final int LIMIT = 200; // 每秒最多允许的请求数量
private static final int PERIOD = 60 * 1000; // 时间周期,以毫秒为单位,例如这里设置为60秒
private AtomicInteger counter = new AtomicInteger(0); // 请求计数器
private long startTime = System.currentTimeMillis(); // 当前周期开始的时间
/**
* 尝试获取访问令牌
* @return 如果允许请求则返回true,否则返回false
*/
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
if (currentTime - startTime > PERIOD) {
// 重置计数器
startTime = currentTime;
counter.set(0);
}
return counter.incrementAndGet() <= LIMIT;
}
}
缺点
以下情景假设:
- 服务端每分钟最多处理 200 个请求
临界问题
某一时刻出现突发请求,窗口剩余时间内,服务处于不可用状态
滑动窗口算法
用于解决计数器算法的临界问题
- 根据计数周期进行均分,将其划分为若干块 (假设为n个)
- 设定一个窗口,窗口包含n个块
- 窗口随着随着时间进行滑动,丢掉最先出现的块,加入最新的块
- 算法保证:一个窗口内的请求次数不会超过限定值
实现
根据窗口的特点:
- 块:先进先出 --> 队列
- 频繁在队首插入,队尾移除 --> 链表
数据结构:
- 双向链表表示窗口
- 链表内存储原子整数表示当前块的请求次数
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
public class SlidingWindowRateLimiter {
private static final int LIMIT = 100; // 每秒最多允许的请求数量
private static final int PERIOD = 60 * 1000; // 时间周期,以毫秒为单位,例如这里设置为1分钟
private Queue<AtomicLong> windowList = new LinkedList<>(); // 存储每个时间窗口的请求计数
private AtomicLong currentWindowRequests = new AtomicLong(0);
public SlidingWindowRateLimiter() {
windowList.add(new AtomicLong(0));
}
/**
* 尝试获取访问令牌
* @return 如果允许请求则返回true,否则返回false
*/
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
long currentWindow = currentTime / PERIOD * PERIOD;
// 更新当前时间窗口
if (currentWindow != currentWindowRequests.get()) {
// 移除过期窗口数据
while (!windowList.isEmpty() && windowList.peek().get() < currentWindow - PERIOD) {
windowList.poll();
}
// 添加新窗口
if (windowList.peekLast() == null || windowList.peekLast().get() != currentWindow) {
windowList.add(new AtomicLong(0));
}
// 设置当前窗口
currentWindowRequests.set(currentWindow);
}
// 获取当前窗口的计数器
AtomicLong counter = windowList.peekLast();
// 增加请求计数
if (counter.incrementAndGet() > LIMIT) {
// 超过限制,回退计数
counter.decrementAndGet();
return false;
}
return true;
}
}
优缺
滑动窗口在面对突发请求时,可以将服务不可用的时间减少至一个区间
- 区间块数划分越多,效果越明显
- 但是区间块数越多,服务端需要保存数据也越多,耗费内存
需要注意的是
- 滑动窗口无法处理临界问题
- 滑动窗口只能缓解突发请求的问题,划分的块越多,不可用的时间就越少
以下情景假设:
- 服务端每分钟最多处理 200 个请求
漏桶算法
类似于消息队列MQ削峰,可以处理临界问题
- 区别在于:MQ通常不会轻易丢弃消息,漏桶很可能会丢弃请求
服务器引入一个队列存储到达的请求,并按照一定规则从队列取出和处理请求
- 一定规则:通常匀速处理请求
- 假设每分钟最多300次请求,则每秒处理
300 / 60 = 5
个请求
- 假设每分钟最多300次请求,则每秒处理
- 拒绝请求的时机:队列已满
实现
漏桶算法的作用和机理与MQ十分类似
- 可以固定MQ的队列长度,使用MQ来实现漏桶算法
优缺
服务器通常按照恒定速率处理请求,无法很好的发挥最大效率
令牌桶算法
用于解决上述算法的问题:
- 临界问题,请求数会超过限制
- 保证请求周期内生成令牌数不超过限制
- 遇到突发请求,会出现长时间服务不可用的问题
- 只要能获取到令牌,服务器是全速进行处理的
漏桶算法:
几个抽象的角色:
- 加工厂:
- 生产令牌
- 仓库:
- 存储令牌
- 消费者:
- 加工厂员工使用令牌接待消费者
服务器类似加工厂,按照一定规则生成令牌并存储
- 一定规则:
- 通常是根据限流规则均匀生成
- 假设每分钟最多300次请求,则每秒生成
300 / 60 = 5
个令牌,之后存储到仓库- 仓库已满时:丢弃溢出的令牌
请求类似消费者,服务器处理请求时需要消耗一个令牌
实现
令牌桶实际应用比较多,可以直接使用第三方提供的组件
- 单体架构:Guava
- 分布式架构:Redisson
Guava
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.2.1-jre</version>
</dependency>
import com.google.common.util.concurrent.RateLimiter;
// 每秒放入 5 个令牌
RateLimiter rateLimiter = RateLimiter.create(5.0);
@Test
public void test() throws IOException {
// 阻塞获取令牌 默认获取 1个
// rateLimiter.acquire();
// 执行业务逻辑...
// 尝试获取令牌 默认获取 1个
// 还可设置等待时长、获取令牌个数
boolean acquire = rateLimiter.tryAcquire();
if (acquire) {
// 执行业务逻辑...
}
}
Redisson
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.26.0</version>
</dependency>
@Bean
RedissonClient redissonClient(RedisProperties redisProperties) {
Config config = new Config();
SingleServerConfig singleServerConfig = config.useSingleServer();
singleServerConfig.setAddress(String.format("redis://%s:%s", "127.0.0.1", 7890));
singleServerConfig.setDatabase(0);
singleServerConfig.setPassword("pwd");
return Redisson.create(config);
}
@Resource
private RedissonClient redissonClient;
@Test
public void test() throws IOException {
String limitKey = "limit_request_count_biz1";
RRateLimiter rateLimiter = redissonClient.getRateLimiter(limitKey);
// 每秒产生 5 个令牌
rateLimiter.trySetRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);
// 不断自旋尝试获取锁
while (rateLimiter.tryAcquire());
// 执行业务逻辑
}
优缺
- 与漏桶算法
- 可以应对突发请求,更快的进行处理
- 与滑动窗口
- 功能类似,大量的突发请求会影响其他正常的请求
- 令牌机制解偶了请求验证和处理,或许根据令牌可以更方便地进行请求处理前地验证
- 功能类似,大量的突发请求会影响其他正常的请求