滑动窗口算法
固定窗口的边界突发问题,来自于把时间切成一个个区块。一个请求要么落在这个区块里,要么落在那个区块里,而跨越区块交界处的一段时间,可能会承受两倍于限额的流量。
不再使用区块,问题自然消失。这里的两种算法,都以"以正在判断的这个请求为终点"的窗口来度量流量,它们的区别只在于各自记住了多少信息。
滑动窗口日志
为每一个被接受的请求都保留一个时间戳。当新请求到达时,向前回溯一个窗口长度,数一数里面有多少个请求,再做判断。
窗口是每次都重新计算的,所以它会跟在每个请求后面移动。还是同一个停车场的例子,三十秒窗口,限额为五:
| 时间 | 到达数 | 窗口回溯到 | 窗口内计数 | 结果 |
|---|---|---|---|---|
| 0秒 | 1 | 0秒 | 0 | 接受 |
| 5秒 | 2 | 0秒 | 1 | 两个都接受,计数变为3 |
| 15秒 | 2 | 0秒 | 3 | 两个都接受,计数变为5 |
| 25秒 | 1 | 0秒 | 5 | 拒绝,已达限额 |
| 35秒 | 1 | 5秒 | 4 | 接受,0秒的请求已过期移出窗口 |
| 40秒 | 3 | 10秒 | 3 | 两个接受,第三个拒绝 |
在35秒时,窗口起点是5秒,所以来自0秒的请求已经落在窗口之外,不再计入。这种"逐渐过期"正是整个机制的核心:容量是随着旧请求陆续退出窗口而逐步恢复的,而不是像重置那样一次性全部恢复。
任何连续三十秒的时间段内,请求数都不会超过五个。这正是固定窗口无法保证的事。
而这里的边界跟在正在判断的请求后面,每次都会移动。没有人会经历"重置",也没有人需要等待重置发生。
滑动窗口计数器
日志算法的代价在于那一串时间戳。计数器用两个整数就换来了大部分的精确度。
回到固定窗口的思路:为当前窗口和上一个窗口各保留一个计数,然后根据上一个窗口还有多少比例仍落在滑动回溯范围内,给它的计数加权:
x = 我们处于当前窗口的进度,用小数表示
weighted = (1 - x) * previousCount + currentCount
若 weighted + 1 <= limit 则接受以三十秒窗口、限额为5、且上一个窗口已经满额为例:
| 时间 | 进入窗口的时长 | 1 - x | 加权计数 | 加上这次请求 | 结果 |
|---|---|---|---|---|---|
| 35秒 | 5秒,即1/6 | 5/6 | 5/6 × 5 + 0 = 4.17 | 5.17 | 拒绝,超过5 |
| 40秒 | 10秒,即1/3 | 2/3 | 2/3 × 5 + 0 = 3.33 | 4.33 | 接受 |
| 40秒(再次) | 10秒,即1/3 | 2/3 | 2/3 × 5 + 1 = 4.33 | 5.33 | 拒绝 |
(1 - x) * previousCount 这一项就是估算值:随着当前窗口逐渐填满,上一个窗口留在视野内的部分越来越少,它的贡献也就平滑地淡出。
每个客户端只需两个计数器,完全不用时间戳。
它没有去检查哪些具体请求还落在范围内,而是假设它们是均匀分布的,直接取一个比例。这样更便宜,而且结果也八九不离十。
两者分歧之处
计数器假设上一个窗口的请求是均匀分布的。但现实中它们很少真的均匀分布,而哪个算法更宽松,取决于请求实际聚集在什么位置。
请求聚集在上一个窗口靠前的位置。 日志算法会真实地回溯三十秒,发现这些较早的请求已经落在窗口之外,于是放行更多请求。而计数器仍然会把它们的一部分算进去,因此会拒绝。
这种情况下日志算法更宽松,因为计数器把容量看得比实际情况更紧张。
请求聚集在上一个窗口靠后的位置。 这时日志算法仍然能看到它们,因而拒绝;而计数器已经把上一个窗口的大部分计数淡出了。这种情况下计数器更宽松,因为它低估了那些流量发生的时间有多近。
所以误差是双向的,这正是它可以被接受的原因。它并不是系统性地偏袒客户端或偏袒服务器中的某一方。
有时这个猜测比较宽松,有时比较严格,而具体是哪种,完全取决于那些请求真实到达的时间点。
动手试一试
三十秒窗口,限额为5,上一个窗口已经接受了5个请求。当前窗口目前还没有任何请求。
- 用计数器算法判断,33秒时的一个请求会被接受吗?
- 当前窗口进行到什么时刻,第一个请求才会被接受?
- 流量条件不变,但上一个窗口的5个请求全部发生在它的第一秒内。33秒时的请求,哪种算法会放行?为什么?
对照一下你的答案
1. 拒绝。 在33秒时,你处于这个三十秒窗口的第3秒,所以 x 是1/10。加权计数是 0.9 × 5 + 0 = 4.5,再加上这次请求得到5.5,超过了限额5。
2. 在36秒时。 你需要满足 (1 - x) × 5 + 1 <= 5,也就是 (1 - x) × 5 <= 4,即 1 - x <= 0.8,也就是 x >= 0.2。三十秒窗口的五分之一是6秒,所以第一个可被接受的请求出现在36秒。
3. 日志算法会放行;计数器仍然拒绝。 日志算法回溯到3秒。之前的五个请求全部发生在这之前,所以它们都已经过期移出窗口,计数为0。
计数器完全不知道它们具体发生在什么时候,只能假设均匀分布,仍然把4.5计入其中。这正是这种近似算法要付出的实际代价:客户端在过去三十秒内其实什么都没发送,却依然被拒绝了。
接下来的方向
这两种算法都在强制执行一个平均值,都把突发流量当成需要防止的东西。但很多突发流量其实是正常合理的——比如一个页面同时加载六个资源,这并不是滥用行为。
令牌桶采取了相反的思路:它有意允许突发流量,然后让客户端花时间去"赚取"下一次突发的资格。

