限流器调研

单机限流算法


计数器(固定窗口)


简介

基于一个给定的时间窗口,维护一个计数器用于统计访问次数,然后实现以下规则:

  • 如果访问次数小于阈值,则代表允许访问,访问次数 +1。
  • 如果访问次数超出阈值,则限制访问,访问次数不增。
  • 如果超过了时间窗口,计数器清零,并重置清零后的首次成功访问时间为当前时间。这样就确保计数器统计的是最近一个窗口的访问量。


代码示例

// 毫秒为单位的时间窗口
private final long windowInMs;
// 时间窗口内最大允许的阈值
private final int threshold;
// 最后一次成功请求时间
private long lastReqTime = System.currentTimeMillis();
// 计数器
private long counter;

// 这里的key可以看成限流的资源(维度),比如按接口限流,接口A一个key,接口B一个key
public synchronized boolean tryAcquire(String key) {
    long now = System.currentTimeMillis();
    // 如果当前时间已经超过了上一次访问时间开始的时间窗口,重置计数器,以当前时间作为新窗口的起始值
    // 首先重置检测
    if (now - lastReqTime > windowInMs) {       #1
        counter = 0;
        lastReqTime = now;                  #2
    }
    // 然后再阈值判断
    if (counter < threshold) {                  #3
        counter++;                          #4
        return true;
    } else {
        return false;
    }
}


优点

算法简单。

缺点

临界突变
以 1 分钟允许 100 次访问为例,如果流量均匀保持 200 次/分钟的访问速率,系统的访问量曲线大概是这样的(按分钟清零):
理想型
但如果流量并不均匀,假设在时间窗口开始时刻 0:00 有几次零星的访问,一直到 0:50 时刻,开始以 10 次/秒的速度请求,就会出现这样的访问量图线:

图片
在临界的 20 秒内(0:50~1:10)系统承受的实际访问量是 200 次,换句话说,最坏的情况下,在窗口临界点附近系统会承受 2 倍的流量冲击,这就是简单窗口不能解决的临界突变问题。

平滑度(突刺)问题
突刺跟临界突变差不多,也是由短时间内流量激增引起的,但是突刺可能出现在时间窗口的任何位置,导致时间窗口内大部分时间服务都是处于空闲状态。


滑动窗口


简介

把整个大的时间窗口切分成更细粒度的子窗口,每个子窗口独立计数,但是限流是按照整个大的时间窗口的计数来。每经过一个子窗口大小的时间,就向右滑动一个子窗口,更新大窗口的计数,这样一来就能解决固定窗口临界突变的问题了。
在这里插入图片描述
如上图所示,将一分钟的时间窗口切分成 6 个子窗口,每个子窗口维护一个独立的计数器用于统计 10 秒内的访问量,每经过 10s,时间窗口向右滑动一格。
回到简单窗口出现临界突变的例子,结合上面的图再看滑动窗口如何消除临界突变。如果 0:50 到 1:00 时刻(对应灰色的格子)进来了 100 次请求,接下来 1:00~1:10 的 100 次请求会落到橙色的格子中,由于算法统计的是 6 个子窗口的访问量总和,这时候总和超过设定的阈值 100,就会拒绝后面的这 100 次请求。

优点

解决了临界突变问题。


缺点

精度问题
现在思考这么一个问题:滑动窗口算法能否精准地控制任意给定时间窗口 T 内的访问量不大于 N?

答案是否定的,还是将 1 分钟分成 6 个 10 秒大小的子窗口的例子,假设请求的速率现在是 20 次/秒,从 0:05 时刻开始进入,那么在 0:05~0:10 时间段内会放进 100 个请求,同时接下来的请求都会被限流,直到 1:00 时刻窗口滑动,将大窗口计数置为0,左边界时间置为0:10,放入请求,假设在 1:00~1:05 时刻放进 了100 个请求。如果把 0:05~1:05 看作是 1 分钟的时间窗口(滑动窗口只会保证0:10~1:10内的请求量不回超过100,但是我们按0:05~1:05来看确实超了),那么这个窗口内实际的请求量是 200,超出了给定的阈值 100。
虽然不能做到精准控制,但是可以通过提高窗口细分的粒度来提高精度,比如1分钟分成60个格子,每个格子1s。
平滑度(突刺)问题
使用滑动窗口算法限制流量时,我们经常会看到像下面一样的流量曲线。

