Files
cs-note/hzh/REDIS/滑动窗口计数器.md
T

140 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [redis, rate-limiting, sliding-window-counter, contribution]
create time: 2026-06-01 10:30
---
# 滑动窗口计数器:加权贡献度详解
## 概述
固定窗口计数器在边界处会出现**突放量达到限制两倍**的问题。滑动窗口计数器的改进思路很直观:**把总窗口切分成 N 个子窗口,用一个加权公式估算当前窗口的总量**。
本节深入讲解它的核心——**贡献度计算**(对应选择题 Q11),以及它在生产中的实际实现方式。
## 一、基本模型
假设总窗口是 1 秒,切成 N=10 个子窗口(每个 100ms):
```mermaid
flowchart TD["滑动窗口计数器 - N=10 子窗口"]
subgraph Window["1 秒总窗口"]
direction LR
W1["W1<br/>已过期的部分 × 占比"]
W2["W2<br/>已过期的部分 × 占比"]
W3["W3<br/>已过期的部分 × 占比"]
W4["W4<br/>当前完整窗口 → 100%"]
end
Current["当前时刻"] --> Pos["位于第 8 个子窗口"]
Pos --> Calc["total = Σ(各子窗口贡献)"]
```
| 子窗口位置 | 计入比例 | 说明 |
|-----------|---------|------|
| **已过期的前 M-1 个窗口** | 剩余时间比例折算 | `count × (剩余未过期时间 / 总窗口大小)` |
| **当前正在进行的窗口 M** | 100% 全计 | `count × 1.0` |
| **未来的窗口** | 0% | 还未发生,不计入 |
> [!question]- 💡 思考:为什么要加权?
>
> 想象一个场景:某个用户在第 1 秒内发了 100 个请求,但在第 2 秒开始时这些请求"还剩多少"仍然算在当前滑动窗口内?显然是零——因为完全过期了。但如果某个用户在上一个子窗口的末尾发的请求,它只有一小部分时间已经超出当前窗口。这种"活了多少"的差别,就是加权要解决的问题。
## 二、贡献度计算(核心知识点 🔑)
这是你最薄弱的一环,我们用具体数字来演示。
### 2.1 Q11 考点:一句话答案
> **已过期的窗口按其剩余未过期时间占总窗口大小的比例折算贡献度。**
>
> 例如:总窗口 10s,某个子窗口已过 8s 只剩 2s 仍在窗口内 → 贡献度 = count × 2/10
这在 Q11 选择题中对应答案 **C. 按剩余时间比例折算**。
### 2.2 数值演示
让我们看一个完整的例子来加深理解:
```
总窗口 W = 10s,N = 10 个子窗口,每窗口 1s
当前时刻:t = 7.5s(第 8 个子窗口内,进度 50%)
滑动窗口范围:t ∈ [7.5 - 10, 7.5] = [-2.5, 7.5]
各个窗口在当前窗口内的存活时间:
W8 (7~8s) : 存活 0.5s → 占比 5%/10s = 0.05
W7 (6~7s) : 存活 1.0s → 占比 1.0s/10s = 0.10
W6 (5~6s) : 存活 1.0s → 占比 0.10
...(中间同理)...
W-1(-2~-1s): 存活 0.5s → 占比 0.05
```
在这个场景中,`total = W8.count × 0.05 + W7.count × 0.10 + ... + W-1.count × 0.05`
### 2.3 实际生产实现
在实际代码中,大多数实现维护 N 个槽位(数组),循环覆盖。这样不需要记录复杂的时间线:
```go
// 假设 N=10,每 100ms 一个子窗口
type SlidingWindowCounter struct {
slots [10]int64 // 10 个子窗口
slotIdx int // 当前子窗口索引
windowMs int64 // 总窗口 1000ms
}
func (sw *SlidingWindowCounter) TryAcquire() bool {
now := time.Now().UnixMilli()
// 计算当前属于哪个子窗口
currentSlot := int(now % sw.windowMs / (sw.windowMs / int64(len(sw.slots))))
// 计算滑动窗口内的总请求数
var total float64
for i := 0; i < len(sw.slots); i++ {
if i == currentSlot {
// 当前窗口:100% 计入
total += float64(sw.slots[i])
} else if i < currentSlot {
// 之前的窗口:按相对位置递减权重
diff := currentSlot - i
weight := float64(len(sw.slots)-diff) / float64(len(sw.slots))
total += float64(sw.slots[i]) * weight
}
// i > currentSlot:未来窗口,weight = 0
}
return total < maxCount
}
```
**关键公式:**
```
slot[i] 的贡献度 = count[i] × weight[i]
其中 weight[i] = (len(slots) - (currentSlot - i)) / len(slots),对 i < currentSlot
```
举例(N=10,当前在第 8 个槽):
- slot[7](上一个完整窗口): weight = (10-1)/10 = 0.9
- slot[6]: weight = (10-2)/10 = 0.8
- slot[0](最早的那个): weight = (10-8)/10 = 0.2
> [!note]- 两种实现的等价性
>
> 上面的"槽位差值权重"实现和前面"基于时间的剩余比例折算"在数学上是等价的。槽位差值实现是工程上的简化版本,省去了精确时间线的维护,但效果一致——离当前越近的窗口权重越高。
## 三、三种窗口算法对比
| 维度 | 固定窗口 | 滑动窗口计数器 | 滑动窗口日志 |
|------|---------|-------------|------------|
| **精度** | 低(可突破 2×) | 中(≈1/N 误差) | 高(精确到每条) |
| **空间** | O(1) | O(N) 子窗口 | O(maxQPS × window) |
| **突放量** | 2× 阈值 | ≤ (1+1/N)× 阈值 | 精确控制 |
| **适用场景** | 日配额、粗略统计 | 毫秒级精准限流 | 低频次高精场景 |
## 关联笔记
- [[分布式限流]] — 完整的限流算法演进路线
- [[路由键与Hash Tag]] — Cluster 环境下 Key 的路由设计