高并发流量限制-计数器&漏桶&令牌桶

目录

背景:

一、常见限流算法:

二、开源令牌桶实现

1、动手试试?

2、原理分析:

3、分布式限流算法:


背景:

      通常在高并发访问的情况下,会通过限流的手段来控制流量问题,以保证服务器处于正常压力下,一般对超过的部分不做处理,即丢弃。

限流的手段通常有计数器、漏桶、令牌桶。注意限流和限速(所有请求都会处理)的差别,视业务场景而定

一、常见限流算法:

1、计数器:在一段时间间隔内(时间窗),处理请求的最大数量固定,超过部分不做处理

2、漏桶:漏桶大小固定,处理速度固定,但请求进入速度不固定(在突发情况请求过多时,会丢弃过多的请求)

3、令牌桶:令牌桶的大小固定,令牌的产生速度固定,但是消耗令牌(即请求)速度不固定(可以应对一些某些时间请求过多的情况),每个请求都会从令牌桶中取出令牌,如果没有令牌则丢弃该次请求。

👉 计数器demo:

public class SpeedCounter {  // 计速器

    // 起始时间
    private static long startTime = System.currentTimeMillis();
    // 时间间隔 ms
    private static long interval = 10;
    // 每秒限制数量
    private static long maxCount = 1000;
    //
    private static AtomicLong nowCount = new AtomicLong();

    // 计数判断
    private static long isAccess(int taskId, int nth) {
        long nowTime = System.currentTimeMillis();
        if (nowTime < startTime + interval) {
            long oldValue;
            long newValue;
            do {
                oldValue = nowCount.get();
                newValue = oldValue + 1;
            } while (!nowCount.compareAndSet(oldValue, oldValue + 1));
            
            if (newValue <= maxCount) {
                return newValue;
            } else {
                return -newValue;
            }
        } else {
            synchronized (SpeedCounter.class) {
                System.out.println("waiting in ........................"  +taskId + ", nth: " + nth);
                if (nowTime > startTime + interval) { // 双重检验 防止重复初始化
                    System.out.println("================init start .================= " + taskId + ", nth: " + nth);
                    nowCount.set(0);
                    startTime = nowTime;
                }
            }
            return 0;
        }
    }
    public static void main(String[] args) {
        for (int i = 1; i < 10; i++) {
            task(i);
        }
    }

