手写限流算法-窗口篇:固定窗口&滑动窗口

前言

在分布式系统中,流量控制是一个非常重要的环节

流量控制通过限制每个用户调用API的频率来保护API免受无意或恶意的过度使用。如果没有速率限制,每个用户可以随心所欲地发出请求,导致请求的"峰值",令其他用户"饥饿"。

流量控制有以下好处:

  1. 保护服务:可以提高服务的可用性
  2. 成本管理:可用于成本控制,如减少因测试或错误配置等意外事故造成的开销
  3. 管理资源分配策略:使多个用户公平地共享一个服务
  4. 安全性:可以用来防御或缓解一些常见的攻击,如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的滑动窗口算法

大致步骤如下:

  1. 根据请求进入的时间戳,计算当前请求的时间ID
  2. 根据时间ID路由到某一个样本窗口(这里采用的是取模法,可以扩展成其他算法)
  3. 根据时间ID,计算出请求应该打在滑动窗口中的哪一个样本窗口
    • 如果样本窗口不存在,创建样本窗口(初始化case)
    • 如果样本窗口未过期,样本窗口判断请求是否通过,通过则统计该请求
    • 如果样本窗口已过期,将该样本窗口的count置0,更新它的窗口,模拟滑动的效果
  4. 样本窗口判断请求是否通过,如果通过,则统计请求
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,那么两个场景会相互影响,所以熔断场景使用一个窗口对象,限流场景使用一个窗口对象,可以杜绝这种情况发生

相关推荐
跟着珅聪学java19 分钟前
spring boot +Elment UI 上传文件教程
java·spring boot·后端·ui·elementui·vue
強云20 分钟前
界面架构- MVP(Qt)
qt·架构
徐小黑ACG1 小时前
GO语言 使用protobuf
开发语言·后端·golang·protobuf
想跑步的小弱鸡3 小时前
Leetcode hot 100(day 3)
算法·leetcode·职场和发展
战族狼魂4 小时前
CSGO 皮肤交易平台后端 (Spring Boot) 代码结构与示例
java·spring boot·后端
xyliiiiiL5 小时前
ZGC初步了解
java·jvm·算法
杉之5 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
爱的叹息5 小时前
RedisTemplate 的 6 个可配置序列化器属性对比
算法·哈希算法
hycccccch6 小时前
Canal+RabbitMQ实现MySQL数据增量同步
java·数据库·后端·rabbitmq
独好紫罗兰6 小时前
洛谷题单2-P5713 【深基3.例5】洛谷团队系统-python-流程图重构
开发语言·python·算法