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

6.4 KiB
Raw Blame History

tags, create time
tags create time
redis
sliding-window
sorted-set
performance
monitoring
2026-06-01 10:30

滑动窗口日志方案细节

概述

滑动窗口日志方案是最精确的限流方式:每条请求作为 Sorted Set 的一条记录,通过范围查询实现精确统计。这一节深入你标记为薄弱的 F6——Lua 脚本中的清理逻辑和各操作的详细时序。

一、核心流程(F6 考点深挖)

完整时序图

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 考点详解:清理范围

-- 关键行(对应填空题 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 全局唯一:

// 方案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 命令组合:

// 不需要 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 监控指标

# 查看某个 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 看数量,考虑切换到令牌桶

关联笔记