diff --git a/hzh/REDIS/Sorted Set 滑动窗口.md b/hzh/REDIS/Sorted Set 滑动窗口.md index 510caff..2f97623 100644 --- a/hzh/REDIS/Sorted Set 滑动窗口.md +++ b/hzh/REDIS/Sorted Set 滑动窗口.md @@ -5,123 +5,285 @@ create time: 2026-06-01 10:30 # Sorted Set 实现滑动窗口详解 -## 概述 +## 一句话概括 -**Sorted Set(有序集合)** 是 Redis 中最适合实现精确滑动窗口的数据结构。每条请求的时间戳作为 score,Redis 自动按分数排序,配合范围查询和清理就能实现精确的窗口统计。 +**把每个请求的时间戳当成"分数"存进有序集合,每次来新请求时:** -这个方案你标记为 Q4 薄弱点——核心在于理解它的 **运作流程、时间计算方式和空间代价**。 +> ① 清除窗口外的旧记录 → ② 数一数还剩多少 → ③ 没超就放行,超了就拒绝 -## 一、基本思路 +就这么三步。下面是逐步拆解。 + +--- + +## 第一步:直观理解——为什么用 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 - subgraph "请求到达" - A["客户端构造请求"] --> B["生成唯一 member
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 + A[请求到达] --> B[ZREMRANGEBYSCORE
删过期] + B --> C[ZCARD
数一数] + C --> D{>= max?} + D -- 否 --> E[ZADD 插入
EXPIRE 设过期] + D -- 是 --> F[返回 429 拒绝] + style B fill:#fff3e0 style C fill:#fff3e0 - style D fill:#fff3e0 - style F fill:#e8f5e9 - style G fill:#ffebee + style E fill:#e8f5e9 + style F fill:#ffebee ``` -## 二、关键参数与时间计算 🔑 +### 为什么要用 Lua 脚本? -这是你最薄弱的地方之一(对应填空题 F6)。 +因为这 4 步必须是 **原子操作**——不允许其他请求插进来干扰: -### 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 -` +线程A: 清理过期 → 数数(得到 99) + ↑ 此时线程B也来了,也得到 99 +线程B: 清理过期 → 数数(得到 99) + +线程A: 插入!count = 100 +线程B: 插入!count = 101 ← 限流失效了 ``` -### 2.2 清理范围详解(F6 考点) +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 -- 放行 ``` -- **下界 `-inf`**:所有比它小的分数(更早的时间戳)都是过期的 -- **上界 `now - window * 1000`**:窗口开始的时间点 - - `now` 是毫秒级时间戳 - - `window` 的单位为秒 - - 乘以 1000 是为了统一单位到毫秒 +--- -**为什么用 `-inf` 而不是具体数值?** -因为 Sorted Set 中可能存着历史遗留数据,用 `-inf` 确保一次性清理干净。如果只从某个固定值开始,可能会漏掉更早的过期记录。 +## 第四步:关键细节汇总 -### 2.3 成员(member)的唯一性 +### 参数一览 -每个请求必须生成一个全局唯一的 member,避免 score 相同时被覆盖: +| 参数 | 含义 | 示例值 | 单位 | +|------|------|--------|------| +| `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 -import ( - "crypto/rand" - "fmt" -) +// 按用户 ID 限流 +key := fmt.Sprintf("rate:sliding:user:%s", userID) -func generateMember() string { - var b [8]byte - rand.Read(b[:]) - return fmt.Sprintf("%x", b[:]) -} +// 按 IP 限流 +key := fmt.Sprintf("rate:sliding:ip:%s", clientIP) + +// 按接口路径限流 +key := fmt.Sprintf("rate:sliding:api:%s", apiPath) ``` -或者用更标准的 `ulid` / `uuid`: +三层关系: + +``` +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 -import "github.com/oklog/ulid/v2" +// 推荐 ULID(单调递增 + 全局唯一) +member := ulid.Now().String() -member := ulid.Now().String() // 单调递增,天然有序 +// 或者 UUIDv4(纯随机但足够碰撞安全) +// member := uuid.New().String() ``` -> [!tip]- 为什么不用序列号? -> -> 分布式环境下多实例并发,简单的自增序号可能在同一毫秒产生重复。UUIDv4 / ULID / RequestID 能保证全局唯一。 +**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() + member := generateMember() // ULID / UUID result, err := c.Eval(ctx, slidingWindowLua, []string{key}, windowSec, maxRequests, now, member).Int() if err != nil { @@ -131,63 +293,60 @@ func SlidingWindowAllow(ctx context.Context, c *redis.Client, id string, windowS } ``` -## 四、空间复杂度分析(Q4 考点) +--- -这是 Q4 选择题考察的点:**在高并发场景下 Sorted Set 会成为瓶颈。** +## 第五步:代价分析 & 方案选择 -### 4.1 计算公式 +### 内存估算 + +每条记录的开销约 **~100 字节**(score 8字节 + member ~26字节 + 跳表节点 ~64字节)。 ``` -存储空间 ≈ maxQPS × window_seconds × 单条记录大小 +总内存 ≈ 记录数 × 100 字节 + +记录数 = QPS × window ``` -每条记录的内存开销: -- score(double):8 字节 -- member(字符串):约 26 字节(ULID) -- Sorted Set 节点开销:约 64 字节 -- **合计约 ~100 字节/条** +| 场景 | 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 | -### 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 | +当 **QPS > 1000 且窗口 > 10s** 时,Sorted Set 已经不太合适了: -> [!warning]- 什么时候不该用 Sorted Set? -> -> 如果你的 API 接口 QPS > 1000 且窗口 > 10s,Sorted Set 的方案就不太合适了。这时应该切换到: -> - **令牌桶/漏桶**:O(1) 空间,适合保护后端资源 -> - **滑动窗口计数器**:O(N) 空间,N 远小于记录数 +- 单 key 存储数万 ~ 数十万条记录,内存压力大 +- 每次请求要做 O(log N) 的 ZADD + ZREMRANGEBYSCORE,N 很大时 CPU 和延迟明显上升 -### 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{"选择优化方向"} + Start["需要限流"] --> Q1{"要精确统计还是近似即可?"} - Choice -- "降低精度换取空间" --> SWC["切换为滑动窗口计数器
O(N) 子窗口,N << 记录数"] - Choice -- "保持精确但控制规模" --> REDUCE["减少窗口大小或 maxQPS
例如 60s → 10s"] - Choice -- "接受高频开销" --> KEEP["继续使用 Sorted Set
加监控告警"] + Q1 -- 精确 --> SS["Sorted Set
精确到每条请求
空间 O(QPS×window)"] + Q1 -- 近似即可 --> TC{"QPS 高不高?"} - style Problem fill:#ffebee + TC -- 低 --> SWC["滑动窗口计数器
分成 N 个子窗口
空间 O(N),N 远小于记录数"] + TC -- 高 --> TB["令牌桶/漏桶
O(1) 空间
不精确但够用"] + + style SS fill:#fff3e0 style SWC fill:#e3f2fd - style KEEP fill:#fff3e0 + style TB fill:#c8e6c9 ``` +| 维度 | Sorted Set | 滑动窗口计数器 | 令牌桶/漏桶 | +|------|-----------|-------------|-----------| +| **精度** | 精确到每条请求 | 近似(子窗口粒度) | 不精确(速率平滑) | +| **空间** | O(QPS × window) | O(子窗口数 N) | O(1) | +| **时间** | O(log N) / 次 | O(1) / 次 | O(1) / 次 | +| **适用** | QPS ≤ 1000、小窗口 | QPS ≤ 10000 | 无上限 | + +--- + ## 关联笔记 - [[分布式限流]] — 完整的限流算法演进路线 diff --git a/hzh/REDIS/路由键与Hash Tag.md b/hzh/REDIS/路由键与Hash Tag.md index 79468ed..8accd71 100644 --- a/hzh/REDIS/路由键与Hash Tag.md +++ b/hzh/REDIS/路由键与Hash Tag.md @@ -33,19 +33,21 @@ slot := crc16(key) % 16384 ### 2.1 规则 ```mermaid -flowchart LR["Hash Tag 计算规则"] - A["rate:limit:{user123}:daily"] --> B["提取 {} 内内容: user123"] - B --> C["CRC16(user123) % 16384"] - C --> D["落点 = slot X"] +flowchart LR + subgraph hashTagRule["Hash Tag 计算规则"] + A["rate:limit:{user123}:daily"] --> B["提取 {} 内内容: user123"] + B --> C["CRC16(user123) % 16384"] + C --> D["落点 = slot X"] - E["rate:limit:{user123}:hourly"] --> B + E["rate:limit:{user123}:hourly"] --> B - F["rate:limit:user456:daily"] --> G["无 {} → 取完整 Key"] - G --> H["CRC16(rate:limit:user456:daily) % 16384"] - H --> I["落点 = slot Y (通常 ≠ X)"] + F["rate:limit:user456:daily"] --> G["无 {} → 取完整 Key"] + G --> H["CRC16(rate:limit:user456:daily) % 16384"] + H --> I["落点 = slot Y (通常 ≠ X)"] - style D fill:#c8e6c9 - style I fill:#fff3e0 + style D fill:#c8e6c9 + style I fill:#fff3e0 + end ``` | Key 示例 | 实际参与计算的字符串 | 落点 |