    public static void task(final int taskId) {
        new Thread(new Runnable() {
            public void run() {
                try {
                    for (int i = 1; i <= 100; i++) {
                        long cnt;
                        if ((cnt = isAccess(taskId, i)) > 0) {
                            System.out.println("业务" + taskId + "顺利执行" + cnt);
                        } else {
                            System.out.println("业务" + taskId + "被丢弃" + cnt);
                        }
                    }
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

}

👉 漏桶demo:

public class LeackBucket { // 漏桶
    // 起始时间
    private static long startTime = System.currentTimeMillis();
    // 流出速率 /ms
    private static long speed = 20;
    // 桶的容量
    private static long maxCount = 100;
    //
    private static long nowCount = 0;

    public static boolean isAccess() {
        long nowTime = System.currentTimeMillis();
        long outCount = (nowTime - startTime) * speed; // 流出数量
        startTime = nowTime; // 更新

        nowCount = nowCount - outCount <= 0 ? 0 : nowCount - outCount;
        if (nowCount < maxCount) {
            nowCount++;
            return true;
        } else {
            return false;
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 500; i++) {
            if (isAccess()) {
                System.out.println("业务顺利执行...");
            } else {
                System.out.println("业务被丢弃---");
            }
        }
    }
}

漏桶和令牌桶的主要区别在于处理速度上,漏桶是恒定速度处理请求,而令牌桶是根据令牌是否有剩余来决定是否处理请求,也可以说生产速度决定处理速度。

👉 令牌桶限流在生产上十分常见,接下来我们将以开源的一些实现进行分析 ~ 

二、开源令牌桶实现

如果是单机限流需求,首推 GuavaRateLimiter,我们将以此展开分析...

RateLimiter 的内部实现基本单位是微秒,也就是将 1s 的令牌量转化为 多少微秒生产一个令牌,比如 1s 可以生产 1000 个令牌, 那就是 1ms(1000微秒)生产一个令牌。

假设前 1ms 内有 1000 个线程请求令牌,仅有一个能拿到令牌,其他都得排队等待生产了才能处理。而如果一开始生产了 1000 个令牌,则 1000 个线程都可以同时拿到令牌,这就是令牌桶的一大特点:非恒速消费

1、动手试试?

这里先介绍Ratelimiter的使用,然后跟着源码深入理解

👉 例1:

    private final static ExecutorService executor = Executors.newFixedThreadPool(10);

    @Test
    public void testAcquire() {
        RateLimiter rateLimiter = RateLimiter.create(10);
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        for (int i = 0; i < 50; i++) {
            doProcess(() -> {
                double acquire = rateLimiter.acquire();
                System.out.printf("Acquire success and wait: %s(s)\n", acquire);
            });
        }
    }
    
    private static void doProcess(Runnable task) {
        Future<?> future = executor.submit(task);
        try {
            if (null != future.get()) {
                System.out.println(future.get());
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

 创建每秒处理 10 个请求的 ratelimiter, 让线程 sleep 100ms 是为了有一定的时间创建令牌,看看结果可以观察到刚开始有令牌可以直接拿到,后面则需要阻塞一定时间才能拿到:

   

Ratelimiter 有两类主要获取令牌的方法:

  •  一是acquire() 会阻塞直到拿到令牌,
  • 另外是 tryAcquire() 可以设置超时时间,超过指定时间没有拿到令牌则丢弃。

我们接着再看看 tryAcquire() ...

👉  例2:

    @Test
    public void testTryAcquirePermits() {
        RateLimiter rateLimiter = RateLimiter.create(10);
        for (int i = 0; i < 50; i++) {
            doProcess(() -> {
                if (rateLimiter.tryAcquire(90, TimeUnit.MILLISECONDS)) {
                    System.out.println("Try acquire success");
                } else {
                    System.out.println("Try acquire failure and discard request");
                }
            });
        }
    }

设置超时时间90毫秒,超过则直接丢弃

另外Ratelimiter这两个方法都可以设置一次性可以拿n个令牌这个参数,

👉  例3:

    @Test
    public void testAcquirePermits() {
        RateLimiter rateLimiter = RateLimiter.create(10);
        for (int i = 0; i < 50; i++) {
            doProcess(() -> {
                double acquire = rateLimiter.acquire(12);
                System.out.printf("Acquire success and wait: %s(s)\n", acquire);
            });
        }
    }

设置的每秒 10 个令牌, 一次性拿 12 个令牌,可以看到结果第一次无须等待直接就能拿到 12 个令牌,为什么?

这就是后面会介绍的提前消费让后面的请求“买单”(也就是要花费相应的等待时间),也正是这样让 RateLimiter 可以处理一些请求猛增的突发场景(一次性拿一个令牌其实处理不了)

Ratelimiter 有两个实现子类,上面默认创建的是 SmoothBursty 的实例,它是一种以恒定速度生产的一种方式

下面看看 SmoothWarmingUp,是一种需要设置“预热期”的方式,在“预热期”内,生产速度较慢,到稳定之后则是恒定生产速度。

可类比应该刚上线,响应请求较慢,一段时间稳定即正常了。

👉 例4:

    @Test
    public void testAcquireWithSmoothWarmingUp() {
        RateLimiter rateLimiter = RateLimiter.create(10, 5, TimeUnit.SECONDS);
        for (int i = 0; i < 50; i++) {
            doProcess(() -> {
                double acquire = rateLimiter.acquire(10);
                System.out.printf("Acquire success and wait: %s(s), current: %s\n",
                        acquire, System.currentTimeMillis() / 1000);
            });
        }
    }

设置每秒10个令牌,5秒“预热”时间,每次获取10个令牌,从结果可以看到5秒“预热”期内等待时间都是大于1秒 并逐渐减小, 直到预热期之后达到稳定:

以上便是 Ratelimiter 的基本用法

2、原理分析:

 1、SmoothBursty

👉 首先看阻塞方法acquire():     

  @CanIgnoreReturnValue
  public double acquire() {
    return acquire(1);
  }

  @CanIgnoreReturnValue
  public double acquire(int permits) {
    long microsToWait = reserve(permits);
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return 1.0 * microsToWait / SECONDS.toMicros(1L);
  }

  final long reserve(int permits) {
    checkPermits(permits);
    synchronized (mutex()) {
      return reserveAndGetWaitLength(permits, stopwatch.readMicros());
    }
  }

  final long reserveAndGetWaitLength(int permits, long nowMicros) {
    long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
    return max(momentAvailable - nowMicros, 0);
  }

/**
   * Reserves the requested number of permits and returns the time that those permits can be used
   * (with one caveat).
   *
   * @return the time that the permits may be used, or, if the permits may be used immediately, an
   *     arbitrary past or present time
   */
  abstract long reserveEarliestAvailable(int permits, long nowMicros);

可以看到一个主要的方法reserve(permits) 返回一个需要等待的微秒数, 在reserve方法中锁定了mutex(),mutex() 字面意思看是互斥量,也就是对象锁,保证线程安全性。

reserve中通过reserveAndGetWaitLength来返回需要等待的时间, 真正计算等待时间的方法是由子类SmoothRateLimiter实现的reserveEarliestAvailable方法:

  @Override
  final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    resync(nowMicros);
    long returnValue = nextFreeTicketMicros;
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    // freshPremits这个字段就是存量不足,需要多少个新的令牌
    double freshPermits = requiredPermits - storedPermitsToSpend;
    // 等待时间, SmoothBursty的storedPermitsToWaitTime恒为0, 也就保证了等待时间仅由freshPermits决定, 爆发性增长也在此处体现, 只要存量足够 直接拿走 无须等待
    long waitMicros =
        storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);

    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
  }

    /** Updates {@code storedPermits} and {@code nextFreeTicketMicros} based on the current time. */
  void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    // nextFreeTicketMicros 这个字段表示下一个可以获的令牌的时间点
    if (nowMicros > nextFreeTicketMicros) {
      // 试想当前1s已经过去一部分,滑动窗口已经往前挪走一步,所以需要把新的可用令牌计算进来
      // SmoothBurty因为是恒定速度生产,用时间长度除以生产速度即可得出
      // 至于SmoothWarmingUp后面介绍
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      storedPermits = min(maxPermits, storedPermits + newPermits);
      nextFreeTicketMicros = nowMicros;
    }
  }

好,现在进入计算等待时间的具体实现上,看第一个方法resync(nowMicros), 这是一个很关键的方法, 从字面意思上看是重新同步,也的确是这样,它会更新整个时间窗口的信息(令牌存量、nextFreeTicketMicos)。

可以这样看,这是一条长长的时间轴,nextFreeTicketMicos是这条线上的标志时间点,每次请求过来的时候用看在这个点的前面还是后面,如果在前面了说明窗口信息过时了需要更新,反之则不用。

更新时 coolDownIntervalMicros 方法由最终的子类实现,当前讨论的是 SmoothBurty,看看其实现:

    @Override
    double coolDownIntervalMicros() {
      // 稳定的时间间隔的微秒数(两个令牌之间) -- 生产速度
      return stableIntervalMicros;
    }

  // 这一步是在create的时候设置的rate, 这里会计算stableIntervalMicros
  @Override
  final void doSetRate(double permitsPerSecond, long nowMicros) {
    resync(nowMicros);
    double stableIntervalMicros = SECONDS.toMicros(1L) / permitsPerSecond;
    this.stableIntervalMicros = stableIntervalMicros;
    doSetRate(permitsPerSecond, stableIntervalMicros);
  }

好,回到 reserveEarliestAvailable 继续往下走,接下来就是判断存量和请求量的关系,如果大于直接取走不用等待,小于则要计算等待时间 waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros)。

对于 SmoothBursty 的 storedPermitsToWaitTime 恒为 0,用 freshPermits * 生产速度即为等待时间,然后将 nextFreeTicketMicros 时间点往前挪, 再留意一下 returnValue, 便可发现这步操作实现了让后面的请求“买单”, 因为 nextFreeTicketMicros 标志点已经往前挪了,而返回的并非最新的。 

👉 tryAcquire(), 设置超时时间,非阻塞:

  public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
    long timeoutMicros = max(unit.toMicros(timeout), 0);
    checkPermits(permits);
    long microsToWait;
    synchronized (mutex()) {
      long nowMicros = stopwatch.readMicros();
      if (!canAcquire(nowMicros, timeoutMicros)) {
        return false;
      } else {
        microsToWait = reserveAndGetWaitLength(permits, nowMicros);
      }
    }
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return true;
  }

  private boolean canAcquire(long nowMicros, long timeoutMicros) {
    return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
  }

  @Override
  final long queryEarliestAvailable(long nowMicros) {
    return nextFreeTicketMicros;
  }

2、SmoothWarmingUp: 有预热期

 This implements the following function where coldInterval = coldFactor * stableInterval.

 <pre>
          ^ throttling
          |
    cold  +                  /
 interval |                 /.
          |                / .
          |               /  .   ← "warmup period" is the area of the trapezoid between
          |              /   .     thresholdPermits and maxPermits
          |             /    .
          |            /     .
          |           /      .
   stable +----------/  WARM .
 interval |          .   UP  .
          |          . PERIOD.
          |          .       .
        0 +----------+-------+--------------→ storedPermits
          0 thresholdPermits maxPermits
 </pre>

  对于SmoothWarmingUp理解了这张图,就能理解其实现原理,这几个变量需要关注:

  • 纵坐标表示时间, 可以理解为生产一个令牌需要的时间,刚开始生产速度最慢为cold interval
  • 横坐标表示令牌量
  • 用一条垂线做游标,使用时游标向左移动,不使用时游标向右移动
  • stableInterval: 稳定时生产速度 ,比如我们设置1s限制10个令牌,则stableInterval = 100ms, 也就100ms生产一个令牌
  • coldInterval:   硬编码3*stableInterval, 从coldInterval到stableInterval是“预热”阶段,用条垂线来做游标,此时游标是往左边移动
  • warm up period: 是我们自己设置的“预热”时间,也就是梯形的面积 
  • maxPermits: 最大令牌量,不使用时最大;由我们设置的每秒请求量 和 预热时间 两个参数决定
  •  thresholdPermits: 生产一个令牌需要的时间coolInterval降到stableInterval时的这个点
  •  矩形面积等于梯形面积的一半,注意这里面积就是时间, 为啥是一半?

文档说和 coldFactor 因子等于3有关,个人理解,假设现在游标移动到了矩形上,有一小段时间没有请求,此时游标向右边移动,因为矩形有一段距离 下一次请求来的时候不至于走到梯形“预热”阶段。

而如果矩形面积偏大,很长一段时间没有请求,下一次请求的时候可能仍然在矩形阶段(而系统此时希望是在预热阶段);所以矩形和梯形面积达到一定比例才合适。

 代码上逻辑上基本一致,主要区别在于子类实现的一些细节上:  

  @Override
  final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    resync(nowMicros);
    long returnValue = nextFreeTicketMicros;
    // 要从"存量"中取出的令牌量
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    // “存量”不足时
    double freshPermits = requiredPermits - storedPermitsToSpend;
    long waitMicros =
        storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);
    // 将游标往前挪
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
  }

