解密流量风暴中的守护神:深入探讨限流算法的奥秘

13 阅读15分钟

一、限流的分类

前端限流: js多次点击1次提交,提交后置灰,答题、验证码等。

代理层限流: IP限流、用户限流、硬件设备限流、连接数限流、其他限流策略。

服务层限流: 线程数限流、http链接数限流。

接口层限流: 算法限流。

二、四种限流算法

1、计数器算法

计数器限流算法是一种简单的限流策略。它通过一个计数器来记录在某个时间窗口内的请求数量。如果请求次数超过了设定的上限,则拒绝后续的请求。 image.png

public class CounterDemo {
    private long timeStamp = getNowTime();
    private volatile int reqCount = 0;
    private final int limit = 10; // 时间窗口内最大请求数
    private final long interval = 1000; // 时间窗口 1000 ms, 1s
    public boolean grant() {
        long now = getNowTime();
        if (now < timeStamp + interval) {
            // 在时间窗口内
            reqCount++;
            // 判断当前时间窗口内是否超过最大请求控制数
            return reqCount <= limit;
        }
        else {
            timeStamp = now;
            // 超时后重置
            reqCount = 1;
            return true;
        }
    }
}

2、滑动窗口算法

滑动窗口限流算法旨在解决计数器限流算法的问题。它将整个时间窗口划分为多个小的子窗口,并对每个小窗口进行计数,从而使请求处理更加均匀和连续。 image.png

public class SlidingWindowDemo {

    private final int limit = 100; // 时间窗口内最大请求数
    private final long interval = 1000; // 时间窗口 1000 ms, 1s
    private List<Long> reqTimes = new ArrayList<>(){{
        add(getNowTime());
    }}; // 记录请求的时间
    public boolean grant() {
        long t_m = getNowTime();
        // 判断 t_m 是否在时间窗口内
        if (t_m < reqTimes.get(0) + interval) {
            // 判断当前时间窗口内是否超过最大请求控制数
            if (reqTimes.size() < limit) {
              reqTimes.add(t_m);
                return true;
            } else {
                return false;
            }
        }
        else {
            // 如果不在时间窗口内,丢弃第一个时间点
            requestTimes.remove(0);
            return true;
        }
    }
}

3、漏斗算法

  • 漏桶限流算法指有一个固定大小的漏桶以固定速率流出水,如果漏桶中没有水就不流出(相当于没有请求),如果流通的水过多就把多余的水丢弃(相当于请求过多)。
  • 其优点是可以均匀分配请求资源。缺点也是因为平均分配请求资源导致无法应对突发请求流量。

4、令牌桶算法

令牌桶限流算法指有一个固定大小的桶并且按照固定速率向其放入令牌,当桶中令牌个数满了后就拒绝新添加的令牌。当一个请求过来时会去桶里拿一个令牌,如果拿到令牌就说明请求可以通过,如果没有拿到令牌就拒绝请求。 令牌桶限流算法相比漏桶限流算法,除了能限制平均请求资源外,还能应对突发的请求流量。

三、guava对于单机限流的具体实现

Guava的 RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。

令牌桶限流类似一种生产者消费者模式,一个生产者对应多个消费者。

令牌的生产需要单独起一个线程进行生产,这就会产生两个问题:

1.单机中多个接口都用到了令牌桶限流器,这就会起很多个线程去生产令牌。

2.如果cpu负载过高,生产令牌线程获取不到时间片,无法及时生产令牌,限流就会不准确。

guava是如何解决这个问题的呢

guava采用一种惰性生产令牌的方式,每次请求令牌时,通过当前时间和下次产生令牌时间的差值计算出现在有多少个令牌,如果当前时间比发放时间大,会获得令牌,并且会生成令牌存储。如果令牌不够,则让线程sleep,并且将下次令牌产生时间更新成当前时间+sleep时间

3.1、平滑突发限流

// 初始化一个RateLimiter, 使用令牌桶算法
RateLimiter limiter = RateLimiter.create(10); // 每秒生成10个令牌

// 当前存储的令牌数
double storedPermits;

// 最大存储令牌数
double maxPermits;

// 添加令牌的时间间隔(微秒)
double stableIntervalMicros;

/**
 * 下一次请求可以获取令牌的起始时间。
 * 由于RateLimiter允许预消费令牌,上次请求预消费令牌后,
 * 下次请求需要等待相应的时间到nextFreeTicketMicros时刻才能获取令牌。
 */
private long nextFreeTicketMicros = 0L;

