固定窗口限流在 go 中不应手写 time.now() + sync.mutex 实现,除非是单机且 qps 极低场景;否则应选用滑动窗口、令牌桶等更精确、线程安全的方案。

固定窗口限流在 Go 里别手写 time.Now() + sync.Mutex
直接说结论:除非是单机、QPS NewFixedWindowLimiter(10, time.Second) 在压测时大概率出现漏限或双倍放行——因为 time.Now() 调用时机和锁竞争会导致窗口边界错位。
常见错误现象:
- 第 999ms 允许第 10 个请求,第 1001ms 又允许第 1 个请求(新窗口刚重置),两毫秒内放行 20 次
- 高并发下
lastTime被多个 goroutine 同时读写,count值滞后或跳变 - 服务重启后计数器归零,但没做持久化,导致短时间“完全不控流”
实操建议:
- 若必须用固定窗口,优先走
rate.Limiter的AllowN(now, n)模拟(设burst为窗口容量,limit为每窗口总令牌数) - 单机轻量场景可改用
atomic.Int64存窗口起始秒级时间戳(time.Now().UTC().Unix() / 60),避免每次调time.Now() - 永远用 UTC 时间计算窗口 ID,跨时区部署时本地时间会直接错乱
Redis 实现固定窗口时,INCR 和 EXPIRE 必须原子执行
用 rdb.Incr(ctx, key) 后再 rdb.Expire(ctx, key, window) 是典型错误。这两个命令非原子,网络抖动或 Redis 主从切换时,可能出现 INCR 成功但 EXPIRE 失败 → key 永久存在 → 后续所有请求被误限。
使用场景:
- 分布式服务,需要多实例共享限流状态
- 窗口粒度 ≥ 1 分钟(秒级窗口在 Redis 上延迟不可控)
实操建议:
- 必须用 Lua 脚本封装:先
INCR,再判断是否为 1,是则EXPIRE,最后返回计数值 - key 构造必须含窗口起始时间,例如
fmt.Sprintf("rate:%s:%d", ip, now.Unix()/60),不能只用 IP - Redis 过期是惰性清理,
EXPIRE不保证准时失效,窗口大小建议 ≥ 30 秒,避免过早失效漏计
rate.Limiter 不是固定窗口,但能更稳地达成类似效果
rate.Limiter 是令牌桶,不是固定窗口,但它可以通过参数逼近固定窗口行为,且没有临界突刺问题。比如你要“每分钟最多 60 次”,不要硬写 60 秒窗口,而是用:rate.NewLimiter(rate.Every(1*time.Second), 60)。
参数差异:
-
rate.Every(1*time.Second)表示每秒补充 1 个令牌(即平均 QPS=1) -
60是 burst 容量,代表最多可累积 60 个令牌 —— 等效于“空闲 60 秒后,允许一次性突发 60 次” - 真正限制的是长期速率,突发由 burst 缓冲,不会像固定窗口那样在窗口切换瞬间断崖式拒绝
性能影响:
- 无锁路径,底层用
atomic,比手写 mutex 快 3–5 倍 - 内存占用恒定,不随请求数增长
- 注意
burst别设太大,比如 QPS=100 就设burst=50,否则空闲后突增流量会打垮下游
固定窗口真正的适用场景其实很窄
它只适合三类情况:后台导出接口(用户手动点击,QPS≈1)、运维健康检查(固定周期 ping,每分钟一次)、日志上报聚合(客户端批量发,服务端按分钟统计用量)。其他场景,尤其是用户直连的 API,固定窗口的临界突刺和状态不一致问题会直接暴露在业务指标里。
容易被忽略的地方:
- 固定窗口无法区分“均匀流量”和“毛刺流量”,而真实业务关心的是后者
- 多机部署时,如果没统一 NTP 时间,
time.Now().Unix() / 60算出的窗口 ID 可能差 1,导致同一分钟被算成两个窗口 - 哪怕只用单机,Go runtime 的 GC STW 也可能让
time.Now()返回旧时间,造成窗口重置延迟
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











