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

5.2 KiB
Raw Blame History

tags, create time
tags create time
redis
rate-limiting
sliding-window-counter
contribution
2026-06-01 10:30

滑动窗口计数器:加权贡献度详解

概述

固定窗口计数器在边界处会出现突放量达到限制两倍的问题。滑动窗口计数器的改进思路很直观:把总窗口切分成 N 个子窗口,用一个加权公式估算当前窗口的总量。

本节深入讲解它的核心——贡献度计算(对应选择题 Q11),以及它在生产中的实际实现方式。

一、基本模型

假设总窗口是 1 秒,切成 N=10 个子窗口(每个 100ms):

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 个槽位(数组),循环覆盖。这样不需要记录复杂的时间线:

// 假设 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)× 阈值 精确控制
适用场景 日配额、粗略统计 毫秒级精准限流 低频次高精场景

关联笔记