不能用 INCR + EXPIRE 实现滑动窗口,因其仅支持固定窗口(如每秒清零),无法准确表达“过去60秒内最多100次”;多key分桶模拟会导致key爆炸、内存高、跨桶统计难保障原子性与性能;ZSET配合Lua脚本(ZREMRANGEBYSCORE+ZADD+ZCARD)可单key、单RTT、语义准确实现。

为什么不能用 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 连接池。
go-redis/v9 中必须用 Lua 脚本原子执行三步操作
滑动窗口的核心动作是“清理旧数据 → 插入当前请求 → 统计总数”,这三步拆开调用必然出错。高并发下 A 和 B 同时看到 99 条记录,各自写入第 100 条,结果突破阈值。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 必须把
ZREMRANGEBYSCORE、ZADD、ZCARD压进一个 Lua 脚本,由 Redis 单线程顺序执行 - 脚本里调
redis.call("TIME")获取服务端时间戳,拼成毫秒整数:seconds * 1000 + math.floor(microseconds / 1000) - 绝不能传
time.Now().UnixMilli()进去——多实例 NTP 误差 > 窗口长度(如 1 秒)时,结果直接错乱 -
ARGV全是字符串,Lua 里必须用tonumber()转型后再参与计算,否则时间戳字符串比大小会出错
Go 客户端调用 EVALSHA 的关键细节
别每次 Eval 都传完整脚本体,网络开销大还可能超时失败。正确做法是预加载并缓存 SHA1。
立即学习“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放所有动态值 - 返回值统一用整型:
1表示允许,0表示拒绝;Go 侧要显式转:result.(int64) == 1
连接池、降级与 key 设计容易被忽略的点
限流器自己不能成为瓶颈,也不能因 Redis 不可用就全线崩溃。
-
PoolSize至少设为50,QPS 超 5k 时建议100+;Timeout必须设100ms,超时立刻 fallback 到golang.org/x/time/rate.Limiter - key 必须带业务上下文,例如
fmt.Sprintf("rate:%s:%s", action, ip);共用同一个 key 会让 /login 和 /pay 相互干扰 -
EXPIRE必须和ZADD在同一 Lua 脚本里执行,否则有竞态:key 可能刚设好过期就被删掉,或长期不清理占满内存 - 每个请求插入的
member要唯一,建议用fmt.Sprintf("%d_%s", now_ms, uuid.NewString()[:8]),避免同一毫秒内覆盖

















