不能用map[int64]int存时间片,因其并发不安全会panic,且本质是固定窗口而非滑动窗口;真滑动窗口须用[]int64+atomic实现,索引计算和过期判断均依赖绝对时间,并配合懒清理与原子更新。

为什么不能用 map[int64]int 直接存时间片
直接用 map[int64]int 按秒(或毫秒)做 key 存请求数,看似直观,但实际会触发 panic: concurrent map read and map write。Go 的 map 默认非并发安全,多个 goroutine 同时读写必然崩溃。加 sync.RWMutex 全局锁又会让吞吐量断崖下跌——尤其在 QPS 上千时,锁争用明显。更隐蔽的问题是:它根本不是滑动窗口,而是固定窗口。比如用 time.Now().Unix() 作为 key,所有请求都归到“当前秒”,第 59 秒和第 0 秒的请求永远分属两个桶,中间没重叠,无法实现“过去 60 秒内”的连续统计。
用 []int64 + atomic 实现真滑动窗口
预分配一个固定长度的 []int64,每个元素代表一个时间片的计数,配合原子操作更新,才是轻量、无锁、低 GC 的单机方案。关键点不在“分片”,而在“索引计算”和“过期判断”必须用绝对时间:
- 设窗口总长
windowSec = 60,分片数shards = 60(即每片 1 秒),则每片时长shardDuration = int64(windowSec * 1e9 / shards) - 当前片索引:
idx := (now.UnixNano() / shardDuration) % int64(shards),注意先转int64防溢出 - 某片对应的时间起点是
idx * shardDuration,判断是否过期得比对now.UnixNano() - windowSec*1e9,不能只看索引差 - 每次请求进来时执行「懒清理」:遍历所有片,把起点早于窗口起点的对应位置清零(或跳过累加)
- 更新必须用
atomic.AddInt64(&slice[idx], 1),禁止slice[idx]++
为什么别用 time.Timer 或 time.Ticker 清理过期桶
定时器清理听上去合理,但实际不可控:触发时机不准、可能堆积 goroutine、且清理本身有延迟,导致窗口统计滞后甚至错乱。真实场景中,清理动作应和请求处理耦合——每次请求进来时顺手做,既省资源又保实时性。更关键的是,time.Timer 是 per-bucket 的,60 片就要起 60 个 timer,内存和调度开销陡增;而懒清理只需一次遍历,且可跳过未使用的片。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
Redis 分布式场景下,ZSET + Lua 是唯一可靠选择
单机方案跨不了进程,多实例部署必须上 Redis。但很多人误用 INCR + EXPIRE,这本质是固定窗口,且两个命令非原子:断连时 key 可能永久存在或丢失。真正滑动窗口必须用 ZSET 存时间戳为 score,再靠 Lua 脚本原子执行三步:ZREMRANGEBYSCORE 清理过期项 → ZADD 写入新请求 → ZCOUNT 统计。特别注意:Lua 中必须调用 redis.call("TIME") 获取 Redis 服务端时间,不能传 Go 的 time.Now(),否则各机器 NTP 偏差超窗口粒度(如 1 秒)就会导致限流失效。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