    // 对于SmoothWarmingUp来说,横坐标 meaxPermits -> 0 期间等待时间控制在此处
      @Override
    long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
      double availablePermitsAboveThreshold = storedPermits - thresholdPermits;
      long micros = 0;
      // measuring the integral on the right part of the function (the climbing line)
      if (availablePermitsAboveThreshold > 0.0) {
        double permitsAboveThresholdToTake = min(availablePermitsAboveThreshold, permitsToTake);
        // TODO(cpovirk): Figure out a good name for this variable.
        double length =
            permitsToTime(availablePermitsAboveThreshold)
                + permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake);
        // 实际就是计算梯形的面积
        micros = (long) (permitsAboveThresholdToTake * length / 2.0);
        permitsToTake -= permitsAboveThresholdToTake;
      }
      // measuring the integral on the left part of the function (the horizontal line)
      // 矩形面积
      micros += (long) (stableIntervalMicros * permitsToTake);
      return micros;
    }

 直接看这条:long waitMicros =
        storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);  

✅ storedPermitsToWaitTime(),这里有三种情况:图中的面积就是这个方法来计算出的等待时间

   

✅ (long) (freshPermits * stableIntervalMicros) 这个是用来计算当游标移动到小于0之后的等待时间,其实这里和图3矩形的求法相同

✅ 还有一种情况:

   

RateLimiter小结:

  • 主要步骤有两个,一是刷新滑动时间窗口 更新数据,二是计算等待时间,SmoothBurty取决于stableIntervalMicros和存量,    SmoothWarmingUp取决于上图的移动
  • SmoothBurty可以处理一些请求爆发增长的情况, 因为它可以缓存最多1s的令牌量, 试想一个装满令牌的令牌桶,当请求过来时  可以一次性都给出去,而SmoothWarmingUp则不会缓存
  • SmoothBurty有真正意义上的存量,可以直接拿去用,最多为maxPermits,无须等待;而SmoothWarmingUp没有真正意义上的存量,从maxPermits -> 0只是用来计算等待时间的标准
  • RateLimiter有后一次请求为前一次请求“买单”的特性,因为每次返回的nextFreeTicketMicros不是最新的,每次的消耗量(等待时间) 都会更新到nextFreeTicketMicros,让下一次处理

3、分布式限流算法:

从单机到分布式限流,原理上有一些相似性,可以进一步了解:

👉 分布式限流利器

👉 如何使用Redis实现分布式锁?

以上是个人理解,如有问题 请指出!

  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

柏油

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值