专栏文章
专栏文章
调度与限流系列
1. 调度与限流 #01:时间轮(Hashed Timing Wheel) 2. 调度与限流 #02:层级时间轮 3. 调度与限流 #03:最小堆定时器 4. 调度与限流 #04:令牌桶 5. 调度与限流 #05:漏桶 6. 调度与限流 #06:滑动窗口计数器 7. 调度与限流 #07:滑动窗口日志

调度与限流 #06:滑动窗口计数器

发布于 2026-06-04 04:15 👁 10 次阅读
#算法#Redis#rate-limiting#distributed-systems#sliding-window

滑动窗口计数器将时间轴切成多个细粒度小格,始终统计最近 N 个格的请求总数,彻底消除固定窗口边界处双倍流量的突发问题;Nginx limit_req、Redis Cell、Sentinel 等限流组件均采用此思路。

相关文章令牌桶 · 漏桶 · 滑动窗口日志

sliding window counter


目录

章节 说明
问题背景 固定窗口的临界双倍突发,具体数字示例
核心数据结构 环形数组、currentSlot 指针、totalCount 字段定义
record(request) 完整伪代码 计算当前格、清除过期格、计数递增
isAllowed(limit) 伪代码 先 record 再判断 totalCount
执行追踪 窗口 1s / 10 格 / limit=100 的完整数值演示
Redis 实现方案 ZSET 精确滑动窗口 vs 多 key 近似桶方案
异常与边界场景 精度权衡、原子性、动态调窗、分布式聚合
滑动窗口 vs 令牌桶 vs 漏桶 三种限流模型的核心对比
参考资料 论文、文档、书籍

问题背景

固定窗口的临界双倍突发

固定窗口计数器最朴素:每个时间窗口(如 1 秒)独立计数,超过 limit 则拒绝。
它有一个不可忽视的缺陷——窗口边界处可以打出 2 倍流量

具体数字示例(limit = 100 req/s,窗口长度 = 1 s):

窗口 A:[00:00.000 ~ 00:01.000)   窗口 B:[00:01.000 ~ 00:02.000)
                           ↑ 边界
  00:00.900 ~ 00:01.000 内打入 100 个请求  → 窗口 A 恰好用完配额 ✓
  00:01.000 ~ 00:01.100 内打入 100 个请求  → 窗口 B 刚开始,配额重置 ✓

结果:200ms 内通过了 200 个请求,是 limit 的 2 倍!

固定窗口对每个窗口独立计数,完全感知不到跨窗口的累积速率。

滑动窗口计数器的解法

将单个大窗口(如 1 s)拆成 N 个等宽小格(sub-window),每格宽度 = windowSize / N
维护一个大小为 N 的环形数组,每格存一个计数器。
任意时刻,当前窗口 = 最近 N 个格,对这 N 个格的计数求和即为当前速率。

当时间推进,旧格自动"滑出"窗口并被清零,新格滑入,窗口始终覆盖最近 windowSize 时长,不存在硬性边界重置的问题。


核心数据结构

SlidingWindowCounter {
    buckets:        int[]   // 环形数组,长度 = N(N = windowSize / subWindowSize)
    currentSlot:    int     // 当前写入格的下标,范围 [0, N-1]
    lastSlotTime:   long    // currentSlot 对应的时间戳起点(毫秒),用于检测格切换
    totalCount:     int     // 最近 N 个格的请求总数(维护滚动和,避免每次遍历求和)
    windowSize:     long    // 整个滑动窗口的时长(毫秒),例如 1000
    subWindowSize:  long    // 每个小格的时长(毫秒),例如 100
    N:              int     // 格的数量 = windowSize / subWindowSize,例如 10
}

字段说明:

