Files
cs-note/hzh/REDIS/Sorted Set 滑动窗口.md
T

12 KiB
Raw Blame History

tags, create time
tags create time
redis
sorted-set
sliding-window
lua-script
2026-06-01 10:30

Sorted Set 实现滑动窗口详解

一句话概括

把每个请求的时间戳当成"分数"存进有序集合,每次来新请求时:

① 清除窗口外的旧记录 → ② 数一数还剩多少 → ③ 没超就放行,超了就拒绝

就这么三步。下面是逐步拆解。


第一步:直观理解——为什么用 Sorted Set?

朴素想法的问题

假设我们要限制 每分钟最多 100 个请求,一种想法是:把每个请求的到达时间都记下来,随时数一下"最近一分钟内来了几个"。

时间轴:t-60s ━━━━━┳━━━━━┳━━━━━► t(现在)
                   ❌     ✅
                 过期区  保留区

问题在于:你怎么快速找出"哪些记录落在最近 60 秒内"?

如果用普通 String 或 Hash,只能遍历所有键做比较。而 Sorted Set 天然按 score 排序,直接就能做范围操作。

Sorted Set 里的角色

字段 放什么 作用
score(分数) 毫秒级时间戳 决定顺序,也是判断是否在窗口的依据
member(成员) UUID / RequestID 保证全局唯一,避免同毫秒的请求被覆盖

Sorted Set 的内部样子(注意看并发场景):

score (时间戳)         member (UUID)        说明
─────────────────────────────────────────────────────────────
1748700000000  │────► abc123def456...
1748700000000  │────► 999xyz012abc...      ← ⚠️ 分布式并发同一毫秒,member 不同就并存!
1748700001500  │────► 789xyz012abc...
1748700003200  │────► def456ghi789...
1748700005000  │────► ghi789jkl012...
1748700005000  │────► mmm012nnn345...      ← ⚠️ 同上,score 重复完全没问题

有了有序性,两个操作一步到位:

  1. 删过期:所有 score < now - 60s 的记录一次性批量删除
  2. 统计:剩下的数量 = 窗口内的请求数

第二步:四条核心命令

整个算法只用了 4 条 Redis 命令。一条一条拆。

1. ZREMRANGEBYSCORE — 清理过期记录

ZREMRANGEBYSCORE key -inf (now - window * 1000)

原理: 从负无穷到窗口左边界,把所有过期元素删干净。

时间轴:  ← 过期区          → 保留区
          ┃───────┼─────────────────►
                  ▲
             now - window×1000

ZREMRANGEBYSCORE 做的事:
━━━━━━━━━━━━━━━━━━━┓
删除这里面的每一条               ┗━━ 保留这里的
───────────────────┛

Q:为什么下界用 -inf?
历史记录可能跨越多个窗口周期,用 -inf 确保一次性全部清理干净,不会漏掉更早的数据。

2. ZCARD — 统计当前数量

ZCARD key

返回集合中的元素个数,即窗口内的请求总数。O(1),Redis 内部维护了计数。

3. ZADD + EXPIRE — 插入 & 兜底

没超限就放行,同时给 key 设一个过期时间:

ZADD key now member      -- 以当前时间戳为 score 插入
EXPIRE key window        -- window 秒后 Redis 自动删 key,兜底清理

EXPIRE 的真正作用——为什么要设过期?

这里有个容易困惑的地方:不是说 Lua 脚本没执行到 EXPIRE(Lua 是原子的,要么全做完要么全不做),也不是说 ZREMRANGEBYSCORE 清不干净(它每次都从 -inf 开始删)。

EXPIRE 防御的是以下几种边界场景:

