滑动窗口限流不能靠incr+expire实现,因其本质是每n秒清零的固定窗口,会导致临界流量翻倍、并发漏限/误限、无法表达动态时间区间及多实例时间错乱;必须用zset+lua脚本原子执行清理、插入、统计三步,且时间戳须取自redis服务端time命令。

滑动窗口限流不能靠 INCR + EXPIRE 实现,那是固定窗口,临界流量会翻倍;真滑动必须用 ZSET + Lua 脚本原子执行清理、统计、写入三步。
为什么 INCR + EXPIRE 不是滑动窗口
它本质是「每 N 秒清零一次」,比如窗口设为 60 秒,第 59 秒进 100 个请求,第 60 秒末 key 过期,第 61 秒又进 100 个——系统认为两个窗口互不干扰,实际 2 秒内已处理 200 次请求。这不是滑动,是硬切。
- 并发下
INCR成功但EXPIRE失败,key 永久存在 → 漏限 -
EXPIRE成功但后续多个客户端同时INCR,各自从 1 开始计数 → 误限 - 无法表达「以当前请求时间为终点、向前推 T 秒」这个动态区间
- 多实例部署时,各机器本地时间差 > 窗口粒度(如 1 秒),结果直接错乱
ZSET + Lua 脚本的最小可靠实现
核心逻辑必须在 Redis 服务端一次执行完:清理过期项 → 插入当前请求 → 统计窗口内总数。Lua 中必须调 redis.call("TIME") 获取服务端时间戳,不能传 time.Now().UnixMilli() 进去。
在 Go 中使用 google/wire 实现编译时依赖注入——wire.NewSet、wire.Build、wire.Bind(接口→实现)、wire.Struct、wire.Value、wire.Interface
-
score必须是毫秒级整数:seconds * 1000 + math.floor(microseconds / 1000) -
member不能重复,建议拼接fmt.Sprintf("%d_%s", now, uuid.NewString()[:8]) -
ZREMRANGEBYSCORE必须放在ZADD前,否则刚插入的请求可能被自己删掉 -
KEYS只能传业务标识(如"rate:ip:192.168.1.100"),ARGV按顺序传窗口长度、最大请求数、唯一 member - Go 侧调用返回值是
int64,需显式判断:result.(int64) == 1
Go 客户端调用的易错点
别把 Lua 脚本写成字符串拼接再传给 Eval —— 容易注入,且无法复用 SHA1 缓存。正确做法是预加载脚本并全局复用 EvalSha。
- 初始化时用
client.ScriptLoad(luaScript).Result()获取evalSha - 调用时用
client.EvalSha(ctx, evalSha, []string{key}, windowMs, maxReq, member) -
PoolSize至少设为 50,QPS 超 5k 时建议 100+;Timeout必须设为 100ms,超时直接降级 - Redis 不可用时,立刻 fallback 到
golang.org/x/time/rate.Limiter,按 IP 或 UID 维度兜底 - 限流
key必须带业务上下文,比如fmt.Sprintf("rate:api:%s:%s", ctx.Request.URL.Path, getClientIP(ctx)),避免 /login 和 /pay 共享阈值
最常被忽略的是 Redis 服务端时间同步 —— 必须开启 chrony,误差控制在 100ms 内,否则多实例下时间戳一错,整个滑动逻辑就失效了。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