@Override
final void doSetRate(double permitsPerSecond, long nowMicros) {
    // 同步当前时间,更新storedPermits和nextFreeTicketMicros
    resync(nowMicros);
    // 计算添加令牌的稳定时间间隔
    double stableIntervalMicros = SECONDS.toMicros(1L) / permitsPerSecond;
    // 更新stableIntervalMicros值
    this.stableIntervalMicros = stableIntervalMicros;
    // 设置新的生产速率和稳定时间间隔
    doSetRate(permitsPerSecond, stableIntervalMicros);
}

private void resync(long nowMicros) {
    // 如果当前时间超出了nextFreeTicketMicros,表示可以添加令牌
    if (nowMicros > nextFreeTicketMicros) {
        // 计算应该添加的令牌数量,并更新storedPermits
        storedPermits = min(maxPermits,
            storedPermits + (nowMicros - nextFreeTicketMicros) / stableIntervalMicros);
        // 更新时间戳
        nextFreeTicketMicros = nowMicros;
    }
}

void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
    // 保存旧的最大存储令牌数
    double oldMaxPermits = this.maxPermits;
    // 根据最大突发时间和生产速率计算新的最大存储令牌数
    maxPermits = maxBurstSeconds * permitsPerSecond;
    // 初始化或更新storedPermits,根据新的和旧的最大存储令牌数比例
    if (oldMaxPermits == Double.POSITIVE_INFINITY) {
        storedPermits = maxPermits;
    } else {
        storedPermits = (oldMaxPermits == 0.0)
            ? 0.0 // 初始状态
            : storedPermits * maxPermits / oldMaxPermits; // 更新状态
    }
}

acquire源码

public double acquire(int permits) {
 // 计算获取令牌所需等待的时间
 long microsToWait = reserve(permits);
 // 进行线程sleep
 stopwatch.sleepMicrosUninterruptibly(microsToWait);
 return 1.0 * microsToWait / SECONDS.toMicros(1L);
}
final long reserve(int permits) {
    checkPermits(permits);
 // 由于涉及并发操作,所以使用synchronized进行并发操作
 synchronized (mutex()) {
 return reserveAndGetWaitLength(permits, stopwatch.readMicros());
 }
}
final long reserveAndGetWaitLength(int permits, long nowMicros) {
 // 计算从当前时间开始,能够获取到目标数量令牌的时间
 long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
 // 两个时间相减,获得需要等待的时间
 return max(momentAvailable - nowMicros, 0);
}
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 = nextFreeTicketMicros + waitMicros;
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
  }

3.2、平滑预热限流

平滑预热限流初始化


    RateLimiter limiter = RateLimiter.create(10,1,TimeUnit.SECONDS);
    stableInterval //稳定状态下添加令牌的速率
    coldInterval   //冷却状态下添加令牌的速率
    thresholdPermits //热启动的令牌数目阈值
    maxPermits //令牌最大数目
    warmUpPeriod //预热时间长度
    coldFactor = 3 //冷却因子

平滑预热限流关键源码

@Override
void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
    // 保存旧的最大存储令牌数
    double oldMaxPermits = maxPermits;
    
    // 计算新的最大存储令牌数,根据预热时间和稳定时间间隔
    maxPermits = warmupPeriodMicros / stableIntervalMicros;
    
    // 计算半数令牌数,用于梯形计算
    halfPermits = maxPermits / 2.0;
    
    // 计算冷却时间间隔,为稳定间隔的三倍
    double coldIntervalMicros = stableIntervalMicros * 3.0;
    
    // 计算梯形斜边的斜率,用于计算在阈值和最大存储令牌之间的限流速率
    slope = (coldIntervalMicros - stableIntervalMicros) / halfPermits;
    
    // 初始化或更新storedPermits,根据新的和旧的最大存储令牌数比例
    if (oldMaxPermits == Double.POSITIVE_INFINITY) {
        storedPermits = 0.0; // 初始状态为冷状态
    } else {
        storedPermits = (oldMaxPermits == 0.0)
            ? maxPermits // 初始状态为冷状态
            : storedPermits * maxPermits / oldMaxPermits; // 更新状态比例
    }
}

long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
    // 计算在阈值以上可用的令牌数目
    double availablePermitsAboveThreshold = storedPermits - thresholdPermits;
    long micros = 0;
    
    // 计算右边梯形区域的积分(面积)
    if (availablePermitsAboveThreshold > 0.0) {
        // 计算在阈值以上需要获取的令牌数目
        double permitsAboveThresholdToTake = min(availablePermitsAboveThreshold, permitsToTake);
        
        // 梯形高:permitsAboveThresholdToTake
        // 梯形下底:permitsToTime(availablePermitsAboveThreshold)
        // 梯形上底:permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake)
        micros = (long) (permitsAboveThresholdToTake
            * (permitsToTime(availablePermitsAboveThreshold)
            + permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake)) / 2.0);
        
        // 更新待获取的令牌数,使其减去已经计算过的部分
        permitsToTake -= permitsAboveThresholdToTake;
    }
    // 计算左边稳定速率部分的积分(面积),即放行速率稳定在stableInterval
    micros += (stableIntervalMicros * permitsToTake);
    return micros;
}

