前言
在分布式系统中,流量控制是一个非常重要的环节
流量控制通过限制每个用户调用API的频率来保护API免受无意或恶意的过度使用。如果没有速率限制,每个用户可以随心所欲地发出请求,导致请求的"峰值",令其他用户"饥饿"。
流量控制有以下好处:
- 保护服务:可以提高服务的可用性
- 成本管理:可用于成本控制,如减少因测试或错误配置等意外事故造成的开销
- 管理资源分配策略:使多个用户公平地共享一个服务
- 安全性:可以用来防御或缓解一些常见的攻击,如DDOS攻击
常用的流量控制算法有:固定窗口算法,滑动窗口算法,漏桶算法,令牌桶算法
本篇文章介绍的是固定窗口算法和滑动窗口算法
抽象
不管是固定窗口还是滑动窗口,都是窗口,都可以抽象出共同的方法和属性来
抽取窗口信息
先抽取窗口共有的属性
java
public class WindowInfo {
// 长度(毫秒)
private final long length;
// 许可,窗口允许通过的请求的大小
private final int count;
// 限流的粒度 方法名,ip
private final String key;
public WindowInfo(long length, int count, String key) {
this.length = length;
this.count = count;
this.key = key;
}
public long getLength() {
return length;
}
public int getCount() {
return count;
}
public String getKey() {
return key;
}
}
窗口长度:一个窗口的时间长度,这个长度可以以毫秒、微秒、纳秒为单位,这里以毫秒为单位
窗口最大许可:一个窗口在单位时间长度内允许通过的请求的大小
key:标识一种类型的请求,作为限流的粒度,该窗口仅仅为该类型的请求服务,key可能是一个方法的全限定名,可能是一个客户端的ip地址等
抽取接口
窗口有共同的方法,就是判断请求是否能够通过
java
public interface Window {
boolean allowable(WindowInfo windowInfo);
}
抽取抽象类和Stat
这里引入了一个新的类,Stat
那么为什么需要Stat类呢?
因为window是死的,window仅仅是一个框架,根据具体的实现类去采用具体的限流算法进行限流
而Stat是活的,Stat是具体到每一种类型请求的具体统计数值以及数据的动态变化,描述一个窗口的具体情况,比如窗口的长度,窗口当前的许可,窗口是为哪一种key服务,都是由Stat来记录的,一个请求能否通过,也是由动态的Stat来决定的
这里的设计采用了模板方法模式,做了Stat的类型判断和存取,子类Stat只需要重写allowable方法即可
java
public abstract class AbstractWindow implements Window {
private final Map<String, Stat> statMap = new ConcurrentHashMap<>();
// 模板方法
public boolean allowable(WindowInfo windowInfo) {
int count = windowInfo.getCount();
String key = windowInfo.getKey();
if (count <= 0) {
return true;
}
Stat stat;
if ((stat = statMap.get(key)) == null) {
// putIfAbsent 双重检查
if (windowInfo instanceof FixWindowInfo) {
statMap.putIfAbsent(key, new FixWindow.Stat((FixWindowInfo) windowInfo));
} else if (windowInfo instanceof SlidingWindowInfo) {
statMap.putIfAbsent(key, new SlidingWindow.Stat((SlidingWindowInfo) windowInfo));
}
stat = statMap.get(key);
}
return stat.allowable();
}
public interface Stat {
boolean allowable();
}
}
实现
接下来看看具体的实现类
固定窗口
固定窗口的算法核心是:一个窗口内固定只能处理若干个请求,窗口过期后,会重置计数;比如一秒内只能处理50个请求,那这一秒内如果超过了50个请求,多出来的请求会被丢弃,要等待该窗口过期后重置计数
FixWindowInfo
固定窗口的窗口信息与父类相同,暂不需要额外的字段,但是依然抽取出来以供后续扩展
java
public class FixWindowInfo extends WindowInfo {
public FixWindowInfo(long length, int count, String key) {
super(length, count, key);
}
public static FixWindowInfo create(long length, int count, String key) {
return new FixWindowInfo(length, count, key);
}
}
FixWindow
以下算法实现参考了dubbo的固定窗口算法
java
public class FixWindow extends AbstractWindow implements Window {
@Data
public static class Stat implements AbstractWindow.Stat {
private final FixWindowInfo windowInfo;
// 上一次Reset的时间
private final AtomicLong lastResetTime;
// 窗口当前的允许的请求量
private final AtomicInteger allows;
public Stat(FixWindowInfo windowInfo) {
this.windowInfo = windowInfo;
this.lastResetTime = new AtomicLong(System.currentTimeMillis());
this.allows = new AtomicInteger(windowInfo.getCount());
}
AtomicBoolean updating = new AtomicBoolean(false);
@Override
public boolean allowable() {
// 获取当前时间
long now = System.currentTimeMillis();
// sync
// 当前时间大于(上次刷新的时间+窗口长度)
// 说明窗口已经过期了,需要刷新窗口
if (now > lastResetTime.get() + windowInfo.getLength()) {
// dcl
synchronized (this) {
if (now > lastResetTime.get() + windowInfo.getLength()) {
// 刷新许可
allows.set(windowInfo.getCount());
// 刷新更新时间
lastResetTime.set(now);
}
}
}
// cas+自旋
// 当前时间大于(上次刷新的时间+窗口长度)
// 说明窗口已经过期了,需要刷新窗口
// while (now > lastResetTime.get() + windowInfo.getLength()) {
// // cas成功
// if (updating.compareAndSet(false, true)) {
// // 双重检查
// if (now > lastResetTime.get() + windowInfo.getLength()) {
// // 刷新许可
// allows.set(windowInfo.getCount());
// // 刷新更新时间
// lastResetTime.set(now);
// updating.set(false);
// }
// }
// // cas失败,yield+自旋
// else {
// Thread.yield();
// }
// }
return allows.get() >= 0 && allows.decrementAndGet() >= 0;
}
}
}
在固定窗口的Stat中,除了组合固定窗口的窗口信息(静态的窗口长度,窗口最大许可,key)外,还加入了动态的Stat信息:
lastResetTime:窗口上次刷新的时间
allows:窗口当前的许可数
固定窗口的Stat还实现了allowable方法,
判断请求是否通过的核心请求只有一句
java
return allows.get() >= 0 && allows.decrementAndGet() >= 0
窗口过期重置计数的逻辑,写了两种实现方式
一种是sync+双重检查锁,第二种是cas+自旋+双重检查锁
java
// 第一种:sync
// 当前时间大于(上次刷新的时间+窗口长度)
// 说明窗口已经过期了,需要刷新窗口
if (now > lastResetTime.get() + windowInfo.getLength()) {
// dcl
synchronized (this) {
if (now > lastResetTime.get() + windowInfo.getLength()) {
// 刷新许可
allows.set(windowInfo.getCount());
// 刷新更新时间
lastResetTime.set(now);
}
}
}
// 第二种:cas+自旋
// 当前时间大于(上次刷新的时间+窗口长度)
// 说明窗口已经过期了,需要刷新窗口
while (now > lastResetTime.get() + windowInfo.getLength()) {
// cas成功
if (updating.compareAndSet(false, true)) {
// 双重检查
if (now > lastResetTime.get() + windowInfo.getLength()) {
// 刷新许可
allows.set(windowInfo.getCount());
// 刷新更新时间
lastResetTime.set(now);
updating.set(false);
}
}
// cas失败,yield
else {
Thread.yield();
}
}
后面选择了sync,而没有选择cas+自旋
这里考虑的是每个请求都要该经过方法,在高并发下,如果窗口过期,可能会导致大量的线程进行cas+自旋,占用cpu资源,所以采用了sync,让一个线程更新,其他线程阻塞,会更加合适
滑动窗口
滑动窗口算法的核心是:在滑动窗口中分两种窗口,一种是滑动窗口,一种是样本窗口,一个滑动窗口中有多个样本窗口,一个请求会根据它的时间戳,被路由到相应的样本窗口,然后在样本窗口中进行统计;滑动窗口需要清除掉已经过期的样本窗口,也就是重置它的统计量
SlidingWindowInfo
在SlidingWindowInfo中,新增了size字段
size:滑动窗口中样本窗口的数量
另外原本的length和count的语义也变成了滑动窗口的长度和滑动窗口的许可
而样本窗口的长度等于滑动窗口的长度/size,样本窗口的许可等于滑动窗口的许可/size
java
public class SlidingWindowInfo extends WindowInfo {
// 滑动窗口的长度
// private final long length;
// 滑动窗口允许通过的请求的大小
// private final int count;
// 限流的粒度 方法名,ip
// private final String key;
// 滑动窗口中样本窗口的数量
// 样本窗口数量不能小于1
// 同样也不应该小于count,否则样本窗口中分配不到请求通过的许可,如10/100=0
// 尽可能得能够被length和count整除,否则会向下取整造成精度损失
private final int size;
public int getSize() {
return size;
}
public SlidingWindowInfo(long length, int count, String key, int size) {
super(length, count, key);
this.size = size;
}
public static SlidingWindowInfo create(long length, int count, String key, int size) {
return new SlidingWindowInfo(length, count, key, Math.max(size, 1)/* 样本窗口数量不能小于1 */);
}
}
SampleWindowInfo
以下是样本窗口的窗口信息,只有count和length
java
@Data
public class SampleWindowInfo {
int count;
long length;
public SampleWindowInfo(long length, int count) {
this.length = length;
this.count = count;
}
public static SampleWindowInfo create(long length, int count) {
return new SampleWindowInfo(length, count);
}
}
SampleWindow
样本窗口的Stat用于统计样本窗口当前的许可数以及样本窗口的时间id
时间id指的是这个样本窗口在整个时间戳上的序号,用 当前时间戳 / 样本窗口长度 即可得到
java
class SampleWindow {
static class Stat implements AbstractWindow.Stat {
private final SampleWindowInfo sampleWindowInfo;
// 时间id
private final AtomicLong timeId;
// 窗口当前的允许的请求量
private final AtomicInteger allows;
Stat(SampleWindowInfo sampleWindowInfo, long timeId) {
this.sampleWindowInfo = sampleWindowInfo;
this.timeId = new AtomicLong(timeId);
this.allows = new AtomicInteger(sampleWindowInfo.getCount());
}
@Override
public boolean allowable() {
return allows.get() >= 0 && allows.decrementAndGet() >= 0;
}
boolean updateWindowTimeIdCas(long oldTimeId, long newTimeId) {
return timeId.compareAndSet(oldTimeId, newTimeId);
}
// 线程不安全,外层使用cas保证线程安全
void resetCount() {
allows.set(sampleWindowInfo.getCount());
}
public SampleWindowInfo getSampleWindowInfo() {
return sampleWindowInfo;
}
public AtomicLong getTimeId() {
return timeId;
}
public AtomicInteger getAllows() {
return allows;
}
}
}
SlidingWindow
滑动窗口算法的实现参考了sentinel的滑动窗口算法
大致步骤如下:
- 根据请求进入的时间戳,计算当前请求的时间ID
- 根据时间ID路由到某一个样本窗口(这里采用的是取模法,可以扩展成其他算法)
- 根据时间ID,计算出请求应该打在滑动窗口中的哪一个样本窗口
- 如果样本窗口不存在,创建样本窗口(初始化case)
- 如果样本窗口未过期,样本窗口判断请求是否通过,通过则统计该请求
- 如果样本窗口已过期,将该样本窗口的count置0,更新它的窗口,模拟滑动的效果
- 样本窗口判断请求是否通过,如果通过,则统计请求
java
public class SlidingWindow extends AbstractWindow implements Window {
public static class Stat implements AbstractWindow.Stat {
// 样本窗口数组
AtomicReferenceArray<SampleWindow.Stat> sampleWindowStatArray;
SlidingWindowInfo slidingWindowInfo;
SampleWindowInfo sampleWindowInfo;
public Stat(SlidingWindowInfo slidingWindowInfo) {
long length = slidingWindowInfo.getLength();
int count = slidingWindowInfo.getCount();
int size = slidingWindowInfo.getSize();
this.slidingWindowInfo = slidingWindowInfo;
this.sampleWindowInfo = SampleWindowInfo.create(length / size, count / size);
this.sampleWindowStatArray = new AtomicReferenceArray<>(size);
}
@Override
public boolean allowable() {
return currentSampleWindowStat().allowable();
}
/**
* 获取时间id,当前时间除以样本窗口的长度
*/
private long getTimeId(long timeMillis) {
return timeMillis / sampleWindowInfo.length;
}
/**
* 根据时间id路由到某一个样本窗口
* 这里使用的是取模法,可以替换为其他算法
*
* @param timeId
* @return
*/
private int routeSampleStatIndex(long timeId) {
return (int) (timeId % sampleWindowStatArray.length());
}
/**
* 计算请求打在哪一个样本窗口中
*
* @return
*/
private SampleWindow.Stat currentSampleWindowStat() {
// 获取当前时间
long timeMillis = System.currentTimeMillis();
// 计算当前时间的时间Id
long timeId = getTimeId(timeMillis);
// 通过取模的方式获取该时间Id会落在滑动窗口的哪一个样本窗口上
int sampleStatIndex = routeSampleStatIndex(timeId);
while (true) {
// 获取到当前时间所在的样本窗口
SampleWindow.Stat sampleStat = sampleWindowStatArray.get(sampleStatIndex);
// 若当前时间所在样本窗口为null,说明还不存在,需要创建一个
if (sampleStat == null) {
// 创建一个样本窗口
SampleWindow.Stat initSampleStat = new SampleWindow.Stat(sampleWindowInfo, timeId);
if (sampleWindowStatArray.compareAndSet(sampleStatIndex, null, initSampleStat)) {
// 创建成功返回窗口
return initSampleStat;
} else {
Thread.yield();
}
}
// 若当前样本窗口的id与旧的样本窗口的id相同
// 则说明这两个是同一个样本窗口
else if (timeId == sampleStat.getTimeId().get()) {
return sampleStat;
}
// 若当前样本窗口的id不等于旧的样本窗口的id
// 则说明旧的样本窗口已经过时了,需要使用cas的方式将旧的样本窗口替换(更新窗口id,并重置count)
else if (timeId != sampleStat.getTimeId().get()) {
long oldTimeId = sampleStat.getTimeId().get();
// 替换窗口的时间id,模拟滑动的效果
if (oldTimeId != timeId && sampleStat.updateWindowTimeIdCas(oldTimeId, timeId)) {
sampleStat.resetCount();
return sampleStat;
} else {
Thread.yield();
}
}
}
}
}
}
测试
简单测试一下
固定窗口算法
java
public class Main {
public static void main(String[] args) throws InterruptedException {
FixWindow window = new FixWindow();
System.out.println(window.allowable(FixWindowInfo.create(1000, 10, "1")));
TimeUnit.MILLISECONDS.sleep(998);
for (int i = 0; i < 10; i++) {
System.out.println(window.allowable(FixWindowInfo.create(1000, 10, "1")));
// TimeUnit.MILLISECONDS.sleep(100);
}
TimeUnit.MILLISECONDS.sleep(200);
for (int i = 0; i < 10; i++) {
System.out.println(window.allowable(FixWindowInfo.create(1000, 10, "1")));
// TimeUnit.MILLISECONDS.sleep(100);
}
}
}
java
true
true
true
true
true
true
true
true
true
true
false
true
true
true
true
true
true
true
true
true
true
可以观察到固定窗口确实会存在放行流量超过双倍阈值的问题
滑动窗口算法
java
public class Main {
public static void main(String[] args) throws InterruptedException {
SlidingWindowInfo slidingWindowInfo = SlidingWindowInfo.create(1000, 10, "1", 2);
SlidingWindow window1 = new SlidingWindow();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (true) {
System.out.println(window1.allowable(slidingWindowInfo));
try {
TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextInt(500, 1500));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
java
true
true
false
true
true
true
false
false
false
false
true
true
true
true
true
true
true
true
true
false
false
false
...
总结
以上即固定窗口和滑动窗口算法的具体实现,总的来说,Window被我抽象成了窗口的框架,而Stat才是具体的统计类
那这么看来FixWindow和SlidingWindow类更像是一个工具类,那么为什么不直接设计成静态的工具类呢?
因为个人认为,窗口是一个通用的组件,比如熔断中可能会使用窗口,限流中也可能使用到窗口,这两种窗口是虽然实现上区别不大,但是它们是属于不同的场景,如果是静态的工具类,那么熔断场景和限流场景都使用了相同的key,那么两个场景会相互影响,所以熔断场景使用一个窗口对象,限流场景使用一个窗口对象,可以杜绝这种情况发生