单机和分布式场景下,有哪些流控方案?

℡╲_俬逩灬. 提交于 2020-10-07 00:02:35

云栖号资讯:【点击查看更多行业资讯
在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来!

image

阿里妹导读:不同的场景下所需的流控算法不尽相同,那应该如何选择适用的流控方案呢?本文分享单机及分布式流控场景下,简单窗口、滑动窗口、漏桶、令牌桶、滑动日志等几种流控算法的思路和代码实现,并总结了各自的复杂度和适用场景。较长,同学们可收藏后再看。

一 流控的场景

流控的意义其实无需多言了。最常用的场景下,流控是为了保护下游有限的资源不被流量冲垮,保证服务的可用性,一般允许流控的阈值有一定的弹性,偶尔的超量访问是可以接受的。

有的时候,流控服务于收费模式,比如某些云厂商会对调用 API 的频次进行计费。既然涉及到钱,一般就不允许有超出阈值的调用量。

这些不同的场景下,适用的流控算法不尽相同。大多数情况下,使用 Sentinel 中间件已经能很好地应对,但 Sentinel 也并不是万能的,需要思考其他的流控方案。

二 接口定义

为了方便,以下所有的示例代码实现都是基于 Throttler 接口。

Throttler 接口定义了一个通用的方法用于申请单个配额。

当然你也可以定义一个 tryAcquire(String key, int permits) 签名的方法用于一次申请多个配额,实现的思路是一样的。

有些流控算法需要为每个 key 维护一个 Throttler 实例。

public interface Throttler {
    /**
     * 尝试申请一个配额
     *
     * @param key     申请配额的key
     * @return 申请成功则返回true,否则返回false
     */
    boolean tryAcquire(String key);
}

三 单机流控

1 简单窗口

简单窗口是我自己的命名,有些地方也叫做固定窗口,主要是为了跟后面的滑动窗口区分。

流控是为了限制指定时间间隔内能够允许的访问量,因此,最直观的思路就是基于一个给定的时间窗口,维护一个计数器用于统计访问次数,然后实现以下规则:

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

代码实现 SimpleWindowThrottler

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

public 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;
    }
}

另外一种常见的场景是根据不同的 key 来做流控,每个 key 有单独的时间窗口、阈值配置,因此需要为每个 key 维护一个单独的限流器实例。

切换到多线程环境

在现实应用中,往往是多个线程来同时申请配额,为了比较简洁地表达算法思路,示例代码里面都没有做并发同步控制。

以简单窗口的实现为例,要转换为多线程安全的流控算法,一种直接的办法是将 tryAcquire 方法设置为 synchronized。

当然一种感觉上更高效的办法也可以是修改读写变量的类型:

private volatile long lastReqTime = System.currentTimeMillis();
private LongAdder counter = new LongAdder();

不过这样其实并不真正“安全”,设想以下的场景,两个线程 A、线程 B 前后脚尝试获取配额,#1 位置的判断条件满足后,会同时走到 #2 位置修改 lastReqTime 值,线程 B 的赋值会覆盖线程 A,导致时间窗口起始点向后偏移。同样的,位置 #3 和 #4 也会构成竞争条件。当然如果对流控的精度要求不高,这种竞争也是能接受的。

临界突变问题

简单窗口的流控实现非常简单,以 1 分钟允许 100 次访问为例,如果流量均匀保持 200 次/分钟的访问速率,系统的访问量曲线大概是这样的(按分钟清零):

image

但如果流量并不均匀,假设在时间窗口开始时刻 0:00 有几次零星的访问,一直到 0:50 时刻,开始以 10 次/秒的速度请求,就会出现这样的访问量图线:

image

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

2 滑动窗口

如何解决简单窗口算法的临界突变问题?既然一个窗口统计的精度低,那么可以把整个大的时间窗口切分成更细粒度的子窗口,每个子窗口独立统计。同时,每过一个子窗口大小的时间,就向右滑动一个子窗口。这就是滑动窗口算法的思路。

image

如上图所示,将一分钟的时间窗口切分成 6 个子窗口,每个子窗口维护一个独立的计数器用于统计 10 秒内的访问量,每经过 10s,时间窗口向右滑动一格。

回到简单窗口出现临界跳变的例子,结合上面的图再看滑动窗口如何消除临界突变。如果 0:50 到 1:00 时刻(对应灰色的格子)进来了 100 次请求,接下来 1:00~1:10 的 100 次请求会落到黄色的格子中,由于算法统计的是 6 个子窗口的访问量总和,这时候总和超过设定的阈值 100,就会拒绝后面的这 100 次请求。