// 根据斜率和稳定时间间隔计算放行时间
// 可以想象计算出的结果为梯形斜边上的某个点(用于非均匀放行速率)
private double permitsToTime(double permits) {
    return stableIntervalMicros + permits * slope;
}

四、分布式限流

集群限流方式分为集群均摊模式和单机均摊模式两种。

4.1、集群均摊模式

image.png 限制某个资源的整体qps。tokenServer需要单独部署。每次请求需要经过tokenServer的验证。

4.2、单机均摊模式

image.png 无需单独部署tokenServer,由于每台机器的流量不均匀,所以这种方式不准确。

4.3、sentinel算法实现

2012 年,Sentinel 诞生于阿里巴巴,其主要目标是流量控制。2013-2017 年,Sentinel 迅速发展,并成为阿里巴巴所有微服务的基本组成部分,涵盖了几乎所有核心电子商务场景。2018 年,Sentinel 演变为一个开源项目。

  • 丰富的应用场景:Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、集群流量控制、实时熔断下游不可用应用等。
  • 完备的实时监控:Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。
  • 广泛的开源生态:Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Dubbo、gRPC 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。
  • 完善的 SPI 扩展点:Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快速地定制逻辑。例如定制规则管理、适配动态数据源等。

image.png

  • NodeSelectorSlot 负责收集资源的路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降级;
  • ClusterBuilderSlot 则用于存储资源的统计信息以及调用者信息,例如该资源的 RT, QPS, thread count 等等,这些信息将用作为多维度限流,降级的依据;
  • StatisticSlot 则用于记录、统计不同纬度的 runtime 指标监控信息;
  • FlowSlot 则用于根据预设的限流规则以及前面 slot 统计的状态,来进行流量控制;
  • AuthoritySlot 则根据配置的黑白名单和调用来源信息,来做黑白名单控制;
  • DegradeSlot 则通过统计信息以及预设的规则,来做熔断降级;
  • SystemSlot 则通过系统的状态,例如 load1 等,来控制总的入口流量;

根据当前时间获取滑动窗口源码

// timeMillis:表示当前时间的时间戳
// windowLengthInMs:表示一个滑动窗口的时间长度,根据源码来看是1000ms,即一个滑动窗口统计1秒内的数据。
public WindowWrap<T> currentWindow(long timeMillis) {
    if (timeMillis < 0) {
        return null; // 如果时间戳是负数,返回null
    }
    // 根据当前时间计算出当前属于哪个滑动窗口的数组下标
    int idx = calculateTimeIdx(timeMillis);
    // 根据当前时间计算当前滑动窗口的开始时间
    long windowStart = calculateWindowStart(timeMillis);

    /*
     * 根据下标在环形数组中获取滑动窗口(桶)
     */
    while (true) {
        WindowWrap<T> old = array.get(idx); // 获取下标处的滑动窗口(桶)
        if (old == null) {
            /*
             *     B0       B1      B2    NULL      B4
             * ||_______|_______|_______|_______|_______||___
             * 200     400     600     800     1000    1200  timestamp
             *                             ^
             *                          time=888
             *            当前桶为空,因此创建新桶并更新
             *
             * 如果旧桶不存在,则在windowStart创建新桶,
             * 然后尝试通过CAS操作更新环形数组。只有一个线程能够成功更新,
             * 而其他线程则会等待时间片。
             */
            // 创建时间窗口,参数:窗口大小,开始时间,桶(保存统计值)
            WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
            if (array.compareAndSet(idx, null, window)) {
                // 成功更新,返回创建的桶
                return window;
            } else {
                // 竞争失败,线程会放弃时间片并等待桶的可用性
                Thread.yield();
            }
        } else if (windowStart == old.windowStart()) {
            /*
             *     B0       B1      B2     B3      B4
             * ||_______|_______|_______|_______|_______||___
             * 200     400     600     800     1000    1200  timestamp
             *                             ^
             *                          time=888
             *            桶3的startTime:800,因此是最新的
             *
             * 如果当前windowStart等于旧桶的开始时间戳,
             * 表示时间在桶内,所以直接返回桶。
             */
            return old;
        } else if (windowStart > old.windowStart()) {
            /*
             *   (old)
             *             B0       B1      B2    NULL      B4
             * |_______||_______|_______|_______|_______|_______||___
             * ...    1200     1400    1600    1800    2000    2200  timestamp
             *                              ^
             *                           time=1676
             *          桶2的startTime:400,已过期,应重置
             *
             * 如果旧桶的开始时间戳落后于提供的时间,
             * 则表示桶已过期。我们必须将桶重置为当前windowStart。
             * 请注意,重置和清理操作难以原子化,
             * 因此我们需要更新锁来保证桶更新的正确性。
             *
             * 更新锁是有条件的(微小范围),只有当桶已过期时才会生效,
             * 所以在大多数情况下不会导致性能损失。
             */
            if (updateLock.tryLock()) {
                try {
                    // 成功获得更新锁,现在重置桶
                    return resetWindowTo(old, windowStart);
                } finally {
                    updateLock.unlock();
                }
            } else {
                // 竞争失败,线程会放弃时间片并等待桶的可用性
                Thread.yield();
            }
        } else if (windowStart < old.windowStart()) {
            // 不应该进入这里,因为提供的时间已经过去了
            return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        }
    }
}

