滑动窗口限流不能只用time.Now().Unix()算时间差,因会漏掉跨窗口请求聚合,如0.9s–1.8s内150次请求未超任一整秒窗口却明显超限;必须维护带时间语义的结构(如环形桶或ZSet)实现任意起始点向前回溯固定时长的动态求和。

滑动窗口限流为什么不能只用 time.Now().Unix() 做时间差判断
直接拿当前时间戳减去每个请求时间戳,再和窗口时长比大小——这看似合理,实则漏掉关键聚合逻辑。比如你设「1 秒内最多 100 次」,但真实流量在 0.9s–1.8s 这段连续 0.9 秒里来了 150 次:它没跨过任一整秒边界(0–1s 或 1–2s),所以单靠时间差会误判为合法。
滑动窗口的本质是「任意起始点向前回溯固定时间长度」的动态求和,不是静态切片。必须维护带时间语义的结构,比如环形桶或有序集合,否则统计口径就错了。
环形缓冲区实现中 atomic.AddInt64 和 atomic.StoreInt64 的分工陷阱
主逻辑里只该做两件事:获取当前 slot 下标、用 atomic.AddInt64 累加计数;清理旧桶必须交给独立 goroutine,用 atomic.StoreInt64 归零。
- 错误做法:每次请求都检查是否要清桶,多个 goroutine 可能同时执行
atomic.StoreInt64(&buckets[idx], 0),导致某桶被清两次,或漏清 - 正确做法:启动一个
time.Ticker,周期等于 slot 粒度(如 10ms),每次 tick 计算要清的下标,只写不读 - 注意:
UnixMilli()在 Go 1.17+ 才有,旧版本得用UnixNano() / 1e6,别硬套
bucket 数量与窗口时长匹配时最容易踩的整除边界
假设你要实现「1 秒内最多 100 次」,选了 100 个 slot,每个代表 10ms —— 表面看刚好 100 × 10ms = 1s,没问题。但实际计算求和范围时,要用模运算从当前下标往前数 windowSizeMs / slotMs 个位置。
容易错的地方:
- 误设 50 个 slot → 实际覆盖 500ms,限流过严,大量合法请求被拒
- 误设 1000 个 slot → 单个 slot 粒度太细(1ms),小流量打散在多个 slot,总和偏低,限流变松
- 求和循环里没处理好模绕回,比如
(i - n + len(buckets)) % len(buckets)少了+ len(buckets),负数取模结果异常
Redis ZSet 版本里 ZRemRangeByScore 的 score 精度坑
用 ZSet 实现滑窗时,score 必须是毫秒级时间戳(UnixMilli()),不能用秒级(Unix()),否则 1 秒内所有请求 score 相同,ZRemRangeByScore 会误删整批。
还有两个硬伤:
-
ZCard是 O(1),但ZRemRangeByScore是 O(log(N)+M),N 是总成员数,M 是删除数量;高并发下 Redis 延迟抖动明显 - 没做 pipeline,每次请求都发 3 条命令(清旧、查总数、加新),网络 RTT 成瓶颈
- 没设
EXPIRE的 fallback,如果客户端断连,ZSet 可能长期残留,得靠 TTL 主动兜底
环形缓冲区适合单机高频场景,ZSet 适合多实例共享状态,但别指望它扛住每秒几万次调用——真到那个量级,得上专用限流服务或硬件网关。

















