vault backup: 2026-06-01 00:35:50
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
---
|
||||
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 操作
|
||||
- [[滑动窗口计数器]] — 空间效率更高的替代方案
|
||||
Reference in New Issue
Block a user