Files
cs-note/hzh/REDIS/Sorted Set 滑动窗口.md
T

6.0 KiB
Raw Blame History

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

Sorted Set 实现滑动窗口详解

概述

Sorted Set(有序集合) 是 Redis 中最适合实现精确滑动窗口的数据结构。每条请求的时间戳作为 score,Redis 自动按分数排序,配合范围查询和清理就能实现精确的窗口统计。

这个方案你标记为 Q4 薄弱点——核心在于理解它的 运作流程、时间计算方式和空间代价。

一、基本思路

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 脚本结构

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 考点)

redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)
                                       │         │
                                       │         └─ 窗口起始边界(毫秒)
                                       └────────── 从负无穷到这个边界
  • 下界 -inf:所有比它小的分数(更早的时间戳)都是过期的
  • 上界 now - window * 1000:窗口开始的时间点
    • now 是毫秒级时间戳
    • window 的单位为秒
    • 乘以 1000 是为了统一单位到毫秒

为什么用 -inf 而不是具体数值? 因为 Sorted Set 中可能存着历史遗留数据,用 -inf 确保一次性清理干净。如果只从某个固定值开始,可能会漏掉更早的过期记录。

2.3 成员(member)的唯一性

每个请求必须生成一个全局唯一的 member,避免 score 相同时被覆盖:

import (
    "crypto/rand"
    "fmt"
)

func generateMember() string {
    var b [8]byte
    rand.Read(b[:])
    return fmt.Sprintf("%x", b[:])
}

或者用更标准的 ulid / uuid:

import "github.com/oklog/ulid/v2"

member := ulid.Now().String()  // 单调递增,天然有序

[!tip]- 为什么不用序列号?

分布式环境下多实例并发,简单的自增序号可能在同一毫秒产生重复。UUIDv4 / ULID / RequestID 能保证全局唯一。

三、完整调用示例

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 和延迟都会明显上升。

五、优化方向

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

关联笔记