字段 类型 说明
buckets int[N] 环形数组,下标 i 对应第 i 个时间格的请求计数
currentSlot int 当前格下标;写满后自动 (currentSlot+1) % N 滚动
lastSlotTime long (ms) 当前格的起始时间戳;用于判断是否该切换到下一格
totalCount int 滚动维护的窗口内总计数;清格时减去旧值,增格时加 1
windowSize long 整个窗口长度,单位毫秒
subWindowSize long 单格宽度,单位毫秒;越小越精确,越大越省内存
N int 格数 = windowSize / subWindowSize;数组大小固定

初始状态:buckets 全为 0,currentSlot = 0lastSlotTime = now()totalCount = 0


record(request) 完整伪代码

function record(counter):
    lock(counter)
    try:
        now = currentTimeMillis()

        // --- 步骤 1:计算 now 落在哪个格 ---
        elapsed   = now - counter.lastSlotTime       // 距当前格起始过了多少 ms
        slotsDiff = elapsed / counter.subWindowSize  // 需要前进几格(整除)

        if slotsDiff >= counter.N:
            // 经过了完整窗口以上,全部格都已过期,直接清零
            for i in [0, N-1]:
                counter.buckets[i] = 0
            counter.totalCount   = 0
            counter.currentSlot  = (counter.currentSlot + slotsDiff) % counter.N
            counter.lastSlotTime = now - (now % counter.subWindowSize)
                                          // 对齐到格边界

        else if slotsDiff > 0:
            // 前进了 slotsDiff 格,逐一清除中间过期格
            for step in [1, slotsDiff]:
                nextSlot = (counter.currentSlot + step) % counter.N
                counter.totalCount  -= counter.buckets[nextSlot]  // 从总数中减去
                counter.buckets[nextSlot] = 0                      // 清零
            counter.currentSlot  = (counter.currentSlot + slotsDiff) % counter.N
            counter.lastSlotTime += slotsDiff * counter.subWindowSize

        // --- 步骤 2:在当前格计数 +1 ---
        counter.buckets[counter.currentSlot] += 1
        counter.totalCount                   += 1
    finally:
        unlock(counter)

设计要点


isAllowed(limit) 伪代码

function isAllowed(counter, limit):
    record(counter)               // 先推进窗口,清除过期格

    // record 内已持锁,totalCount 已是最新值
    // 注意:record 内部加锁,此处调用已在锁外;
    // 生产实现中可将 isAllowed 整体加锁,或将 totalCount 读取也纳入锁中
    lock(counter)
    try:
        return counter.totalCount <= limit
    finally:
        unlock(counter)

简化版(将 record 与判断合并在同一把锁内):

function isAllowed(counter, limit):
    lock(counter)
    try:
        now       = currentTimeMillis()
        slotsDiff = (now - counter.lastSlotTime) / counter.subWindowSize

        // 清除过期格(同 record 逻辑)
        if slotsDiff >= counter.N:
            fill_all_zero(counter)
        else if slotsDiff > 0:
            clear_expired_slots(counter, slotsDiff)

        // 判断是否允许
        if counter.totalCount >= limit:
            return false            // 已达上限,拒绝

        // 允许,计数 +1
        counter.buckets[counter.currentSlot] += 1
        counter.totalCount                   += 1
        return true
    finally:
        unlock(counter)

关键:判断与计数必须在同一把锁内完成,否则并发时多个线程同时读到 totalCount < limit,都执行 +1,导致超限。


执行追踪

参数windowSize = 1000msN = 10(每格 100ms),limit = 100
初始状态:buckets = [0,0,0,0,0,0,0,0,0,0]currentSlot = 0lastSlotTime = T0totalCount = 0

─────────────────────────────────────────────────────────────────
T0+0ms   : 60 个请求到来
  slotsDiff = 0,无格切换
  buckets[0] += 60  →  buckets = [60,0,0,0,0,0,0,0,0,0]
  totalCount = 60
  isAllowed(100)?  60 <= 100 → ✓ 全部放行