图片
突发的大流量在窗口开始不久就直接把限流的阈值打满,导致剩余的窗口内所有请求都无法通过。在时间窗口的单位比较大时(例如以分为单位进行流控),这种问题的影响就比较大了。在实际应用中我们要的限流效果往往不是把流量一下子掐断,而是让流量平滑地进入系统当中。


漏桶算法


简介

滑动窗口无法很好解决流量平滑度问题,而漏桶算法则能保证请求处理的速率<=指定的值V,起到削峰填谷的作用。在漏桶算法中,要理解一下概念:

  • 最大允许请求数 N:桶的大小,由程序指定。

  • 时间窗口大小 T:一整桶水漏完的时间,由程序指定。

  • 最大访问速率 V:一整桶水漏完的速度,即 N/T。

  • 水:请求的流量。当请求数量小于N时,说明请求可以被处理,将水放入漏桶中。

  • 漏桶:缓冲队列。漏桶算法需要借助缓冲队列来保存外部流量请求。

  • 漏水:对请求进行处理。从缓冲队列中按固定速率V拿出请求并进行处理。

  • 溢出:请求被限流。桶注水的速度比漏水的速度快,最终导致指定时间内请求的数量超过指定值N,超过的这部分请求被丢弃(限流)。

代码实现

下面是溢出(限流 )判断代码,当返回true时,表示没有达到限流阈值,请求可以被处理,之后请求被放入缓冲队列中等待处理。

// 当前桶内剩余的水
private long left;
// 上次成功注水的时间戳
private long lastInjectTime = System.currentTimeMillis();
// 桶的容量
private long capacity;
// 一桶水漏完的时间
private long duration;
// 桶漏水的速度,即 capacity / duration
private double velocity;

public boolean tryAcquire(String key) {
    long now = System.currentTimeMillis();
    // 当前剩余的水 = 之前的剩余水量 - 过去这段时间内漏掉的水量
    // 过去这段时间内漏掉的水量 = (当前时间-上次注水时间) * 漏水速度
    // 如果当前时间相比上次注水时间相隔太久(一直没有注水),桶内的剩余水量就是0(漏完了)
    left = Math.max(0, left - (long)((now - lastInjectTime) * velocity));
    // 往当前水量基础上注一单位水,只要没有溢出就代表可以访问
    if (left + 1 <= capacity) {
        lastInjectTime = now;
        left++;
        return true;
    } else {
        return false;
    }
}

优点

平滑流量,提供一个稳定的流量输出速率。

缺点

在处理突发流量时缺乏效率,太保守,设定60秒内的流量阈值为60,那请求处理速率为1个/秒,假设在0~1秒时来了60个请求,本来系统可以很快一次性处理掉这60个请求的,偏偏就要等60秒才能处理完,缺乏效率。

令牌桶算法

简介

令牌桶算法就是以稳定的速率往桶中存放令牌,当桶满了,多余的令牌就会被丢弃。当请求到达时,会从同种拿一个令牌,如果能拿到,请求就会被放行处理,否则被丢弃(限流)。

令牌桶中没有使用缓存队列。

代码实现

下面是请求到来时,是否放行的判断。

// 当前桶内剩余的令牌
private long left;
// 上次成功添加令牌的时间戳
private long lastInjectTime = System.currentTimeMillis();
// 桶的容量
private long capacity;
// 空桶放满令牌所需的时间
private long duration;
// 桶中放令牌的速度,即 capacity / duration
private double velocity;

long now = System.currentTimeMillis();
left = Math.min(capacity, left + (long)((now - lastInjectTime) * velocity));
if (left - 1 > 0) {
    lastInjectTime = now;
    left--;
    return true;
} else {
    return false;
}

优点

既可以在大流量场景下限制处理请求的速率(通过限制放令牌到的速率),也可以在流量激增场景下处理瞬时流量(通过一次性拿到大量令牌)。

总结

图片

