Files
cs-note/hzh/REDIS/滑动窗口日志方案.md
T

199 lines
6.4 KiB
Markdown
Raw Normal View History

2026-06-01 00:35:50 +08:00
---
tags: [redis, sliding-window, sorted-set, performance, monitoring]
create time: 2026-06-01 10:30
---
# 滑动窗口日志方案细节
## 概述
滑动窗口日志方案是最精确的限流方式:每条请求作为 Sorted Set 的一条记录,通过范围查询实现精确统计。这一节深入你标记为薄弱的 F6——Lua 脚本中的清理逻辑和各操作的详细时序。
## 一、核心流程(F6 考点深挖)
### 完整时序图
```mermaid
sequenceDiagram
participant App as Go 应用
participant R as Redis
Note over App,R: ── Lua 脚本执行(原子化)─
App->>R: EVALSHA sha1 ... <script>
rect rgb(240, 248, 255)
Note over R: Step 1: 清理过期记录
R->>R: ZREMRANGEBYSCORE key -inf now-window*1000
Note right of R: 删除所有 score < now-window<br/>的记录
end
rect rgb(255, 248, 240)
Note over R: Step 2: 统计当前数量
R->>R: ZCARD key
Note right of R: 返回当前窗口内<br/>剩余的有效记录数
end
alt count >= maxLen
Note over R: Step 3a: 超限
R-->>App: return count (拒绝)
else count < maxLen
Note over R: Step 3b: 放行
R->>R: ZADD key now member
Note right of R: 插入新记录<br/>score = now (毫秒时间戳)
R->>R: EXPIRE key window
Note right of R: TTL = window 秒<br/>防止 Key 永远存在
R-->>App: return count + 1 (放行)
end
```
### F6 考点详解:清理范围
```lua
-- 关键行(对应填空题 F6)
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)
│ │ │
│ │ └── 窗口大小 × 1000(秒 → 毫秒)
│ └────────────── 窗口起始边界
└────────────────────── 从负无穷开始
```
| 参数 | 含义 | 为什么这样设? |
|------|------|--------------|
| `-inf` | 负无穷 | 确保所有更早的历史记录都被清理,不留死角 |
| `now - window * 1000` | 窗口起始点 | 这是"当前时刻往前推一个窗口长度"的时间点 |
**单位统一很重要:**
- `now` 是毫秒级时间戳(`time.Now().UnixMilli()`)
- `window` 传入的是秒
- 所以 `window * 1000` 转换为毫秒
- 最终比较的上下界都是毫秒级数值
## 二、member 唯一性保障
Sorted Set 中,如果两条记录的 score 完全相同且 member 也相同,第二条会**覆盖第一条**。因此必须保证 member 全局唯一:
```go
// 方案1: ULID(推荐,单调递增 + 分布式安全)
import "github.com/oklog/ulid/v2"
func makeMember() string {
return ulid.Now().String()
}
// 方案2: UUID v4
import "github.com/google/uuid"
func makeMember() string {
return uuid.New().String()
}
// 方案3: 毫秒时间戳 + 随机后缀
func makeMemberV3() string {
ts := time.Now().UnixMilli()
randSuffix := rand.Intn(10000)
return fmt.Sprintf("%d_%04d", ts, randSuffix)
}
```
> [!tip]- 为什么不用简单计数器?
>
> 分布式环境下不同实例并发时,序号可能冲突。ULID/UUID 天然去重且有序(ULID 按时间排序),是 Sorted Set 的最佳选择。
## 三、Redis 原生 Sorted Set 方案(非 Lua)
如果你的场景不需要 Lua 的原子性保证,可以直接用两个 Redis 命令组合:
```go
// 不需要 Lua 的轻量版实现
func SlidingWindowSimple(ctx context.Context, rdb *redis.Client, id string, windowSec int, maxLen int64) error {
key := fmt.Sprintf("rate:sliding:%s", id)
now := time.Now().UnixMilli()
cutoff := now - int64(windowSec)*1000
member := ulid.Now().String()
// 步骤1: 原子性地做两件事
pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(cutoff, 10))
pipe.ZCard(ctx, key)
results, err := pipe.Exec(ctx)
if err != nil {
return err
}
currentCount := int(results[1].(*redis.IntCmd).Val())
if currentCount >= int(maxLen) {
return ErrRateLimited
}
// 步骤2: 添加并设置过期
pipe2 := rdb.Pipeline()
pipe2.ZAdd(ctx, key, &redis.Z{Score: float64(now), Member: member})
pipe2.Expire(ctx, key, time.Duration(windowSec)*time.Second)
_, err = pipe2.Exec(ctx)
return err
}
```
> [!warning]- 这个版本的隐患
>
> Pipeline 虽然减少了 RTT,但 ZRemRangeByScore → ZCard → ZAdd **不是原子操作**。在高并发下可能出现:
> - 线程 A 查了 ZCard(count=99)
> - 线程 B 插入了 ZADD(count=100)
> - 线程 A 又插入 ZADD(count=101)→ **超限**
>
> 所以对严格限流的场景,**必须用 Lua**。
## 四、空间增长与监控
### 4.1 各层级的内存占用
```
每条记录约 ~100 字节(含 Sorted Set 节点开销)
日配额场景 (QPS ≈ 0.01):
活跃记录 ≈ 1000/day × 100B = 100 KB ✓
接口限流场景 (QPS = 100):
活跃记录 ≈ 100/s × 60s × 100B = 600 KB ✓
高频网关 (QPS = 1000):
活跃记录 ≈ 1000/s × 60s × 100B = 6 MB ⚠️
超高并发 (QPS = 10000):
活跃记录 ≈ 10000/s × 60s × 100B = 60 MB ❌ 不建议用此方案
```
### 4.2 监控指标
```bash
# 查看某个 Sorted Set 的大小
ZCARD rate:sliding:user123
# 检查内存使用
MEMORY USAGE rate:sliding:user123
# 查看所有限流相关 Key 的大小(SCAN + MEMORY USAGE)
redis-cli --scan --pattern "rate:sliding:*" | xargs -I{} redis-cli MEMORY USAGE {}
```
在 Prometheus 或 Grafana 中建议监控:
- `rate:sliding:*` 集合的 ZCARD 总量分布
- 单个 Key 的最大 ZCARD 值(告警阈值设为 maxLen 的 2 倍)
- 每个 Key 的内存占用 `MEMORY USAGE`
## 五、常见问题排查
| 症状 | 原因 | 排查方法 |
|------|------|---------|
| 超限后仍大量通过 | ZADD member 重复导致覆盖 | 检查 member 是否全局唯一 |
| 内存持续增长 | EXPIRE 未生效 | 用 `TTL key` 检查是否为 -1 |
| 计数偏大 | 时间戳单位不匹配 | 确认 now 是毫秒,window 转毫秒 |
| CPU 飙高 | Sorted Set 规模过大 | `ZCARD` 看数量,考虑切换到令牌桶 |
## 关联笔记
- [[分布式限流]] — 滑动窗口日志的整体定位和对比
- [[Sorted Set 滑动窗口]] — Sorted Set 滑动窗口的全面讲解
- [[滑动窗口计数器]] — O(N) 空间的替代方案