4. 流控策略类型

说到限流,很多人第一反应就是计数器。但计数器有个硬伤——它在时间边界上会突然"归零",导致流量尖刺。我早年在一个抢购系统里就吃过这个亏,每秒限流1000,结果在整秒边界上瞬间涌进来2000个请求,系统直接打嗝。

滑动窗口,就是来解决这个问题的。

4.1 滑动窗口的核心原理

你想想看,计数器是"一刀切"的时间段。比如1秒一个窗口,第0.9秒来了1000个请求,第1.1秒又来了1000个,虽然它们实际间隔只有0.2秒,但计数器认为它们属于两个不同的窗口,全放过去了。

滑动窗口不这么干。它把时间切得更细,比如把1秒切成10个100ms的小格子。窗口不是固定从整秒开始,而是随着时间"滑"过去的。每个请求进来,窗口就往前挪一点,始终只统计最近1秒内的数据。

核心思想:窗口的起始时间不是固定的,而是以当前时间为终点,向前推一个窗口长度。这样就不会出现"边界突刺"。

我画了一张图,你看一眼就明白了:

滑动窗口原理示意图 固定窗口(有边界突刺) 窗口1(0~1s) 窗口2(1~2s) 边界突刺 滑动窗口(平滑移动) 当前窗口(最近1秒) 时间方向 → 每个小格子代表一个子时间片(如100ms),窗口随当前时间滑动,只统计最近N个子片

4.2 窗口大小与限流精度

这里有个关键问题:窗口切多细才合适?

说白了,这是一个精度和性能的权衡。

子窗口数量 精度 内存开销 适用场景
1(退化为计数器) 低 极低 对精度无要求,资源紧张
10 中 低 大部分业务场景
100 高 中 对流量抖动敏感的场景
1000+ 极高 高 高频交易、实时计费

我个人习惯,一般取10~20个子窗口。为什么?因为再细下去,收益就边际递减了。我曾经在一个项目里试过切1000个格子,结果发现限流曲线和切100个几乎一样,但CPU多吃了不少。

经验值:子窗口数量 = 窗口总时长 ÷ 期望的精度粒度。比如窗口1秒,希望精度到100ms,那就切10个。别盲目追求细粒度。

4.3 三种主流实现方式

实现滑动窗口,我见过三种路子。每种都有它的脾气。

4.3.1 基于循环数组

这是最高效的方式。用一个固定长度的数组,每个元素存一个子窗口的计数。再加一个总计数器和一个指针,指向当前子窗口。

// 伪代码示例:循环数组实现滑动窗口
class SlidingWindow {
    int[] buckets;      // 子窗口计数数组
    int bucketSize;     // 每个子窗口的时长(ms)
    int totalCount;     // 当前窗口总计数
    int currentIndex;   // 当前子窗口索引
    long lastTime;      // 上次更新时间戳

    boolean allowRequest(int limit) {
        long now = System.currentTimeMillis();
        // 滑动:把过期的子窗口清零
        while (now - lastTime >= bucketSize) {
            int nextIndex = (currentIndex + 1) % buckets.length;
            totalCount -= buckets[nextIndex];
            buckets[nextIndex] = 0;
            currentIndex = nextIndex;
            lastTime += bucketSize;
        }
        // 判断是否超限
        if (totalCount >= limit) {
            return false;  // 被限流
        }
        buckets[currentIndex]++;
        totalCount++;
        return true;
    }
}

这个实现的好处是O(1)的时间复杂度,内存也固定。我在一个日活千万的网关里用的就是这种方案,单机扛住了每秒5万次调用。

4.3.2 基于时间轮

时间轮其实和循环数组是一家人,但它更灵活。你可以把子窗口放在一个环形链表里,每个格子可以挂一个任务列表。

嗯,这里要注意:时间轮适合那种"需要精确到毫秒级"的场景。比如你不仅要统计请求数,还要在某个时间点触发回调。但如果你只是做限流,用循环数组就够了,别给自己加戏。

4.3.3 基于有序集合(Redis ZSet)

这是分布式场景下的常用方案。每个请求的时间戳作为score,存到ZSet里。统计时,用ZREMRANGEBYSCORE删掉过期的,再用ZCARD数一下剩下的。

// Redis ZSet 实现滑动窗口
// 每次请求执行:
ZREMRANGEBYSCORE rate_limiter:user_123 0 (now - 1000)
ZCARD rate_limiter:user_123
// 如果返回值 < 限制数,则:
ZADD rate_limiter:user_123 now now
// 否则拒绝请求

避坑指南:我曾经在一个项目里直接用ZSet做限流,结果QPS一上去,Redis的CPU就飙到90%。后来发现是ZREMRANGEBYSCORE在大量数据下性能不行。解决方案:配合Lua脚本做原子操作,并且定期清理过期数据。

4.4 滑动窗口 vs 其他算法

我把几种常见限流算法放在一起对比一下,你心里就有数了:

算法 精度 内存开销 突发处理 实现复杂度
固定窗口计数器 低 极低 差(有边界突刺) 简单
滑动窗口 中~高 低~中 好 中等
漏桶算法 高 低 差(强制平滑) 简单
令牌桶算法 高 低 好(允许突发) 中等

滑动窗口最大的优势,就是它在"精度"和"实现成本"之间找到了一个很好的平衡点。你不需要像令牌桶那样维护一个复杂的令牌生成逻辑,也不需要像漏桶那样排队等待。说白了,它就是计数器的一个"高配版"。

总结一下:滑动窗口适合大多数业务限流场景。如果你对精度要求不是变态高,用10~20个子窗口的循环数组实现,性价比最高。如果要做分布式限流,Redis ZSet + Lua脚本是标配。别一上来就上令牌桶,先问问自己:我真的需要那个"突发能力"吗?