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

199 lines
6.4 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, 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) 空间的替代方案