代码实现(参考 Sentinel)

Sentinel 提供了一个轻量高性能的滑动窗口流控算法实现,看代码的时候可以重点关注这几个类:

1)功能插槽 StatisticSlot 负责记录、统计不同纬度的 runtime 指标监控信息,例如 RT、QPS 等。

Sentinel 内部使用了 slot chain 的责任链设计模式,每个功能插槽 slot 有不同的功能(限流、降级、系统保护),通过 ProcessorSlotChain 串联在一起。

参考官方 Wiki:
https://github.com/alibaba/Sentinel/wiki/Sentinel工作主流程

2)StatisticSlot 使用 StatisticNode#addPassRequest 记录允许的请求数,包含秒和分钟两个维度。

3)具体记录用到的是 Metric 接口,对应实现类 ArrayMetric,背后真正的滑动窗口数据结构是 LeapArray 。

4)LeapArray 内部维护了滑动窗口用到的关键属性和结构,包括:

a)总窗口大小 intervalInMs,滑动子窗口大小 windowLengthInMs,采样数量sampleCount:

sampleCount = intervalInMs / windowLengthInMs

当前实现默认为 2,而总窗口大小默认是 1s,也就意味着默认的滑动窗口大小是 500ms。可以通过调整采样数量来调整统计的精度。

b)滑动窗口的数组 array,数组中每个元素以 WindowWrap 表示,其中包含:

  • windowStart:滑动窗口的开始时间。
  • windowLength:滑动窗口的长度。
  • value:滑动窗口记录的内容,泛型表示,关键的一类就是 MetricBucket,里面包含了一组 LongAdder 用于记录不同类型的数据,例如请求通过数、请求阻塞数、请求异常数等等。

记录请求的逻辑说白了,就是根据当前时间获取所属的滑动窗口,然后将该窗口的统计值 +1 即可。但实际上,获取当前所属的时间窗口这一步隐含了不少细节,详细的实现可以从 LeapArray#currentWindow 中找到,源码的注释写得很详细,这里就不多提了。

这里借助一张其他同学画的图表述以上的流程:

image

以上的流程基于 3.9.21 版本的源码,早先版本的 Sentinel 内部版本实现不尽相同,使用了一个叫 SentinelRollingNumber 的数据结构,但原理是类似的。

精度问题

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

答案是否定的,还是将 1 分钟分成 6 个 10 秒大小的子窗口的例子,假设请求的速率现在是 20 次/秒,从 0:05 时刻开始进入,那么在 0:05~0:10 时间段内会放进 100 个请求,同时接下来的请求都会被限流,直到 1:00 时刻窗口滑动,在 1:00~1:05 时刻继续放进 100 个请求。如果把 0:05~1:05 看作是 1 分钟的时间窗口,那么这个窗口内实际的请求量是 200,超出了给定的阈值 100。

如果要追求更高的精度,理论上只需要把滑动窗口切分得更细。像 Sentinel 中就可以通过修改单位时间内的采样数量 sampleCount 值来设置精度,这个值一般根据业务的需求来定,以达到在精度和内存消耗之间的平衡。

平滑度问题

使用滑动窗口算法限制流量时,我们经常会看到像下面一样的流量曲线。

image

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

3 漏桶

滑动窗口无法很好地解决平滑度问题,再回过头看我们对于平滑度的诉求,当流量超过一定范围后,我们想要的效果不是一下子切断流量,而是将流量控制在系统能承受的一定的速度内。假设平均访问速率为 v, 那我们要做的流控其实是流速控制,即控制平均访问速率 v ≤ N / T。

在网络通信中常常用到漏桶算法来实现流量整形。漏桶算法的思路就是基于流速来做控制。想象一下上学时经常做的水池一边抽水一边注水的应用题,把水池换成水桶(还是底下有洞一注水就开始漏的那种),把请求看作是往桶里注水,桶底漏出的水代表离开缓冲区被服务器处理的请求,桶口溢出的水代表被丢弃的请求。在概念上类比:

  • 最大允许请求数 N:桶的大小
  • 时间窗口大小 T:一整桶水漏完的时间
  • 最大访问速率 V:一整桶水漏完的速度,即 N/T
  • 请求被限流:桶注水的速度比漏水的速度快,最终导致
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!