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

355 lines
12 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, sorted-set, sliding-window, lua-script]
create time: 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 — 清理过期记录
```lua
ZREMRANGEBYSCORE key -inf (now - window * 1000)
```
**原理:** 从负无穷到窗口左边界,把所有过期元素删干净。
```
时间轴: ← 过期区 → 保留区
┃───────┼─────────────────►
▲
now - window×1000
ZREMRANGEBYSCORE 做的事:
━━━━━━━━━━━━━━━━━━━┓
删除这里面的每一条 ┗━━ 保留这里的
───────────────────┛
```
**Q:为什么下界用 `-inf`?**
历史记录可能跨越多个窗口周期,用 `-inf` 确保一次性全部清理干净,不会漏掉更早的数据。
### 2. ZCARD — 统计当前数量
```lua
ZCARD key
```
返回集合中的元素个数,即窗口内的请求总数。**O(1)**,Redis 内部维护了计数。
### 3. ZADD + EXPIRE — 插入 & 兜底
没超限就放行,同时给 key 设一个过期时间:
```lua
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 原子化
### 流程图
```mermaid
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 脚本
```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。**
它回答了**"对这个维度做限流"**这个问题:
```go
// 按用户 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` = 单个用户对支付接口的独立配额
### 单位换算要点
```lua
now = 毫秒 (由服务端生成)
window = 秒 (业务配置)
window * 1000 = 毫秒 (用于减法对齐)
```
清理时用 `now - window * 1000`,本质是:**毫秒 − 秒×1000 = 毫秒 − 毫秒**,两边单位必须一致。
### member 必须唯一的原因
Sorted Set 中,如果 member 相同,无论 score 是什么,新值都会**覆盖**旧值。而 score 重复是完全没问题的:
```
场景 结果
──────────────────────────────────────────────
同 score + 同 member ❌ 被覆盖(两条请求丢了一条!)
同 score + 不同 member ✅ 两条并存(正常)
不同 score + 不同 member ✅ 两条并存(正常)
```
你的疑问很关键:**分布式下,不同节点在同一毫秒产生的时间戳相同怎么办?** 答案是无所谓,因为 member(UUID / ULID)已经保证了全局唯一。
```go
// 推荐 ULID(单调递增 + 全局唯一)
member := ulid.Now().String()
// 或者 UUIDv4(纯随机但足够碰撞安全)
// member := uuid.New().String()
```
**Q:为什么不用自增序号?**
分布式多实例并发时,同一毫秒内不同节点的自增 ID 可能重复。ULID / UUID / RequestID 能保证全局唯一。
### Go 调用代码
```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 和延迟明显上升
### 替代方案对比
```mermaid
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 | 无上限 |
---
## 关联笔记
- [[分布式限流]] — 完整的限流算法演进路线
- [[Lua脚本]] — Lua 脚本中的 Sorted Set 操作
- [[滑动窗口计数器]] — 空间效率更高的替代方案