滑动窗口不能直接用time.Now()算时间差,因为Gin并发处理时多goroutine调用time.Now().UnixMilli()无法对齐窗口起点、区分同一秒内不同区间请求,且无分桶加权就不是真滑动窗口;必须用Truncate对齐桶起始时间,按用户维度隔离状态,用sync.RWMutex+[]*atomic.Int64保证并发安全。

滑动窗口为什么不能直接用 time.Now() 算时间差
因为 Gin 是并发处理请求的,多个 goroutine 同时调用 time.Now().UnixMilli() 并做减法,既无法对齐窗口起点,也无法区分「同一秒内不同 100ms 区间」的请求分布。更关键的是:没有分桶,就谈不上加权;没加权,就不是滑动窗口——只是个带时间戳的计数器,和固定窗口没本质区别。
典型错误现象:rate limit triggers too early or too late、burst traffic bypasses limit、高并发下 data race 报错(尤其用了全局 map 或 sync.Map 存时间戳)。
- 必须用
time.Now().Truncate(shardDur)对齐每个桶的起始时间点,否则同一窗口内请求可能被散落到相邻桶 - 桶的 key 不该是原始时间戳(会导致频繁创建/删除),而应是截断后的时间点 + 用户维度标识(如
ip:127.0.0.1或user_id:1001) - 别把桶数组存在
c.Request.Context()里——它随请求生命周期销毁,限流状态必须跨请求持久
SlidingWindow 结构体怎么定义才安全
核心是隔离维度、避免锁争抢、防止溢出。推荐用 sync.RWMutex + 固定长度 []*atomic.Int64,而不是 sync.Map(不支持原子增减)或普通 map[time.Time]int64(并发写 panic)。
示例关键字段:
type SlidingWindow struct {
mu sync.RWMutex
shards []*atomic.Int64
shardDur time.Duration // 如 100ms
window time.Duration // 如 1s
maxReq int64 // 理论上限,非硬阈值
}
-
shards数量建议 10~100:太少(如 2)导致窗口跳变剧烈;太多(如 1000)增加内存与锁开销 - 每个
*atomic.Int64用于单桶计数,避免int溢出(高 QPS 下 1 秒内可能超万次请求) -
maxReq是「窗口总容量 × 分桶数」的参考值,实际放行量 = 各桶当前值 × 权重之和,所以真实通过量可能略高于它——这是滑动窗口的正常行为,不是 bug
如何在 Gin 中间件里正确调用滑动窗口
中间件必须复用同一个 SlidingWindow 实例,不能每次请求都 new 一个。状态要按用户维度隔离,但结构体本身是全局单例。
关键逻辑步骤:
- 从请求中提取标识(如
c.ClientIP()或c.GetHeader("X-User-ID")) - 用该标识 + 当前截断时间(
now.Truncate(shardDur))生成唯一桶 key - 读取当前窗口内所有桶(按时间倒序),计算加权和:
sum += shard[i].Load() * weight[i] - 若加权和 ≥
maxReq,返回http.StatusTooManyRequests并c.Abort() - 否则对最新桶执行
shard[newestIndex].Add(1)
注意:weight 应随桶的新旧程度衰减(例如:最新桶权重 1.0,前一个 0.9,再前一个 0.81…),不能简单求和。
滑动窗口和令牌桶在 Gin 里怎么选
如果业务需要「允许合理突发 + 长期速率可控」,比如 API 接口防刷、AI 模型调用频控,选令牌桶(用 golang.org/x/time/rate 更省心);如果必须精确控制「最近 N 秒内真实请求数分布」,比如风控系统判断短时高频行为、支付下单防重放,才值得上滑动窗口。
滑动窗口的真实代价常被低估:它需要维护多桶状态、加权计算、时间对齐逻辑,且单机场景下性能不如令牌桶。Gin 默认不带限流能力,你写的每行滑动窗口代码,都在替标准库补缺失的原子操作和并发安全设计。
最容易被忽略的一点:滑动窗口的「窗口对齐」必须全局一致。如果服务部署在多台机器上又没用 Redis 同步时间,各节点的 Truncate 起点可能差几毫秒——这会导致限流结果不可预测。单机可靠,跨机需分布式协调。


















