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