不能用 INCR + EXPIRE 实现滑动窗口,因其仅支持固定窗口(如每秒清零),无法准确表达“过去60秒内最多100次”的连续时间切片;而ZSET通过ZREMRANGEBYSCORE清理过期项、ZADD插入、ZCARD统计,配合Lua脚本原子执行三步操作,语义准确、单key高效、避免并发漏判。

为什么不能用 INCR + EXPIRE 实现滑动窗口
因为 INCR 只能做固定窗口(比如每秒清零),无法表达“过去 60 秒内最多 100 次”这种连续时间切片。强行用多个 key 模拟滑动(比如按秒分桶)会导致 key 数爆炸、内存占用高、跨桶统计需额外读取 N 个 key,网络往返和原子性都难保障。
ZSET 方案虽有少量 CPU 开销(ZREMRANGEBYSCORE 是 O(log N + M),M 是删除数量),但胜在单 key、单 round-trip、语义准确。真正瓶颈通常不在 Redis,而在 Go 侧频繁构造 key 或没复用 redis.Client 连接池。
必须用 Lua 脚本保证三步原子执行
滑动窗口的核心动作是清理过期项 → 插入当前请求 → 统计窗口内总数。这三步拆开执行,在高并发下必然漏判超限:A 和 B 同时看到 99 条,各自插入第 100 条,结果变成 101 条。
-
ZREMRANGEBYSCORE必须放在ZADD之前,否则刚插入的请求可能被自己删掉 - 脚本里必须调
redis.call("TIME")获取服务端时间戳,拼成毫秒整数:seconds * 1000 + math.floor(microseconds / 1000) -
ARGV全是字符串,所有时间、阈值都要用tonumber()转型后再参与计算,否则字符串比大小会出错 - 返回值是
int64,Go 侧必须显式判断:result.(int64) == 1
Go 客户端调用 EVALSHA 的关键细节
别每次 Eval 都传完整脚本——网络开销大,还可能因超时失败。正确做法是预加载并缓存 SHA1 值。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
立即学习“go语言免费学习笔记(深入)”;
- 初始化时用
client.ScriptLoad(luaScript).Result()获取evalSha,全局复用 - 调用时用
client.EvalSha(ctx, evalSha, []string{key}, nowStr, beforeStr, windowSecStr, limitStr) -
KEYS数组只放业务 key(如"rate:login:192.168.1.100"),绝不硬编码;ARGV放动态参数,顺序不能错 - 连接池
PoolSize至少设为 50,QPS 超 5k 时建议调到 100+;Timeout必须设为 100ms,超时直接降级或返回 false
Key 设计和降级兜底容易被忽略
同一个 key 用在所有接口上?那 /login 和 /pay 共享阈值,攻击者刷登录就能把支付干掉。限流 key 必须带业务上下文。
- 用户维度:
fmt.Sprintf("rate:user:%s:%s", userID, action) - IP 维度:
fmt.Sprintf("rate:ip:%s:%s", ip, action) - Redis 不可用时,立刻降级到
golang.org/x/time/rate.Limiter,按 IP 或 UID 维度兜底,别裸奔 - 每个
ZADD后必须配EXPIRE(或在 Lua 里一起发),否则冷用户长期占着 ZSet 内存
Redis 服务端时间必须开启 NTP 同步(推荐 chrony),误差控制在 100ms 内;多实例部署时,哪怕只是 1 秒窗口,机器间时间差超过 100ms 就会导致限流失效。

