─────────────────────────────────────────────────────────────────
T0+50ms  : 30 个请求到来
  slotsDiff = 50/100 = 0,仍在格 0
  buckets[0] += 30  →  buckets = [90,0,0,0,0,0,0,0,0,0]
  totalCount = 90

─────────────────────────────────────────────────────────────────
T0+100ms : 20 个请求到来
  slotsDiff = 100/100 = 1,切换到格 1
  清除格 1(原值 0)→ totalCount 不变 = 90
  currentSlot = 1,lastSlotTime = T0+100ms
  buckets[1] += 20  →  buckets = [90,20,0,0,0,0,0,0,0,0]
  totalCount = 110
  isAllowed(100)?  110 > 100 → ✗ 拒绝(窗口内已有 110 个请求)

─────────────────────────────────────────────────────────────────
T0+200ms : 5 个请求到来
  slotsDiff = 1(切换到格 2),清除格 2(值 0)
  currentSlot = 2,totalCount = 110
  isAllowed(100)?  110 > 100 → ✗ 继续拒绝

─────────────────────────────────────────────────────────────────
T0+1100ms: 10 个请求到来
  slotsDiff = (1100-100)/100 = 10 = N,整窗口过期
  全部清零:buckets = [0,0,0,0,0,0,0,0,0,0],totalCount = 0
  currentSlot = (1 + 10) % 10 = 1,lastSlotTime = T0+1100ms
  buckets[1] += 10  →  totalCount = 10
  isAllowed(100)?  10 <= 100 → ✓ 放行

─────────────────────────────────────────────────────────────────
T0+1150ms: 30 个请求到来
  slotsDiff = 50/100 = 0,仍在格 1
  buckets[1] += 30  →  totalCount = 40
  isAllowed(100)?  40 <= 100 → ✓ 放行

  注意:即使 T0+100ms 附近曾经 totalCount=110,
  随着旧格[0](含 90 个请求)在 T0+1000ms 后完全滑出窗口,
  计数自然回落,无需手动清理。
─────────────────────────────────────────────────────────────────

关键观察


Redis 实现方案

方案 A:ZSET 精确滑动窗口

用 Sorted Set 存储每个请求的时间戳作为 score,实现真正意义上的滑动窗口:

key:  rate_limit:{user_id}
member: 请求唯一 ID(或时间戳字符串)
score:  请求时间戳(毫秒)

Lua 脚本(原子执行,保证 count + judge 的原子性):

-- KEYS[1] = rate limit key
-- ARGV[1] = now (ms)
-- ARGV[2] = windowSize (ms)
-- ARGV[3] = limit
-- ARGV[4] = unique request id

local now        = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])
local limit      = tonumber(ARGV[3])
local reqId      = ARGV[4]
local windowStart = now - windowSize

-- 删除窗口外的旧请求
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', windowStart)

-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', KEYS[1])

if count < tonumber(limit) then
    -- 允许:将本次请求加入 ZSET
    redis.call('ZADD', KEYS[1], now, reqId)
    redis.call('EXPIRE', KEYS[1], math.ceil(windowSize / 1000) + 1)
    return 1   -- allowed
else
    return 0   -- rejected
end

优点:精确到毫秒,无近似误差。
缺点:每个请求都存一条记录,高 QPS 下 ZSET 内存占用显著(O(limit) 条记录)。

方案 B:多 key 近似桶方案

用多个独立 key 存储每个时间格的计数,key 名携带格的时间戳:

key:  rate_limit:{user_id}:{slot_timestamp}
value: 计数(整数)
TTL: windowSize + 1 个格宽度(自动过期,无需手动清理)

Lua 脚本:

-- KEYS[1] = 当前格 key(如 rate_limit:u1:16900000)
-- ARGV[1] = limit
-- ARGV[2] = 所有格 key 的列表(用于求和)
-- ARGV[3] = TTL(秒)

