rate.Limiter 不能实现滑动窗口,因其底层为令牌桶,仅维护当前令牌数和补充时间,不记录历史请求时间戳,无法回溯“过去N秒内请求数”;滑动窗口必须依赖时间戳有序存储与范围查询,需Redis ZSET+Lua原子操作。

为什么 rate.Limiter 不能直接实现滑动窗口
rate.Limiter 底层是令牌桶,只维护一个“当前令牌数 + 下次补充时间”,它不记录历史请求时间戳,也没法回溯“过去 N 秒内发生了多少次请求”。所以哪怕你调用 Allow() 十次,它也只知道“此刻有没有令牌”,完全无法回答“最近 60 秒内是否已超 100 次”这种问题。
常见错误现象:有人试图用多个 rate.Limiter(比如每秒一个)拼滑动窗口,结果发现 key 管理爆炸、内存泄漏、时间片对不齐,最后逻辑崩坏。
- 滑动窗口本质依赖时间戳的有序存储和范围查询,必须有状态快照能力
-
rate.Limiter是无状态的瞬时判断器,设计目标就是轻量、无锁、单实例高频复用 - 强行在内存里维护时间戳切片做滑动窗口?单机还行,但并发高时
sync.Mutex会成瓶颈,且无法跨进程共享
用 Redis + ZSet 实现高精度滑动窗口的最小可行代码
Redis 的 ZSet 天然适合:用时间戳作 score,请求标识(如 "req:1722743040.123")作 member,ZCount 查范围,ZRemRangeByScore 清旧数据——全部原子操作,无竞态。
实操建议:
- 窗口粒度别设太细(比如 10ms 一片),否则
ZSet元素暴涨,ZCount延迟上升;生产环境推荐 100ms ~ 1s 片级 - key 设计必须带业务维度,例如
"rate:ip:" + ctx.ClientIP()或"rate:uid:" + userID,避免所有用户挤一个 key - 务必设置过期时间(
EXPIRE),否则冷 key 长期占内存;窗口大小为 60 秒,EXPIRE至少设 65 秒
核心逻辑片段(Gin 中间件节选):
func (m *MiddlewareBuilder) Handler() gin.HandlerFunc {
return func(c *gin.Context) {
key := m.keyGenFunc(c)
now := float64(time.Now().UnixMilli())
minScore := now - float64(m.windowSize.Milliseconds())
<pre class="brush:php;toolbar:false;"> // 查询当前窗口内请求数
count, err := m.client.ZCount(c, key, strconv.FormatFloat(minScore, 'f', -1, 64), strconv.FormatFloat(now, 'f', -1, 64)).Result()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "rate check failed"})
return
}
if count >= m.limit {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
// 记录本次请求
_ = m.client.ZAdd(c, key, &redis.Z{Score: now, Member: fmt.Sprintf("req:%f", now)}).Err()
_ = m.client.Expire(c, key, m.windowSize+5*time.Second).Err()
c.Next()
}}
如何避免 zset 内存无限增长
滑动窗口不清理旧数据,ZSet 会越攒越多,直到 OOM。这不是 Redis 配置问题,而是逻辑缺失。
Go语言(Golang)1.26.0版本提供 Go 官方 Windows amd64 MSI 安装包下载入口,版本号 1.26.0,可用于旧项目维护、兼容性测试和指定版本开发环境配置。
关键动作必须在每次请求前或后执行:
- 用
ZRemRangeByScore(key, "-inf", minScore)主动剔除窗口外数据,不能依赖 TTL 自动清理(TTL 只删 key,不删内部元素) - 注意:这个命令要放在
ZCount之后、ZAdd之前,否则可能误删刚进来的请求 - 如果担心
ZRemRangeByScore影响性能,可改为懒删除:只在ZCount返回值接近阈值时才触发清理
示例补丁(接上一段):
// 在 ZCount 之后、ZAdd 之前插入: _ = m.client.ZRemRangeByScore(c, key, "-inf", strconv.FormatFloat(minScore, 'f', -1, 64)).Err()
分布式场景下 key 冲突与限流精度取舍
同一个用户从不同节点发起请求,若各节点都连同一 Redis,没问题;但如果用了分片 Redis 或多套集群,就可能出现 key 分散、限流失效。
更隐蔽的问题是精度妥协:
- Redis 命令执行有网络延迟,两个紧挨着的请求可能因 RTT 差异被算进不同毫秒片
- 客户端时间不同步(尤其是容器/边缘设备)会导致 score 偏移,窗口计算失真
- 如果你用
time.Now().Unix()(秒级)而非UnixMilli(),那 1 秒内所有请求 score 相同,ZCount就退化成固定窗口
真正容易被忽略的地方:不要在限流中间件里做重试或 fallback。一旦 ZCount 超时或报错,应直接放行或返回 503,而不是降级到本地内存限流——那会彻底破坏全局配额语义。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