场景 会发生什么 EXPIRE 救了什么
正常限流后不再访问 key 永远存在,内存不被释放(里面可能没几条活跃数据) ✅ 自动清理空闲 key,否则 key 永久占用内存
客户端 ZADD 成功但断连,EXPIRE 没发到 Redis ZADD 插入了一条记录 ✅ Redis 内部 TTL 兜底,到点自动删除
绕过 Lua,用 redis-cli 或其他客户端直接操作 手动 ZADD 了元素 ✅ 还是会过期,不会被永久污染
主从切换极端情况 主节点 ZADD + EXPIRE 还没同步就挂掉 ⚠️ 部分情况下 EXPIRE 在从库上丢失(极端场景)

类比理解: ZREMRANGEBYSCORE 是每天大扫除,把过期的东西扔了;EXPIRE 是给房间定个租期,到期直接搬空,不用等你想起来打扫。

4. 判断逻辑

count >= maxLen ?  → 拒绝(返回 429)
count < maxLen    → 放行,插入新记录

第三步:完整流程 & Lua 原子化

流程图

flowchart LR
    A[请求到达] --> B[ZREMRANGEBYSCORE<br/>删过期]
    B --> C[ZCARD<br/>数一数]
    C --> D{>= max?}
    D -- 否 --> E[ZADD 插入<br/>EXPIRE 设过期]
    D -- 是 --> F[返回 429 拒绝]

    style B fill:#fff3e0
    style C fill:#fff3e0
    style E fill:#e8f5e9
    style F fill:#ffebee

为什么要用 Lua 脚本?

因为这 4 步必须是 原子操作——不允许其他请求插进来干扰:

❌ 非原子会怎样:

线程A: 清理过期 → 数数(得到 99)
       ↑ 此时线程B也来了,也得到 99
线程B: 清理过期 → 数数(得到 99)
       
线程A: 插入!count = 100
线程B: 插入!count = 101 ← 限流失效了

Lua 脚本在 Redis 里是单线程串行执行的,不会被中间打断。

精简 Lua 脚本

local key    = KEYS[1]
local window = tonumber(ARGV[1])  -- 窗口大小,秒
local maxLen = tonumber(ARGV[2])  -- 最大请求数
local now    = tonumber(ARGV[3])  -- 毫秒时间戳
local member = ARGV[4]            -- 唯一标识

-- 1. 清理过期(⚠️ 注意单位换算:window 是秒 ×1000 → 毫秒)
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)

-- 2. 统计当前数量
local count = redis.call('ZCARD', key)

-- 3. 判断 & 插入
if count >= maxLen then
    return count              -- 超限
end

redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)
return count + 1              -- 放行

第四步:关键细节汇总

参数一览

参数 含义 示例值 单位
window 滑动窗口大小 60 秒
maxLen 窗口内最大请求数 100 个
now 当前时间 1748700008100 毫秒
member 唯一标识 "abc123..." 字符串
key 滑动窗口的容器(Redis Key) "rate:sliding:user:1001" 字符串

key —— 按什么限流?

这是最容易混淆的概念:Lua 脚本里的 key 是 Redis 的 Key,不是 Sorted Set 内部的 score 或 member。

它回答了**"对这个维度做限流"**这个问题:

// 按用户 ID 限流
key := fmt.Sprintf("rate:sliding:user:%s", userID)

// 按 IP 限流
key := fmt.Sprintf("rate:sliding:ip:%s", clientIP)

// 按接口路径限流
key := fmt.Sprintf("rate:sliding:api:%s", apiPath)

三层关系:

Redis Key (key)                →  限流的维度(哪个用户 / 哪台 IP)
    └── Sorted Set           →  该 key 下的所有请求记录
            ├── member       →  每条请求的唯一 ID(去重用)
            └── score        →  每条请求的时间戳(排序用)

类比记忆:

类比 Redis 对应物 作用
班级 key 划分限流范围
学生名单 Sorted Set 班级里的人
学号 member 区分每个学生(不能重复)
身高 score 按高低排队(可以相同)