分布式限流

  • 背景:现在的服务都是分布式部署,流量都是网关通过负载均衡算法分配,同时经常发生扩缩容的情况,如果还用单机限流对应对分布式场景,会加剧误限、漏限,因此需要一种全局机制来保证全局配额。
  • 解决方案:
  1. 网关层限流:应用接入的网络架构中,在应用服务器之前往往有一层 LVS 或 Nginx 做统一入口,可以在这一层做入口的流控。本质上这就是单机流控的场景。
  2. 存储式限流:使用redis等缓存中间件来维护限流配置。redis官方给出的限流案例如下,下面代码还要加上分布式锁来保证线程安全。
FUNCTION LIMIT_API_CALL(ip):
current = GET(ip)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second"
ELSE
    value = INCR(ip)
    IF value == 1 THEN
        EXPIRE(ip,1)
    END
    PERFORM_API_CALL()
END
  • 存在问题:限流器强依赖于Nginx和redis,当流量非常大时,Nginx或redis可能宕机。
  • 应对方案:
  1. 多层级限流:如网关层限流+存储式限流+单机限流同时生效,Nginx和redis同时维护各自的限流配额,只要有一方运行,限流就在进行;如果Nginx和redis都挂了,降级到单机限流进行兜底。
  2. 减少对redis的访问次数(降低精度):正常情况下,应用每接受一个请求,就需要请求一次redis,这样请求太频繁。现在预先给所有应用分配一定比例的配额(如50%),用完了再像redis申请,申请按照之前配额消耗的速率来,快的多,满的少。这样能大大减少对redis的请求次数,但是会发生漏限的情况,比如应用刚申请完配额,redis里面的配置就重置了,那应用申请的配额就用累加到这个时间窗口,导致实际liulai

 内容大部分来自:单机和分布式场景下,有哪些流控方案?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
下面给出一个简单的限流实现,使用了令牌桶算法: ``` import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class RateLimiter { private final int capacity; // 令牌桶的容量 private final int rate; // 令牌桶的生成速率,即每秒生成多少个令牌 private AtomicInteger tokens; // 当前令牌桶中令牌的数量 private long lastRefillTime; // 上次令牌桶中令牌生成的时间 public RateLimiter(int capacity, int rate) { this.capacity = capacity; this.rate = rate; this.tokens = new AtomicInteger(capacity); this.lastRefillTime = System.nanoTime(); } // 尝试获取令牌,如果获取成功则返回 true,否则返回 false public boolean tryAcquire() { refill(); return tokens.getAndUpdate(n -> n > 0 ? n - 1 : n) > 0; } // 等待直到获取到令牌 public void acquire() throws InterruptedException { while (!tryAcquire()) { TimeUnit.MILLISECONDS.sleep(100); } } // 生成令牌 private void refill() { long now = System.nanoTime(); long elapsedNanos = now - lastRefillTime; int newTokens = (int) (elapsedNanos * rate / 1_000_000_000); if (newTokens > 0) { tokens.updateAndGet(n -> Math.min(n + newTokens, capacity)); lastRefillTime = now; } } } ``` 使用示例: ``` RateLimiter limiter = new RateLimiter(10, 1); // 令牌桶容量为 10,每秒生成 1 个令牌 for (int i = 0; i < 20; i++) { if (limiter.tryAcquire()) { System.out.println("Task " + i + " is executed"); } else { System.out.println("Task " + i + " is rejected"); } TimeUnit.MILLISECONDS.sleep(100); } ``` 输出: ``` Task 0 is executed Task 1 is rejected Task 2 is rejected Task 3 is rejected Task 4 is rejected Task 5 is rejected Task 6 is rejected Task 7 is rejected Task 8 is rejected Task 9 is rejected Task 10 is executed Task 11 is rejected Task 12 is rejected Task 13 is rejected Task 14 is rejected Task 15 is rejected Task 16 is rejected Task 17 is rejected Task 18 is rejected Task 19 is rejected ``` 上面的代码中,令牌桶的容量为 10,每秒生成 1 个令牌。在执行任务时,先使用 `tryAcquire` 尝试获取令牌,如果获取成功则执行任务,否则等待一段时间后重试。如果不想等待,可以使用 `acquire`,它会一直阻塞直到获取到令牌。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值