流量控制算法——计数器、漏桶和令牌桶

3 篇文章 0 订阅
2 篇文章 0 订阅

流量控制算法——计数器、漏桶和令牌桶

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 算法实现

下面是计数器算法的一般实现步骤:

  1. 初始化一个计数器,用于记录请求数量,初始值为0。
  2. 定义一个固定的时间窗口,例如每秒钟,每分钟,或其他时间间隔。
  3. 每当有请求到达时,将计数器加1。
  4. 在固定的时间窗口结束后,检查计数器的值是否超过了设定的阈值。
  5. 如果计数器的值小于阈值,允许请求通过;如果计数器的值大于等于阈值,拒绝多余的请求或采取其他处理措施。
  6. 重置计数器为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 算法原理

漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。

在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的参数,所以即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使某一个单独的流突发到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。通常,漏桶算法与令牌桶算法可以结合起来为网络流量提供更大的控制。

在这里插入图片描述

漏桶限流大致的规则如下:

  1. 进水口(对应客户端请求)以任意速率流入进入漏桶。
  2. 漏桶的容量是固定的,出水(放行)速率也是固定的。
  3. 漏桶容量是不变的,如果处理速度太慢,桶内水量会超出了桶的容量,则后面流入的水滴会溢出,表示请求拒绝。

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 算法原理

对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。

如下图所示,令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

当然,令牌的数量也是有上限的。令牌的数量与时间和发放速率强相关,时间流逝的时间越长,会不断往桶里加入越多的令牌,如果令牌发放的速度比申请速度快,令牌桶会放满令牌,直到令牌占满整个令牌桶。

在这里插入图片描述

令牌桶限流大致的规则如下:

  1. 进水口按照某个速度,向桶中放入令牌。
  2. 令牌的容量是固定的,但是放行的速度不是固定的,只要桶中还有剩余令牌,一旦请求过来就能申请成功,然后放行。
  3. 如果令牌的发放速度,慢于请求到来速度,桶内就无牌可领,请求就会被拒绝。

总之,令牌的发送速率可以设置,从而可以对突发的出口流量进行有效的应对。

令牌桶与漏桶相似,不同的是令牌桶桶中放了一些令牌,服务请求到达后,要获取令牌之后才会得到服务。令牌使用的灵活性赋予了令牌桶使用场景的灵活性,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。

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

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

霜晨月c

谢谢老板地打赏~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值