固定窗口限流易突刺因周期性重置导致临界点计数断层,且INCR+EXPIRE非原子操作引发永久限流或漏限;滑动窗口须用ZSET+Lua并以Redis TIME为时间锚点,单机场景推荐[]int64+atomic。

固定窗口限流为什么容易突刺,INCR + EXPIRE 不是万能解
固定窗口限流在 Go 中最常见写法是 redis.Incr(key) 加 redis.Expire(key, window),但它本质是「周期性重置」,不是真正的时间连续覆盖。比如窗口设为 60 秒,第 59 秒末和第 61 秒初的请求会被分到两个完全隔离的计数器里,中间毫无交叠——这就导致临界时刻可能双倍放行。
更危险的是原子性缺失:INCR 成功但 EXPIRE 失败(网络中断、Redis 崩溃),key 永久存在,后续所有请求都被误限;反之若 key 被提前驱逐或未设过期,就彻底漏限。线上事故常由此触发。
使用场景有限:仅适合低精度限流(如“每小时最多 1000 次”),且单实例部署、无强一致性要求时才可勉强接受。
- 别用
time.Now().Unix() / 60拼 key 名模拟分桶——不同机器时间差 > 1 秒就会错乱 - 别依赖
EXPIRE的准时性:Redis 过期是惰性+定期混合清理,实际失效可能延迟数秒 - QPS 超 500 后,
INCR+EXPIRE的 pipeline 并发压测常暴露竞态,返回值与实际 key 状态不一致
滑动窗口必须用 ZSET + Lua,time.Now() 在 Go 层传时间戳是致命错误
真滑动窗口要回答一个问题:“过去 N 秒内有多少请求?”这要求每个请求带真实时间戳存入有序结构,并支持按时间范围查数+删旧。唯一靠谱方案是 Redis ZSET 配合服务端执行的 Lua 脚本。
关键陷阱在于时间源:time.Now().Unix() 在 Go 层算好再传给 Lua,等于把客户端时间当权威。若两台机器 NTP 同步误差达 1.2 秒,而窗口设为 1 秒,B 机就会把刚进来的请求判为「已过期」,A 机却还在计数——结果 QPS 翻倍也不自知。
正确做法是在 Lua 脚本里调 redis.call("TIME"),它返回 [seconds, microseconds],拼成毫秒级时间戳:seconds * 1000 + microseconds / 1000。这个值才是全局一致的锚点。
- 脚本内三步不可拆:先
ZCOUNT统计当前窗口请求数 → 判断是否超限 →ZADD新请求 →ZREMRANGEBYSCORE清理过期项 - 别用
pipeline模拟原子性:网络延迟或重试会让中间状态不可控 - 返回值是
int64,Go 里必须显式转:result := int64(reply.(int64)),否则类型断言失败静默忽略
单机轻量滑动窗口用 []int64 + atomic,别碰 map[int64]int
如果只是单实例网关或内部服务,不需要跨进程共享状态,[]int64 + atomic 是性能最高、内存最省的方案。核心是预分配固定长度 slice,每个元素代表一个时间片的计数,靠原子操作更新。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
常见 panic 来自直接读写 map[int64]int:多个 goroutine 并发修改会触发 concurrent map read and map write。用 sync.RWMutex 锁整个 map 又会导致高并发下吞吐骤降;sync.Map 更不适合——它为读多写少设计,而滑动窗口是高频写+遍历。
时间片粒度要权衡:窗口总长 60 秒,分 60 片(每片 1 秒)比 600 片(每片 100ms)更稳。过期判断不能只看索引差,必须用绝对时间比对:if now.UnixNano()-int64(i)*shardDuration 。
- 索引计算务必先转
int64:(now.UnixNano() / shardDuration) % int64(shards),否则溢出后索引错乱 - 每次请求进来时「懒清理」:顺手把早于窗口起点的 bucket 清零,而不是起 goroutine 定时清理
- 更新必须用
atomic.AddInt64(&slice[i], 1),slice[i]++不是原子操作
为什么 LPUSH + LTRIM 不适合滑动窗口
LPUSH key timestamp 再 LTRIM key 0 N-1 看似简单,但它只按 list 长度截断,无法按时间范围精准清理。比如窗口设为 60 秒,但某秒突发 1000 次请求,list 尾部全是这一秒的时间戳,LTRIM 只能砍掉最老的 K 个,根本无法保证剩下的全在 60 秒内——老数据可能滞留数分钟。
更严重的是,LTRIM 后你没法知道被 trim 掉的元素里有多少是有效期内的,也就无法准确统计当前窗口请求数。它适合做粗略队列缓存,但不能替代滑动窗口的语义。
如果你真想用 list,得配合额外逻辑:每次 LRANGE 全量扫描并过滤时间戳,但 O(N) 复杂度在高并发下不可接受。所以生产环境只要求精确,就绕不开 ZSET。
- 别用
member存业务 ID 或 clientIP——ZSET 的member是去重的,同一 IP 多次请求会被覆盖 - score 必须统一精度:全用毫秒或全用秒,混用会导致
ZCOUNT范围错位 - zset key 命名建议带业务维度,如
rate:login:ip:192.168.1.1,避免 key 冲突膨胀
复杂点不在算法本身,而在时间源一致性与原子性边界。哪怕脚本只差一行没写 redis.call("TIME"),或者 Go 层忘了把返回值转 int64,线上就可能突然限不住或全拦住。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










