滑动窗口限流不能直接用 time.Now() 算时间差,因会导致竞态、无法分桶统计、时间未对齐;必须按时间片分桶 + 原子更新 + 动态加权,且需 Truncate 对齐窗口起点、隔离用户维度、避免 sync.Map 或 map 并发读写。

滑动窗口限流为什么不能直接用 time.Now() 算时间差
因为 Gin 是并发处理请求的,多个 goroutine 同时读写一个全局计数器会引发竞态;而单纯靠 time.Now().UnixMilli() 做窗口切分,无法区分“同一窗口内不同子区间”的请求分布——比如 1 秒窗口拆成 10 个 100ms 桶,你得知道每个桶里进了几个请求,不是只记总数。
真正可用的滑动窗口必须:按时间片分桶 + 原子更新 + 动态加权(越新的桶权重越高)。Gin 本身不提供限流中间件,得自己搭结构。
- 别用
sync.Map存桶——它不支持原子增减,且 key 是时间戳,频繁创建/删除开销大 - 别把窗口状态存在 HTTP 请求上下文(
c.Request.Context())里——生命周期错配,限流状态必须跨请求共享 - 推荐用
sync.RWMutex配合固定长度 slice 存桶,每桶用int64防止溢出
如何定义滑动窗口结构体并初始化桶
核心是把 1 秒窗口均分为 shards 份(比如 10),每个 shard 对应一个时间槽,用 atomic.Int64 计数。窗口总容量 = 每个 shard 容量 × shard 数,但实际允许通过量 = 各 shard 当前值加权和(新 shard 权重高,旧 shard 权重低)。
type SlidingWindow struct {
mu sync.RWMutex
shards []*atomic.Int64
shardDur time.Duration // 单个 shard 时长,如 100ms
window time.Duration // 总窗口时长,如 1s
maxReq int64 // 每窗口最大请求数
}
<p>func NewSlidingWindow(window time.Duration, shards int, maxReq int64) <em>SlidingWindow {
shardDur := window / time.Duration(shards)
s := &SlidingWindow{
shards: make([]</em>atomic.Int64, shards),
shardDur: shardDur,
window: window,
maxReq: maxReq,
}
for i := range s.shards {
s.shards[i] = &atomic.Int64{}
}
return s
}-
shards数量建议取 10~100:太少会导致精度差(比如 2 个 shard,窗口跳变剧烈);太多增加锁竞争和内存占用 -
maxReq是「理论峰值」,真实通过量可能略高于它——因加权求和时旧桶衰减,新桶未满,这是滑动窗口的特性,不是 bug - 不要在 handler 里每次 new 一个
SlidingWindow——它是全局单例,应初始化一次后注入 Gin 中间件
Gin 中间件里怎么获取当前 shard 并原子累加
关键在定位请求属于哪个 shard:用 time.Since() 算距窗口起点偏移,再除以 shardDur 取模。注意必须用同一基准时间(比如窗口起始秒),否则跨 minute 时计算错乱。
立即学习“go语言免费学习笔记(深入)”;
func (sw *SlidingWindow) Allow(ip string) bool {
now := time.Now()
// 统一窗口起点:向下取整到最近 window 边界
base := now.Truncate(sw.window)
// 计算当前请求落在第几个 shard(从 0 开始)
offset := now.Sub(base)
idx := int(offset / sw.shardDur)
if idx >= len(sw.shards) {
idx = len(sw.shards) - 1
}
<pre class="brush:php;toolbar:false;">sw.mu.Lock()
defer sw.mu.Unlock()
// 清理过期 shard:只保留 [now-sw.window, now] 范围内的桶
// 实际中可另起 goroutine 定期清理,或在此处遍历重置旧桶(简单场景够用)
// 当前 shard +1
sw.shards[idx].Add(1)
// 加权求和:越靠近 now 的 shard 权重越高(线性衰减)
var total int64
for i, shard := range sw.shards {
shardTime := base.Add(time.Duration(i) * sw.shardDur)
weight := float64(now.Sub(shardTime)) / float64(sw.window)
if weight < 0 {
weight = 0
}
total += int64(float64(shard.Load()) * weight)
}
return total <= sw.maxReq}
- 这里用了线性衰减权重,也可换指数衰减(
math.Exp(-lambda * age)),但需引入 math 包且参数难调 -
Truncate(sw.window)是关键——确保所有请求对齐同一窗口周期,避免因系统时钟抖动导致桶错位 - 生产环境务必加 IP 或 user_id 级隔离(用
map[string]*SlidingWindow),否则所有用户共用一套桶,变成全局限流
为什么不用第三方库如 golang.org/x/time/rate
rate.Limiter 是漏桶,不是滑动窗口。它平滑匀速放行,无法体现「短时突发允许、长时间平均受限」的业务需求——比如 API 允许 1 秒内最多 100 次调用,但 500ms 内打满 100 次后,接下来 500ms 必须阻塞;而漏桶会把这 100 次摊到 1 秒里,实际响应延迟升高。
-
rate.Every(10*time.Millisecond)配burst=100看似能撑住 1 秒 100 次,但 burst 是令牌桶容量,不是窗口计数,超限后要等新令牌生成,不符合滑动窗口「自然滚动、无硬阻塞」语义 - 有开源库如
uber-go/ratelimit提供滑动窗口,但它内部用sync/atomic实现,不带权重衰减,只是简单 sum 最近 N 个桶——精度不如加权版本,且没做 shard 过期清理 - 自己实现虽然多十几行,但逻辑可控、无依赖、易调试;上线前务必用
go test -race跑并发压测
滑动窗口真正的复杂点不在计数,而在时间对齐和权重函数选型——这两处写错,限流效果就完全失真。别省掉单元测试里模拟跨 shard 边界的请求序列。


