local total = 0
-- 遍历 N 个格 key 求和(等价于 totalCount)
for i = 1, #KEYS do
    local v = redis.call('GET', KEYS[i])
    if v then total = total + tonumber(v) end
end

if total < tonumber(ARGV[1]) then
    redis.call('INCR', KEYS[1])
    redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
    return 1
else
    return 0
end

优点:内存占用固定(O(N) 个 key,N = 格数),不随 QPS 增长。
缺点:格宽带来近似误差(同固定窗口,但误差缩小为 1/N)。

两种方案对比

维度 ZSET 精确方案 多 key 近似方案
精度 精确到毫秒 误差 ≤ 1 个格宽
内存 O(limit) 条 O(N) 个 key
QPS 下内存 随 QPS 线性增长 固定,不随 QPS 变化
适用场景 严格限流、limit 较小 高 QPS、允许近似误差

异常与边界场景

7.1 格粒度与精度的权衡

格越小(N 越大),滑动窗口越精确,误差越小:

实践建议:N 取 10~60,格宽 ≥ 10ms,在精度与内存/CPU 之间取得合理平衡。

7.2 并发计数的原子性

问题:两个线程同时执行 isAllowed,均读到 totalCount = 99 < 100,均执行 +1,实际通过了 101 个请求。

解决方案

7.3 窗口大小动态调整

业务高峰期需临时将 limit 从 100 调到 200,或将 windowSize 从 1s 调到 5s。

处理方式

7.4 分布式环境下多实例计数聚合

单机滑动窗口计数器无法感知其他实例的请求量:

解决方案


滑动窗口 vs 令牌桶 vs 漏桶

维度 滑动窗口计数器 令牌桶 漏桶
核心语义 N 秒内请求总数 ≤ M 平均速率 ≤ rate,允许突发 严格匀速输出
突发处理 窗口内一旦达到 M 就拒绝,无积累 桶容量决定最大突发量 队列吸收突发,出口匀速
空闲积累 无(旧格清零即失效,不能跨窗口积累) 空闲期积累令牌 无积累
超限行为 立即拒绝(返回 429) 等待或拒绝(可配置) 队列满则丢弃
典型用途 API 速率限制(N 秒内不超过 M 次) 允许合理突发的 API 限流 网络流量整形
实现复杂度 中(环形数组 + 滚动求和) 中(懒惰填充 + 原子更新) 低(队列 + 定时出队)
典型实现 Redis ZSET / Nginx limit_req Guava RateLimiter / AWS API GW Nginx limit_req(严格模式)

选型指南


参考资料

  1. Figma Engineering:An alternative approach to rate limiting https://www.figma.com/blog/an-alternative-approach-to-rate-limiting/ — 详细介绍滑动窗口计数器与固定窗口的对比,ZSET 实现方案。

  2. Cloudflare:How we built rate limiting capable of scaling to millions of domains https://blog.cloudflare.com/counting-things-a-lot-of-different-things/ — 近似滑动窗口在 CDN 边缘节点的工程实践。

  3. Redis 官方文档:Sorted Sets https://redis.io/docs/data-types/sorted-sets/ — ZREMRANGEBYSCOREZCARDZADD 命令详解。

  4. 《System Design Interview Vol.1》Alex Xu — Chapter 4: Design a Rate Limiter — 固定窗口、滑动窗口日志、滑动窗口计数器三种方案的工程对比。

  5. Sentinel 源码:SlidingWindowMetric https://github.com/alibaba/Sentinel/blob/master/sentinel-core/src/main/java/com/alibaba/csp/sentinel/slots/statistic/base/LeapArray.java — 阿里巴巴 Sentinel 的 LeapArray(环形数组)实现,是本文数据结构的工业级参考。

  6. RFC 2697 — A Single Rate Three Color Marker https://datatracker.ietf.org/doc/html/rfc2697 — IETF 对令牌桶和窗口计数在流量着色中的规范定义。

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论