不同业务场景下 key 的设计直接影响限流效果:

  • 按用户 ID:每个用户独立配额,公平但可能被恶意用户批量注册绕过
  • 按 IP:简单粗暴,共享宽带/NAT 下的用户会被连坐
  • 按接口路径:保护后端资源,防止某个接口被打挂
  • 组合 key:rate:sliding:user:1001:api:/pay = 单个用户对支付接口的独立配额

单位换算要点

now                = 毫秒 (由服务端生成)
window             = 秒   (业务配置)
window * 1000      = 毫秒 (用于减法对齐)

清理时用 now - window * 1000,本质是:毫秒 − 秒×1000 = 毫秒 − 毫秒,两边单位必须一致。

member 必须唯一的原因

Sorted Set 中,如果 member 相同,无论 score 是什么,新值都会覆盖旧值。而 score 重复是完全没问题的:

场景                          结果
──────────────────────────────────────────────
同 score + 同 member          ❌ 被覆盖(两条请求丢了一条!)
同 score + 不同 member        ✅ 两条并存(正常)
不同 score + 不同 member      ✅ 两条并存(正常)

你的疑问很关键:分布式下,不同节点在同一毫秒产生的时间戳相同怎么办? 答案是无所谓,因为 member(UUID / ULID)已经保证了全局唯一。

// 推荐 ULID(单调递增 + 全局唯一)
member := ulid.Now().String()

// 或者 UUIDv4(纯随机但足够碰撞安全)
// member := uuid.New().String()

Q:为什么不用自增序号?
分布式多实例并发时,同一毫秒内不同节点的自增 ID 可能重复。ULID / UUID / RequestID 能保证全局唯一。

Go 调用代码

func SlidingWindowAllow(ctx context.Context, c *redis.Client, id string, windowSec int, maxRequests int) (int, error) {
    key := fmt.Sprintf("rate:sliding:%s", id)
    now := time.Now().UnixMilli()
    member := generateMember() // ULID / UUID

    result, err := c.Eval(ctx, slidingWindowLua, []string{key}, windowSec, maxRequests, now, member).Int()
    if err != nil {
        return 0, err
    }
    return result, nil
}

第五步:代价分析 & 方案选择

内存估算

每条记录的开销约 ~100 字节(score 8字节 + member ~26字节 + 跳表节点 ~64字节)。

总内存 ≈ 记录数 × 100 字节

记录数 = QPS × window
场景 QPS 窗口(s) 记录数 约需内存
低频 API 10 86400 864,000 ~86 MB
普通接口 100 60 6,000 ~600 KB
高频网关 1000 60 60,000 ~6 MB
超高并发 10000 60 600,000 ~60 MB

⚠️ 阈值预警

当 QPS > 1000 且窗口 > 10s 时,Sorted Set 已经不太合适了:

  • 单 key 存储数万 ~ 数十万条记录,内存压力大
  • 每次请求要做 O(log N) 的 ZADD + ZREMRANGEBYSCORE,N 很大时 CPU 和延迟明显上升

替代方案对比

flowchart TD
    Start["需要限流"] --> Q1{"要精确统计还是近似即可?"}

    Q1 -- 精确 --> SS["Sorted Set<br/>精确到每条请求<br/>空间 O(QPS×window)"]
    Q1 -- 近似即可 --> TC{"QPS 高不高?"}

    TC -- 低 --> SWC["滑动窗口计数器<br/>分成 N 个子窗口<br/>空间 O(N),N 远小于记录数"]
    TC -- 高 --> TB["令牌桶/漏桶<br/>O(1) 空间<br/>不精确但够用"]

    style SS fill:#fff3e0
    style SWC fill:#e3f2fd
    style TB fill:#c8e6c9
维度 Sorted Set 滑动窗口计数器 令牌桶/漏桶
精度 精确到每条请求 近似(子窗口粒度) 不精确(速率平滑)
空间 O(QPS × window) O(子窗口数 N) O(1)
时间 O(log N) / 次 O(1) / 次 O(1) / 次
适用 QPS ≤ 1000、小窗口 QPS ≤ 10000 无上限

关联笔记