简介:不同的场景下所需的流控算法不尽相同,那应该如何选择适用的流控方案呢?本文分享单机及分布式流控场景下,简单窗口、滑动窗口、漏桶、令牌桶、滑动日志等几种流控算法的思路和代码实现,并总结了各自的复杂度和适用场景。较长,同学们可收藏后再看。
一 流控的场景
流控的意义其实无需多言了。最常用的场景下,流控是为了保护下游有限的资源不被流量冲垮,保证服务的可用性,一般允许流控的阈值有一定的弹性,偶尔的超量访问是可以接受的。
有的时候,流控服务于收费模式,比如某些云厂商会对调用 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 次/分钟的访问速率,系统的访问量曲线大概是这样的(按分钟清零):

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

在临界的 20 秒内(0:50~1:10)系统承受的实际访问量是 200 次,换句话说,最坏的情况下,在窗口临界点附近系统会承受 2 倍的流量冲击,这就是简单窗口不能解决的临界突变问题。
2 滑动窗口
如何解决简单窗口算法的临界突变问题?既然一个窗口统计的精度低,那么可以把整个大的时间窗口切分成更细粒度的子窗口,每个子窗口独立统计。同时,每过一个子窗口大小的时间,就向右滑动一个子窗口。这就是滑动窗口算法的思路。

如上图所示,将一分钟的时间窗口切分成 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 /

本文深入探讨了单机及分布式场景下的流控算法,包括简单窗口、滑动窗口、漏桶、令牌桶、滑动日志等,分析了各自的特点、适用场景及复杂度。
最低0.47元/天 解锁文章
4023

被折叠的 条评论
为什么被折叠?