// 根据当前时间计算出当前属于哪个滑动窗口的数组下标
private int calculateTimeIdx(/*@Valid*/ long timeMillis) {
    // 利用除法取整原则,保证了一秒内所有时间戳得到的timeId是相等的
    long timeId = timeMillis / windowLengthInMs;
    // 利用求余运算原则,保证一秒内获取到的桶的下标是一致的
    return (int) (timeId % array.length());
}

// 根据当前时间计算当前滑动窗口的开始时间
protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
    // 利用求余运算原则,保证一秒内获取到的桶的开始时间是一致的
    // 100 - 100 % 10 = 100 - 0 = 100
    // 101 - 101 % 10 = 101 - 1 = 100
    // 102 - 102 % 10 = 102 - 2 = 100
    return timeMillis - timeMillis % windowLengthInMs;
}

五、关于限流的一些思考

5.1、单机限流的问题?

单纯的单机限流会随着集群规模的扩展而变差,单机分配到的请求数降低、调度策略的不均匀或者是节点故障都会导致都会导致单机限流适用性变差。

5.2、基于外部依赖的全局限流稳定性降低?

为了计算全局的限流数据,需要引入外部依赖。外部依赖的增加无疑降低了服务的可用性。在依赖服务异常时可能会造成限流功能不可用或是触发更为严重的问题。限流场景下的计算天然就是大流量的,对依赖服务的压力显而易见,因此这并不是一个可以忽略或是低概率发生的问题。

5.3、统计精度和性能压力之间的权衡?

如果每来一个请求都很精确的进行全局计算,那么外部依赖的压力会很高。如果进行一定的预取以及本地计算,则容易造成统计不准确。

5.4、如何获取合理的qps?

一种思路是通过压测去对系统进行真实的度量,最后将结果关联到降级配置。这种方式的问题在于压测模型与线上真实运行环境不一定相同,但接口的压测不能说明问题,混合接口压测又难以真实反应实际流量场景。

另一种思路是通过梳理各应用监控数据,从当前系统高峰期的QPS统计,通过预定义的计算公式来计算预期的限流QPS值。通常是根据高峰期的水位情况进行一定的放大。这种方式的问题在于系统性能拐点未知,单纯的预测不一定准确。

5.5、是否可以基于系统的反馈动态更改qps值?

假定初始QPS阈值设高了,在限流未触发时,应用集群负载出现了问题。此时通过监控巡检去发现这一问题,反向调低QPS阈值。反过来当初始阈值低了,那么在限流触发情况下,应用负载还是正常,这个时候自动调大阈值。但这样做法也是有问题的,应用可能对外提供多个接口,可能只部分接口有限流配置。即便观测到了负载与预期不符,但与预期的差异可能是别的原因造成的 ,此时调整已有接口的阈值就都是无用的。准确的阈值是无法通过非实时方式获取到的,如果想实时获取,那么必然要基于应用的运行状况反馈。单机器的反馈是最准确的。因此更合理的限流方式可能需要回归到单机上。

5.6、终极的限流应该是什么样的?

应用可能提供多个接口,任何一个接口都可能是问题所在导致负载问题。从应用的运行数据反馈中也难以准确的获取究竟是哪一个接口造成的影响。可能是单个异常接口,也可能是每个接口都贡献了一份。也就是说,更有效的限流方式是应用级的,因此不是基于QPS去进行限流,而是基于应用的负载状况去进行限流。

论文:Overload Control for Scaling WeChat Microservices