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

前言

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

流量控制通过限制每个用户调用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,那么两个场景会相互影响,所以熔断场景使用一个窗口对象,限流场景使用一个窗口对象,可以杜绝这种情况发生

相关推荐
sp_fyf_202422 分钟前
计算机前沿技术-人工智能算法-大语言模型-最新研究进展-2024-10-02
人工智能·神经网络·算法·计算机视觉·语言模型·自然语言处理·数据挖掘
韩楚风1 小时前
【linux 多进程并发】linux进程状态与生命周期各阶段转换,进程状态查看分析,助力高性能优化
linux·服务器·性能优化·架构·gnu
杨哥带你写代码1 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
AskHarries2 小时前
读《show your work》的一点感悟
后端
我是哈哈hh2 小时前
专题十_穷举vs暴搜vs深搜vs回溯vs剪枝_二叉树的深度优先搜索_算法专题详细总结
服务器·数据结构·c++·算法·机器学习·深度优先·剪枝
A尘埃2 小时前
SpringBoot的数据访问
java·spring boot·后端
Tisfy2 小时前
LeetCode 2187.完成旅途的最少时间:二分查找
算法·leetcode·二分查找·题解·二分
yang-23072 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端
Marst Code2 小时前
(Django)初步使用
后端·python·django
代码之光_19802 小时前
SpringBoot校园资料分享平台:设计与实现
java·spring boot·后端