系统设计面试 | 实现一个限流器:滑动窗口 → 令牌桶 → 漏桶

你的API被刷爆了怎么办?限流不是产品经理的拍脑袋,而是一套明确的算法。


场景:开放API被刷了

你写了一个免费天气查询API:https://api.weather.com/current?city=beijing

第三天,有人写了个爬虫每秒请求1000次。你的服务器CPU飙升,数据库连接池爆满,其他用户全部超时。

老板问:怎么解决?

你说:"限流------每个IP每分钟最多60次。"

老板:"实现一个。"

于是你开始思考:怎么限?


一、最简单但最坑:固定窗口计数器

1.1 思路

把时间切成固定长度窗口(比如1分钟),每个窗口内维护一个计数器。

  • 请求来了,看当前在哪个窗口

  • 如果计数器 < 限制,允许并+1

  • 否则拒绝

1.2 代码

java 复制代码
public class FixedWindowRateLimiter {
    private final int limit;
    private final long windowSizeMs;
    private long currentWindowStart;
    private int counter;
    
    public FixedWindowRateLimiter(int limitPerMinute) {
        this.limit = limitPerMinute;
        this.windowSizeMs = 60_000;
        this.currentWindowStart = System.currentTimeMillis();
        this.counter = 0;
    }
    
    public synchronized boolean allow() {
        long now = System.currentTimeMillis();
        if (now - currentWindowStart >= windowSizeMs) {
            // 进入下一个窗口
            currentWindowStart = now;
            counter = 0;
        }
        if (counter < limit) {
            counter++;
            return true;
        }
        return false;
    }
}

1.3 致命缺陷:窗口边界突发

假设限制是1分钟60次。看这个时间线:

复制代码
窗口1 [00:00:00, 00:00:60)  : 第59秒时来了60次 → 允许
窗口2 [00:01:00, 00:02:00)  : 第61秒时又来了60次 → 允许

结果:1秒内(第59秒~第61秒)涌入了120次请求!

这就是边界突发问题。攻击者可以卡在窗口交界处打爆你的系统。


二、改进版:滑动窗口计数器

2.1 核心思想

不切死窗口,而是用当前时间往前看一个窗口长度,计算这个滑动区间内的总请求数。

2.2 实现方式1:滑动窗口日志(精确但耗内存)

存储每个请求的时间戳,每次请求时删除窗口外的旧时间戳,然后判断剩余数量。

java 复制代码
public class SlidingWindowLogRateLimiter {
    private final int limit;
    private final long windowSizeMs;
    private final Queue<Long> timestamps = new LinkedList<>();
    
    public SlidingWindowLogRateLimiter(int limitPerMinute) {
        this.limit = limitPerMinute;
        this.windowSizeMs = 60_000;
    }
    
    public synchronized boolean allow() {
        long now = System.currentTimeMillis();
        // 移除窗口外的时间戳
        while (!timestamps.isEmpty() && timestamps.peek() < now - windowSizeMs) {
            timestamps.poll();
        }
        if (timestamps.size() < limit) {
            timestamps.offer(now);
            return true;
        }
        return false;
    }
}

优点 :精确
缺点:每个请求都要存储时间戳,内存占用高

2.3 实现方式2:滑动窗口计数器(推荐,更高效)

将窗口分成多个小格子(比如1分钟分成6个10秒的桶),用近似滑动。

原理:当前窗口的计数 = 上一个完整窗口的剩余部分比例 + 当前窗口已过部分。

公式:

复制代码
当前窗口计数 = 上一个窗口计数 * (上一个窗口未过期的比例) + 当前窗口计数
java 复制代码
public class SlidingWindowCounterRateLimiter {
    private final int limit;
    private final int windowSizeSec;
    private final int slotNum;           // 分成几个小格子
    private final long slotTimeMs;
    private final int[] counters;
    private long currentSlotTime;
    
    public SlidingWindowCounterRateLimiter(int limitPerMinute) {
        this.limit = limitPerMinute;
        this.windowSizeSec = 60;
        this.slotNum = 6;        // 分成6格,每格10秒
        this.slotTimeMs = windowSizeSec * 1000 / slotNum;
        this.counters = new int[slotNum];
        this.currentSlotTime = (System.currentTimeMillis() / slotTimeMs) * slotTimeMs;
    }
    
    public synchronized boolean allow() {
        long now = System.currentTimeMillis();
        long nowSlot = (now / slotTimeMs) * slotTimeMs;
        int slotIndex = (int)((nowSlot / slotTimeMs) % slotNum);
        
        // 如果跨格子了,清理落后的格子
        if (nowSlot != currentSlotTime) {
            long slotsPassed = (nowSlot - currentSlotTime) / slotTimeMs;
            for (long i = 1; i <= slotsPassed && i <= slotNum; i++) {
                int idx = (int)((slotIndex - i + slotNum) % slotNum);
                counters[idx] = 0;
            }
            currentSlotTime = nowSlot;
        }
        
        // 计算当前滑动窗口内的总计数
        int total = 0;
        for (int i = 0; i < slotNum; i++) {
            total += counters[i];
        }
        
        if (total < limit) {
            counters[slotIndex]++;
            return true;
        }
        return false;
    }
}

优点 :内存固定,性能高,精度可调(格子越多越精确)
工业界最常用 (如Redis的CL.THROTTLE就是类似思想)


三、令牌桶算法(允许突发)

3.1 原理

想象一个桶,以固定速率(比如每秒10个)往里面加令牌。请求来了,必须从桶里拿一个令牌;拿不到就拒绝。

