滑动窗口计数器不能只用map+定时清理,因会漏统计非整点对齐的请求;必须保留带时间戳事件或时间分片,常用环形数组实现,按需shift比ticker更精准高效。

滑动窗口计数器为什么不能只用 map + 定时清理
直接用 map[string]int 存请求次数、再起 goroutine 每秒删过期 key,看似简单,但会漏统计。比如窗口是 60 秒,当前时间戳是 1717023450,你只保留 key 对应的“最后更新时间”,那 1717023400~1717023449 这 50 秒内的请求就全算不到当前窗口里——因为它们在定时清理时被删了,而新请求还没触发重置逻辑。
本质问题是:滑动窗口要求“任意时刻往前推 N 秒”的累计值,不是“整点对齐的桶”。必须保留带时间戳的原始事件,或至少保留足够粒度的时间分片。
用环形数组 + 时间分片实现低开销滑动窗口
最常用且平衡内存与精度的做法:把窗口切分成固定数量的 slot(如 60 秒窗口切 60 个 1 秒 slot),用环形数组存每个 slot 的计数,再记录每个 slot 对应的起始时间戳。每次计数前先滑动指针、清空已过期 slot。
-
slotDuration要能整除窗口时长,否则边界计算易错;推荐用 1s/100ms 级别 - 数组长度 =
windowSeconds / slotDuration,必须是整数,否则向下取整后窗口实际变短 - 每次
Inc()前先调用shift()检查当前 slot 是否已过期,过期则归零并移动索引 - 并发安全需用
sync.RWMutex或atomic操作 slot 内计数(如果 slot 元素是uint64)
type SlidingWindowCounter struct {
slots []uint64
timestamps []int64
slotDur int64 // 单位:毫秒
windowDur int64 // 单位:毫秒
size int
mu sync.RWMutex
currentIndex int
}
<p>func (c *SlidingWindowCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now().UnixMilli()
c.shift(now)
atomic.AddUint64(&c.slots[c.currentIndex], 1)
}</p><p>func (c *SlidingWindowCounter) shift(now int64) {
slotStart := now - (now % c.slotDur)
for (now - c.timestamps[c.currentIndex]) >= c.windowDur {
c.slots[c.currentIndex] = 0
c.timestamps[c.currentIndex] = slotStart
c.currentIndex = (c.currentIndex + 1) % c.size
slotStart += c.slotDur
}
}</p>
time.Ticker 驱动清理 vs 按需 shift 的取舍
有人用 time.Ticker 每 100ms 触发一次全局 slot 清理,看起来更“主动”,但实际引入额外 goroutine 和锁竞争,且无法保证清理时机与请求到达严格同步——可能刚清完一个 slot,下一毫秒就来 1000 个请求,导致瞬时计数虚高。
按需 shift() 是更稳妥的选择,代价只是每次计数多几次原子读和条件判断(通常
- 高频写场景下,
shift()可能连续跳多个 slot,注意循环上限(比如最多跳c.size次,防止死循环) - 如果业务允许误差 ±1 个 slot,可把
shift()改为只检查当前 slot 是否过期,不循环推进——节省 CPU,适合 QPS 不高但窗口长的场景(如 1 小时窗口) - 不要在
shift()里做日志或网络调用,它会在每次Inc()中执行
用 Redis Sorted Set 实现分布式滑动窗口
单机方案扛不住横向扩展时,得靠 Redis。核心思路:用 ZADD 把请求时间戳作为 score 存入 zset,用 ZCOUNT key min max 统计窗口内请求数,再定期 ZREMRANGEBYSCORE 清理旧数据。
- key 命名建议含业务标识+用户 ID(如
rate:login:uid_123),避免 key 冲突 - score 必须用毫秒时间戳(
time.Now().UnixMilli()),RedisZCOUNT才能正确比较 - 注意 Redis 命令耗时:窗口大(如 1 小时)+ 请求密时,
ZREMRANGEBYSCORE可能阻塞,建议改用后台任务异步清理,或启用 Redis 7.0+ 的 lazyfree 机制 - 本地加一层 LRU cache(如
bigcache)缓存最近 100 个 key 的窗口结果,减少打 Redis 次数
真正难的不是代码怎么写,而是想清楚“窗口起点”由谁定义:客户端时间不可信,服务端必须统一用 time.Now();如果网关和服务部署在不同时区,要强制 NTP 同步,否则窗口边界错位会导致限流误判。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











