流量控制算法——计数器、漏桶和令牌桶
1、为什么限流?
限流在很多场景中用来限制并发和请求量,比如说秒杀抢购,保护自身系统和下游系统不被巨型流量冲垮等。
1.1 举个栗子
-
霜晨月突然发现自己的接口请求量突然涨到之前的10倍,没多久该接口几乎不可使用,并引发连锁反应导致整个系统崩溃。如何应对这种情况呢?生活给了我们答案:比如老式电闸都安装了保险丝,一旦有人使用超大功率的设备,保险丝就会烧断以保护各个电器不被强电流给烧坏。同理我们的接口也需要安装上“保险丝”,以防止非预期的请求对系统压力过大而引起的系统瘫痪,当流量过大时,可以采取拒绝或者引流等机制。
-
以微博为例,例如:某明星被爆出了八卦,瞬时访问量从平时的50万增加到了500万,系统的规划能力最多可以支撑200万访问,那么就要执行限流规则,保证网站是一个可用的状态,不至于服务器崩溃,所有请求不可用。
有人可能会追问:既然存在并发500万的可能,为什么不把系统做到支撑500万?
根据“二八原则”解释,系统性能80%时间都是冗余状态,只有20%的时间处于短缺状态。出于成本考虑,既然有其他方案能解决(优化)高并发场景,属实没有必要为了浪费过多的成本。说白了,省钱就是“限流,降级和熔断”思路解决高并发场景的意义。
2、限流算法
限流算法很多,常见的有三类,分别是:计数器算法、漏桶算法、令牌桶算法,下面逐一讲解。
- **计数器:**在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理。
- **漏桶:**漏桶大小固定,处理速度固定,但请求进入速度不固定(在突发情况请求过多时,会丢弃过多的请求)。
- **令牌桶:**令牌桶的大小固定,令牌的产生速度固定,但是消耗令牌(即请求)速度不固定(可以应对一些某些时间请求过多的情况);每个请求都会从令牌桶中取出令牌,如果没有令牌则丢弃该次请求。
2.1 计数器算法
2.1.1 算法定义
在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理。
计数器算法是限流算法里最简单也是最容易实现的一种算法。简单粗暴,比如,指定线程池大小,指定数据库连接池大小、nginx连接数等,这都属于计数器算法。
举个栗子:我们规定对于A接口,每10秒的访问次数不能超过20次,超过的请求丢弃(丢弃属于策略的一种),那么我们可以这么做:
- 开始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1;
- 如果counter的值大于20并且该请求与第一个请求的间隔时间还在20秒之内,那么说明请求数过多,拒绝访问,执行策略处理(等待,丢弃,抛异常…);
- 如果该请求与第一个请求的间隔时间大于20秒,且counter的值还在限流范围内,那么就重置 counter,就是这么简单粗暴。
2.1.2 算法实现
下面是计数器算法的一般实现步骤:
- 初始化一个计数器,用于记录请求数量,初始值为0。
- 定义一个固定的时间窗口,例如每秒钟,每分钟,或其他时间间隔。
- 每当有请求到达时,将计数器加1。
- 在固定的时间窗口结束后,检查计数器的值是否超过了设定的阈值。
- 如果计数器的值小于阈值,允许请求通过;如果计数器的值大于等于阈值,拒绝多余的请求或采取其他处理措施。
- 重置计数器为0,开始下一个时间窗口的计数。
#include <iostream>
#include <chrono> // 用于处理时间和日期
#include <thread>
#include <mutex>
class RateLimiter {
public:
RateLimiter(int rate, int windowInSeconds) : rate_(rate), windowInSeconds_(windowInSeconds)
{
requestCount_ = 0;
startTimestamp_ = std::chrono::steady_clock::now();// 获取当前时间
}
bool allowRequest() {
std::lock_guard<std::mutex> lock(mutex_); // 使用互斥锁确保线程安全
auto now = std::chrono::steady_clock::now();// 获取当前时间
auto elapsedSeconds = std::chrono::duration_cast<std::chrono::seconds>(now - startTimestamp_).count();
if (elapsedSeconds >= windowInSeconds_) {
// 重置速率限制器以开始新的时间窗口
startTimestamp_ = now;
requestCount_ = 0;
}
if (requestCount_ < rate_) {
requestCount_++;
return true;
}
return false;
}
private:
int rate_; // 限制的请求速率
int windowInSeconds_; // 时间窗口的长度(秒)
int requestCount_; // 当前时间窗口内的请求计数
std::chrono::time_point<std::chrono::steady_clock> startTimestamp_;// 时间窗口的起始时间点
std::mutex mutex_; // 互斥锁,用于确保线程安全
};
int main() {
RateLimiter rateLimiter(20, 10); // 创建速率限制器,每秒允许5个请求
for (int i = 0; i < 100; i++) {
if (rateLimiter.allowRequest()) {
std::cout << "Request " << i + 1 << ": Allowed" << std::endl;// 请求被允许
} else {
std::cout << "Request " << i + 1 << ": Rate Limited" << std::endl;// 请求被限制
}
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 模拟传入请求
}
return 0;
}
这个示例创建了一个RateLimiter
类,它模拟了一个允许每秒5次请求的限流器。在main
函数中,我们模拟了10个请求,并检查它们是否被限流。请注意,这只是一个简单的演示,实际应用中可能需要更复杂的实现。
2.1.3 算法问题
这个算法虽然简单,但是有一个十分致命的问题,那就是临界问题,我们看下图:
从上图中我们可以看到,假设:有一个恶意用户,他在第9秒时,瞬间发送了20个请求,并且第10秒又瞬间发送了20个请求,那么其实这个用户在 1秒里面,瞬间发送了40个请求。
而我们刚才规定的是10秒最多20个请求(规划的吞吐量),也就是每秒钟最多2个请求,用户通过在时间窗口的重置节点处突发请求, 可以瞬间超过我们的速率限制。用户有可能通过算法的这个漏洞,瞬间压垮我们的应用。(只是模拟,模拟,实际肯定不会这么小的数字)
2.2 漏桶算法
2.2.1 算法原理
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的参数,所以即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使某一个单独的流突发到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。通常,漏桶算法与令牌桶算法可以结合起来为网络流量提供更大的控制。
漏桶限流大致的规则如下:
- 进水口(对应客户端请求)以任意速率流入进入漏桶。
- 漏桶的容量是固定的,出水(放行)速率也是固定的。
- 漏桶容量是不变的,如果处理速度太慢,桶内水量会超出了桶的容量,则后面流入的水滴会溢出,表示请求拒绝。
2.2.2 算法实现
#include <iostream>
#include <chrono>
#include <thread>
#include <atomic>
#include <mutex>
class LeakBucketLimiter
{
public:
LeakBucketLimiter() : lastOutTime(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count()),
leakRate(2), capacity(2), water(0) {}
bool isLimit(long taskId, int turn)
{
std::lock_guard<std::mutex> lock(mutex);
// 如果桶为空,更新lastOutTime并允许请求
if (water == 0)
{
lastOutTime = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
water++;
return false;
}
// 计算已漏出的水量
int waterLeaked = static_cast<int>((std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count() - lastOutTime) * leakRate);
// 计算桶中剩余的水量
int waterLeft = water - waterLeaked;
water = std::max(0, waterLeft);
// 更新lastOutTime
lastOutTime = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
// 尝试加水,如果桶未满,允许请求
if (water < capacity)
{
water++;
return false;
}
else
{
// 桶已满,拒绝请求(限制)
return true;
}
}
void testLimit()
{
std::atomic<int> limited(0);
const int threads = 2;
const int turns = 20;
std::chrono::milliseconds start = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch());
for (int i = 0; i < threads; i++)
{
std::thread([this, &limited, turns]()
{
for (int j = 0; j < turns; j++) {
long taskId = std::hash<std::thread::id>{}(std::this_thread::get_id());
bool intercepted = isLimit(taskId, j);
if (intercepted) {
limited++;
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));
} })
.detach();
}
// 等待线程完成
std::this_thread::sleep_for(std::chrono::seconds(1));
float time = (std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()) - start).count() / 1000.0;
std::cout << "被限制次数: " << limited << ", 通过次数: " << (threads * turns - limited) << std::endl;
std::cout << "限制比例: " << static_cast<float>(limited) / (threads * turns) << std::endl;
std::cout << "执行时间: " << time << " 秒" << std::endl;
}
private:
std::mutex mutex;
long lastOutTime;
int leakRate;
int capacity;
std::atomic<int> water;
};
int main()
{
LeakBucketLimiter limiter;
limiter.testLimit();
return 0;
}
//执行结果:
//被限制次数: 8, 通过次数: 32
//限制比例: 0.2
//执行时间: 1.002 秒
2.2.3 算法问题
漏桶的出水速度固定,也就是请求放行速度是固定的,不能灵活的应对后端能力提升。
比如,想要通过动态扩容,使后端流量从1000QPS提升到1WQPS,漏桶就没有办法实现。
所以常常这样讲,漏桶不能有效应对突发流量,只能起到平滑突发流量(整流)的作用。
2.3 令牌桶算法
2.3.1 算法原理
对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。
如下图所示,令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
当然,令牌的数量也是有上限的。令牌的数量与时间和发放速率强相关,时间流逝的时间越长,会不断往桶里加入越多的令牌,如果令牌发放的速度比申请速度快,令牌桶会放满令牌,直到令牌占满整个令牌桶。
令牌桶限流大致的规则如下:
- 进水口按照某个速度,向桶中放入令牌。
- 令牌的容量是固定的,但是放行的速度不是固定的,只要桶中还有剩余令牌,一旦请求过来就能申请成功,然后放行。
- 如果令牌的发放速度,慢于请求到来速度,桶内就无牌可领,请求就会被拒绝。
总之,令牌的发送速率可以设置,从而可以对突发的出口流量进行有效的应对。
令牌桶与漏桶相似,不同的是令牌桶桶中放了一些令牌,服务请求到达后,要获取令牌之后才会得到服务。令牌使用的灵活性赋予了令牌桶使用场景的灵活性,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。
2.3.2 算法实现
#include <iostream>
#include <thread>
#include <vector>
#include <atomic> // 包含原子操作库
#include <chrono> // 包含时间库
class TokenBucketLimiter { // 定义 TokenBucketLimiter 类
public:
TokenBucketLimiter(int capacity, int rate)
: capacity(capacity), rate(rate), tokens(0) {
lastTime = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); // 初始化 TokenBucketLimiter 对象,包括容量、速率、以及当前令牌数量
}
bool isLimited(long taskId, int applyCount) { // 判断是否被限制的函数
long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); // 获取当前时间
long gap = now - lastTime; // 计算时间间隔
int reverse_permits = static_cast<int>(gap * rate / 1000); // 计算时间段内的令牌数
int all_permits = tokens.load() + reverse_permits; // 计算总的令牌数量
tokens.store(std::min(capacity, all_permits)); // 存储当前令牌数量,但不超过容量
std::cout << "tokens " << tokens.load() << " capacity " << capacity << " gap " << gap << std::endl; // 输出当前令牌数量、容量和时间间隔
if (tokens.load() < applyCount) {
return true; // 被限制
} else {
tokens.fetch_sub(applyCount); // 减去申请的令牌数量
lastTime = now; // 更新上一次令牌发放时间
return false; // 未被限制
}
}
void testLimit(int threads, int turns) { // 测试限制功能的函数
std::atomic<int> limited(0); // 初始化原子整数,用于记录被限制的次数
std::vector<std::thread> threadPool; // 创建线程向量,用于并发执行任务
auto start = std::chrono::system_clock::now(); // 记录开始时间
for (int i = 0; i < threads; i++) { // 循环创建线程
threadPool.emplace_back([this, &limited, turns]() { // 使用lambda表达式创建线程
for (int j = 0; j < turns; j++) {
long taskId = std::hash<std::thread::id>{}(std::this_thread::get_id()); // 获取当前线程的唯一标识
bool intercepted = isLimited(taskId, 1); // 判断是否被限制
if (intercepted) {
limited.fetch_add(1); // 增加被限制的次数
}
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 线程休眠200毫秒
}
});
}
for (auto& thread : threadPool) { // 等待所有线程完成
thread.join();
}
auto end = std::chrono::system_clock::now(); // 记录结束时间
float time = static_cast<float>(std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()) / 1000; // 计算执行时间
std::cout << "限制次数: " << limited.load() << ", 允许次数: " << (threads * turns - limited.load()) << std::endl; // 输出限制和允许的次数
std::cout << "限制比例: " << static_cast<float>(limited.load()) / static_cast<float>(threads * turns) << std::endl; // 输出限制比例
std::cout << "执行时间: " << time << " 秒" << std::endl; // 输出执行时间
}
private:
long lastTime; // 上一次令牌发放时间
int capacity; // 令牌桶容量
int rate; // 令牌生成速率
std::atomic<int> tokens; // 当前令牌数量
};
int main() {
TokenBucketLimiter limiter(2, 2); // 创建 TokenBucketLimiter 对象
limiter.testLimit(2, 20); // 运行限制测试
return 0;
}
2.3.3 算法优点
令牌桶的好处之一就是可以方便地应对突发出口流量(后端能力的提升)。
比如,可以改变令牌的发放速度,算法能按照新的发送速率调大令牌的发放数量,使得出口突发流量能被处理。
3、参考文章
https://www.cnblogs.com/aspirant/p/9093437.html
http://t.csdnimg.cn/ODvQn