固定窗口限流易错点在于并发重置:多个goroutine同时判断窗口过期并覆盖lastreset,导致计数翻倍;正确做法是仅首个抢到锁的goroutine重置counter和lasttime,且顺序必须为先置0再更新时间。

固定窗口限流在 Go 里最容易写错的不是逻辑,而是时间重置判断——time.Now().Sub(l.lastReset) > l.window 这个条件在高并发下可能漏判,导致单窗口请求数翻倍。
为什么固定窗口限流器的 lastReset 时间更新容易出错
很多实现把 lastReset 放在锁内更新,但没考虑:当多个 goroutine 同时发现窗口过期,它们会各自重置 counter 并覆盖 lastReset。结果是:本该统一重置的窗口,被多次“提前”刷新,造成临界点穿透。
正确做法是只允许第一个抢到锁的 goroutine 执行重置,其余直接复用新时间点判断:
- 用
if now.Sub(l.lastTime) > l.window判断是否过期,而不是>= - 重置时必须先设
l.counter = 0,再设l.lastTime = now,顺序不能反 - 不要在
Allow()返回前才更新lastTime,否则并发请求可能读到旧值
FixedWindowLimiter 的并发安全关键点
看似简单的互斥锁,实际有三个易忽略细节:
-
sync.Mutex必须保护整个判断+更新流程,不能只锁计数器增减 - 避免在锁内调用
time.Now()—— 虽然开销小,但高 QPS 下会成为瓶颈;建议提前获取now再进锁 - 不要用
time.Unix()秒级时间戳做判断,它丢失毫秒精度,1 秒内大量请求会全部挤进同一个窗口
示例修正片段:
func (l *FixedWindowLimiter) Allow() bool {
now := time.Now() // 提前获取
l.mu.Lock()
defer l.mu.Unlock()
if now.Sub(l.lastTime) > l.window {
l.counter = 0
l.lastTime = now // 立即更新,不等返回
}
if l.counter >= l.limit {
return false
}
l.counter++
return true
}
滑动窗口限流为什么比固定窗口更难落地
滑动窗口不是“多几个时间片”那么简单。核心难点在内存与精度的平衡:
- 用数组存每个时间片计数(如 1s 拆 10 片),需频繁移动下标、计算偏移,
sync.Mutex锁粒度稍大就拖慢吞吐 - 用链表或队列存时间戳(如
[]int64),每次Allow()都要遍历剔除过期项,O(n) 复杂度在万级 QPS 下明显卡顿 - Go 原生没有原子操作支持 slice 截断,
slice = slice[1:]不是线程安全的,必须加锁,反而抵消了 slice 的轻量优势
真正可上线的滑动窗口,往往得放弃“完全精确”,改用带误差容忍的近似算法,比如每 100ms 做一次批量清理,而非每次请求都扫一遍。
令牌桶限流中 tokens 字段的原子更新陷阱
用 atomic.Int32 管理令牌数很常见,但有两个坑:
- 令牌补充逻辑如果放在
Allow()里,每次请求都要算时间差、补令牌,高频下atomic.Load/Store+time.Since()成为热点 - 补令牌和扣令牌必须在同一原子操作内完成,否则可能出现“补了没扣”或“扣了没补”的竞态
- 初始
tokens设为 0 时,第一次请求永远失败——因为还没来得及补充,必须预热或用atomic.CompareAndSwap做条件更新
简单起见,生产环境更推荐用 time.Ticker 单独 goroutine 定期补令牌,Allow() 只做原子扣减,避免时间计算开销。
滑动窗口的精度代价是实打实的 CPU 和内存,不是加个锁就能解决的。真正压测时,你会发现最耗资源的不是算法本身,而是时间戳比较和 slice 操作的锁争用——这点在本地单测根本暴露不出来。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











