4. 流控策略类型
说到限流,很多人第一反应就是计数器。但计数器有个硬伤——它在时间边界上会突然"归零",导致流量尖刺。我早年在一个抢购系统里就吃过这个亏,每秒限流1000,结果在整秒边界上瞬间涌进来2000个请求,系统直接打嗝。
滑动窗口,就是来解决这个问题的。
4.1 滑动窗口的核心原理
你想想看,计数器是"一刀切"的时间段。比如1秒一个窗口,第0.9秒来了1000个请求,第1.1秒又来了1000个,虽然它们实际间隔只有0.2秒,但计数器认为它们属于两个不同的窗口,全放过去了。
滑动窗口不这么干。它把时间切得更细,比如把1秒切成10个100ms的小格子。窗口不是固定从整秒开始,而是随着时间"滑"过去的。每个请求进来,窗口就往前挪一点,始终只统计最近1秒内的数据。
核心思想:窗口的起始时间不是固定的,而是以当前时间为终点,向前推一个窗口长度。这样就不会出现"边界突刺"。
我画了一张图,你看一眼就明白了:
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脚本是标配。别一上来就上令牌桶,先问问自己:我真的需要那个"突发能力"吗?