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

196 lines
6.0 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(有序集合)** 是 Redis 中最适合实现精确滑动窗口的数据结构。每条请求的时间戳作为 score,Redis 自动按分数排序,配合范围查询和清理就能实现精确的窗口统计。
这个方案你标记为 Q4 薄弱点——核心在于理解它的 **运作流程、时间计算方式和空间代价**。
## 一、基本思路
```mermaid
flowchart LR
subgraph "请求到达"
A["客户端构造请求"] --> B["生成唯一 member<br/>uuid / requestId"]
end
subgraph "Redis 操作(Lua 原子化)"
B --> C["ZREMRANGEBYSCORE 清理过期"]
C --> D["ZCARD 计数"]
D --> E{"count >= max?"}
E -- "否" --> F["ZADD 插入新记录"]
E -- "是" --> G["❌ 拒绝"]
F --> H["EXPIRE 设 TTL"]
G --> I["返回 429"]
end
style C fill:#fff3e0
style D fill:#fff3e0
style F fill:#e8f5e9
style G fill:#ffebee
```
## 二、关键参数与时间计算 🔑
这是你最薄弱的地方之一(对应填空题 F6)。
### 2.1 Lua 脚本结构
```go
const slidingWindowLua = `
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 窗口大小,单位:秒
local maxLen = tonumber(ARGV[2]) -- 最大允许的记录数
local now = tonumber(ARGV[3]) -- 毫秒级 Unix 时间戳
local member = ARGV[4] -- 唯一标识(UUID/RequestID)
-- 步骤1:清理过期记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)
-- 步骤2:统计当前窗口内的数量
local count = redis.call('ZCARD', key)
-- 步骤3:判断并插入
if count >= maxLen then
return count -- 超限,直接返回
end
-- 步骤4:放行,添加记录
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)
return count + 1
`
```
### 2.2 清理范围详解(F6 考点)
```lua
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)
│ │
│ └─ 窗口起始边界(毫秒)
└────────── 从负无穷到这个边界
```
- **下界 `-inf`**:所有比它小的分数(更早的时间戳)都是过期的
- **上界 `now - window * 1000`**:窗口开始的时间点
- `now` 是毫秒级时间戳
- `window` 的单位为秒
- 乘以 1000 是为了统一单位到毫秒
**为什么用 `-inf` 而不是具体数值?**
因为 Sorted Set 中可能存着历史遗留数据,用 `-inf` 确保一次性清理干净。如果只从某个固定值开始,可能会漏掉更早的过期记录。
### 2.3 成员(member)的唯一性
每个请求必须生成一个全局唯一的 member,避免 score 相同时被覆盖:
```go
import (
"crypto/rand"
"fmt"
)
func generateMember() string {
var b [8]byte
rand.Read(b[:])
return fmt.Sprintf("%x", b[:])
}
```
或者用更标准的 `ulid` / `uuid`:
```go
import "github.com/oklog/ulid/v2"
member := ulid.Now().String() // 单调递增,天然有序
```
> [!tip]- 为什么不用序列号?
>
> 分布式环境下多实例并发,简单的自增序号可能在同一毫秒产生重复。UUIDv4 / ULID / 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()
result, err := c.Eval(ctx, slidingWindowLua, []string{key}, windowSec, maxRequests, now, member).Int()
if err != nil {
return 0, err
}
return result, nil
}
```
## 四、空间复杂度分析(Q4 考点)
这是 Q4 选择题考察的点:**在高并发场景下 Sorted Set 会成为瓶颈。**
### 4.1 计算公式
```
存储空间 ≈ maxQPS × window_seconds × 单条记录大小
```
每条记录的内存开销:
- score(double):8 字节
- member(字符串):约 26 字节(ULID)
- Sorted Set 节点开销:约 64 字节
- **合计约 ~100 字节/条**
### 4.2 实际数字对比
| 场景 | maxQPS | window(s) | 记录数 | 内存 |
|------|--------|-----------|--------|------|
| 日配额(低频) | 10 | 86400 | 864,000 | ~86 MB |
| API 接口(中频) | 100 | 60 | 6,000 | ~600 KB |
| 高频网关 | 1000 | 60 | **60,000** | ~6 MB |
| 超高并发 | 10000 | 60 | **600,000** | ~60 MB |
> [!warning]- 什么时候不该用 Sorted Set?
>
> 如果你的 API 接口 QPS > 1000 且窗口 > 10s,Sorted Set 的方案就不太合适了。这时应该切换到:
> - **令牌桶/漏桶**:O(1) 空间,适合保护后端资源
> - **滑动窗口计数器**:O(N) 空间,N 远小于记录数
### 4.3 时间复杂度
| 操作 | 复杂度 | 说明 |
|------|--------|------|
| ZADD | O(log N) | Sorted Set 基于跳表 |
| ZREMRANGEBYSCORE | O(log N + M) | N 是集合大小,M 是删除数 |
| ZCARD | O(1) | Redis 内部维护计数 |
| EXPIRE | O(1) | 设置过期时间 |
高并发下每次请求都要做几次 O(log N) 操作,当 N 达到数万甚至数十万时,CPU 和延迟都会明显上升。
## 五、优化方向
```mermaid
flowchart TD
Problem["Sorted Set 太大导致性能问题"] --> Choice{"选择优化方向"}
Choice -- "降低精度换取空间" --> SWC["切换为滑动窗口计数器<br/>O(N) 子窗口,N << 记录数"]
Choice -- "保持精确但控制规模" --> REDUCE["减少窗口大小或 maxQPS<br/>例如 60s → 10s"]
Choice -- "接受高频开销" --> KEEP["继续使用 Sorted Set<br/>加监控告警"]
style Problem fill:#ffebee
style SWC fill:#e3f2fd
style KEEP fill:#fff3e0
```
## 关联笔记
- [[分布式限流]] — 完整的限流算法演进路线
- [[Lua脚本]] — Lua 脚本中的 Sorted Set 操作
- [[滑动窗口计数器]] — 空间效率更高的替代方案