slidingwindowcounter必须用环形数组+时间戳对齐,因map+time.ticker存在逻辑缺陷:无法精确覆盖“当前时间往前推n秒”的任意时刻区间,定时清理仅整点对齐导致漏算,且map并发写panic、锁争用致qps断崖下降;环形数组需满足windowdur被slotdur整除、timestamps存起始时间戳、shift用绝对时间差判断三大硬约束。

SlidingWindowCounter 必须用环形数组 + 时间戳对齐,不能靠 map + time.Ticker 凑合——否则任意时刻查“过去 N 秒”必然少算、错算,且高并发下直接 panic 或锁争用断崖式降 QPS。
为什么 map[string]int + time.Ticker 会漏统计
这不是性能问题,是逻辑缺陷:滑动窗口要求“当前时间往前推 N 秒”的精确覆盖,而定时清理只能整点对齐。
- 比如窗口设为 60 秒,
time.Ticker每秒清一次,但清理触发时刻是 1717023420、1717023421……那 1717023400~1717023419 这 20 秒的请求,在第 20 次清理时就被删了,但新请求还没来得及归入下一个 slot -
map本身不存时间戳,只存最后更新时间或累计值,无法回溯任意时间片内的真实分布 - 多个 goroutine 并发写
map直接 panic;加sync.RWMutex后,实测 QPS 在 2k+ 就开始抖动
环形数组实现必须满足三个硬约束
少一个都会导致窗口边界漂移或统计跳变,线上已踩过坑。
-
windowDur(毫秒)必须能被slotDur(毫秒)整除,例如 60s 窗口配 100ms 分片 → 长度 = 600;若用 128ms,向下取整后实际窗口只剩 51.2s -
timestamps[i]存的是该 slot 的**起始时间戳**(如now - now%slotDur),不是time.Now().UnixMilli(),否则 shift 判断失效 -
shift()里的时间比较必须用绝对时间差:now - c.timestamps[c.currentIndex] >= c.windowDur,不能用索引差(如c.currentIndex - oldIndex),后者在系统时间跳变或调度延迟时完全不可靠
并发安全的关键分工
别一把锁包全局,也别全用 atomic —— 计数和索引移动语义不同,混用会出竞态。
- slot 内计数用
atomic.AddUint64(&c.slots[i], 1),禁止c.slots[i]++ -
currentIndex和timestamps更新必须由sync.RWMutex保护,因为 shift 是读-改-写操作,原子操作无法保证序列一致性 - 不要在
Inc()里做完整遍历清理,只检查当前 slot 是否过期;懒清理 + 单次 shift 最多移动 1 个位置,避免每次调用都 O(N) 扫描
什么时候该换方案
环形数组适合单机 ≤ 5k QPS、key 维度不多(如按 IP 或 API 路径聚合)的场景。超出就不是调优问题,是架构选择错误。
- 单机 > 10k QPS 且 key 数量有限:改用分片原子计数器,开
runtime.NumCPU()个uint64变量,key 哈希后取模写入对应分片,Count()时遍历求和 - 多实例部署或需跨服务共享状态:必须上 Redis + Lua,把
ZADD、ZREMRANGEBYSCORE、ZCARD封装成原子脚本;注意所有时间戳由 Go 传入time.Now().Unix(),禁用 Lua 里的redis.call("TIME")(跨节点不一致) - 别碰
sync.Map存时间片 —— 它为读多写少设计,滑动窗口高频更新+遍历,性能反不如分片map+ 独立sync.RWMutex
真正难的不是写对 Inc 和 Count,是 slotDuration 选多细、windowDur 和 slotDur 是否严格整除、以及 shift 里时间比较是否用绝对时间戳——这三个点线上出过三次 P0 故障。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