桶有上限,允许积累令牌,所以能应对突发流量。

3.2 代码实现

java 复制代码
public class TokenBucketRateLimiter {
    private final long capacity;          // 桶容量
    private final long refillTokensPerSec; // 每秒补充令牌数
    private long availableTokens;
    private long lastRefillTime;
    
    public TokenBucketRateLimiter(long capacity, long refillTokensPerSec) {
        this.capacity = capacity;
        this.refillTokensPerSec = refillTokensPerSec;
        this.availableTokens = capacity;
        this.lastRefillTime = System.nanoTime();
    }
    
    public synchronized boolean allow() {
        refill();
        if (availableTokens > 0) {
            availableTokens--;
            return true;
        }
        return false;
    }
    
    private void refill() {
        long now = System.nanoTime();
        double secondsPassed = (now - lastRefillTime) / 1_000_000_000.0;
        long newTokens = (long)(secondsPassed * refillTokensPerSec);
        if (newTokens > 0) {
            availableTokens = Math.min(capacity, availableTokens + newTokens);
            lastRefillTime = now;
        }
    }
}

3.3 什么时候用令牌桶?

  • 允许短时间内的突发流量(比如10倍正常流量,持续几秒)

  • 比如用户登录接口:平时没人用,但早上8点可能有波高峰


四、漏桶算法(强制平滑)

4.1 原理

请求来了先进桶,桶以固定速率"漏出"处理。桶满了就丢弃新请求。

和令牌桶的区别

  • 令牌桶:放令牌,请求拿令牌 → 允许突发输出

  • 漏桶:请求进桶,以固定速率输出 → 强制平滑,不允许突发

4.2 代码实现

java 复制代码
public class LeakyBucketRateLimiter {
    private final long capacity;
    private final long leakRatePerSec;   // 每秒漏出多少
    private long water;                  // 当前桶里水量
    private long lastLeakTime;
    
    public LeakyBucketRateLimiter(long capacity, long leakRatePerSec) {
        this.capacity = capacity;
        this.leakRatePerSec = leakRatePerSec;
        this.water = 0;
        this.lastLeakTime = System.nanoTime();
    }
    
    public synchronized boolean allow() {
        leak();  // 先漏水
        if (water < capacity) {
            water++;
            return true;
        }
        return false;
    }
    
    private void leak() {
        long now = System.nanoTime();
        double secondsPassed = (now - lastLeakTime) / 1_000_000_000.0;
        long leaked = (long)(secondsPassed * leakRatePerSec);
        if (leaked > 0) {
            water = Math.max(0, water - leaked);
            lastLeakTime = now;
        }
    }
}

4.3 什么时候用漏桶?

  • 对流量平滑性要求极高,不允许任何突发

  • 比如下游是个只能处理匀速流量的老系统或第三方API


五、对比与选型建议

算法 突发能力 平滑性 实现复杂度 典型场景
固定窗口 差(边界双倍突发) 几乎不用
滑动窗口 一般(格子越细越好) 最通用,防护API被刷
令牌桶 强(可积累令牌) 允许突发,如登录、秒杀预热
漏桶 无(强制匀速) 匀速处理,如转码任务排队

一句话

  • 通用防刷 → 滑动窗口计数器

  • 需要应对突发流量 → 令牌桶

  • 下游只能匀速处理 → 漏桶


六、生产环境注意事项

  1. 分布式限流:上述算法都是单机版。分布式下需要中心化存储(Redis + Lua脚本保证原子性),或者每个节点独立限流(总限制 = 单机限制 * 节点数)。

  2. 拒绝策略:限流触发后不是只能返回429。可以:

    • 排队等待(漏桶天然支持)

    • 降级返回缓存数据

    • 让客户端稍后重试(带Retry-After头)

  3. 动态调整:限流阈值应该可配置,甚至根据系统负载自动调整。

  4. 监控:记录被限流的次数,如果频繁触发,说明阈值设低了或被攻击了。


写在最后

面试时遇到"设计限流器",按照这个顺序讲:

  1. 先说固定窗口的坑(边界突发)

  2. 引出滑动窗口(画个时间轴)

  3. 对比令牌桶和漏桶(一个允许突发,一个强制平滑)

  4. 最后根据场景给建议

再写几行核心代码,面试官会觉得:"这人不只会背题,真的动手写过。"

相关推荐
m0_463672201 小时前
Golang怎么获取当前工作目录_Golang如何用os.Getwd获取程序运行路径【基础】
jvm·数据库·python
2401_884454151 小时前
mysql如何处理大量重复值索引_mysql索引存储特征分析
jvm·数据库·python
环流_1 小时前
Redis中set类型以及应用场景
数据库·redis·缓存
kexnjdcncnxjs1 小时前
SQL批量删除不同条件的记录_使用IN子句简化删除逻辑
jvm·数据库·python
liux35281 小时前
Kafka 4.1.1 生产环境调优与最佳实践指南
数据库·分布式·kafka
吴声子夜歌1 小时前
Java——synchronized
java·synchronized
2303_821287381 小时前
如何安装Oracle 12c Cloud Control_OMS服务端组件与Agent部署
jvm·数据库·python
m0_609160491 小时前
React Flow 边缘错位与消失问题的根源分析与 Hooks 重构方案
jvm·数据库·python
weixin_444012931 小时前
CSS怎样调整弹性项目排列顺序_使用order属性轻松控制DOM显示顺序
jvm·数据库·python