正确做法是用sync.map缓存标准化ip对应的rate.limiter实例,设置合理burst(如5–8)与定时清理策略,结合可信x-forwarded-for解析、连接层超时控制及白名单绕过,形成完整防护链路。

rate.Limiter 是 Go 官方维护的令牌桶实现,用它做 IP 限流最轻量、最可控。别一上来就引入 gin-contrib/limiter 或 tollbooth,多数场景自己写几行就能搞定,还能避免 IP 和路径逻辑耦合、内存泄漏、并发不安全等问题。
为什么不用第三方包直接套用
很多项目直接 go get github.com/gin-contrib/limiter,但实际踩坑不少:
• limiter 默认按 c.ClientIP() + c.Request.URL.Path 组合限流,你只想按 IP 限,它却把 /api/user 和 /api/order 算成两个桶;
• 它内部用 map[string]*Limiter 存实例,没清理机制,跑几天内存就涨上去;
• 某些版本对 IPv6 处理不一致,c.ClientIP() 返回 "::1" 还是 "127.0.0.1" 全看前端是否透传 X-Forwarded-For,没做 normalize 就会漏放行。
sync.Map 存 IP 对应的 *rate.Limiter
每个 IP 必须独享一个 rate.Limiter 实例,否则所有 IP 共享同一个桶,等于没限。
用 sync.Map 而不是普通 map,是因为它原生支持并发读写,不需要额外加锁。
关键点:
-
rate.NewLimiter(rate.Limit(1), 5)表示「每秒最多 1 个请求,允许突发 5 个」——注意第一个参数是rate.Limit类型,不是整数 - key 建议用
net.ParseIP(c.ClientIP()).To4().String()统一转 IPv4,避免 "::1" 和 "127.0.0.1" 被当成两个 IP - 不要在
Allow()前加锁 ——rate.Limiter本身是并发安全的
必须加定时清理空闲限流器
用户访问完就再也不来,对应 *rate.Limiter 实例永远留在 sync.Map 里,内存只增不减。
常见错误是用 time.AfterFunc 给每个实例单独设超时,开销太大。
正确做法是起一个 goroutine,每 30 秒扫一次:
func (ls *Limiters) cleanLimiter() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
ls.limiters.Range(func(key, value interface{}) bool {
l := value.(*Limiter)
if time.Since(l.lastTime) > 5 * time.Minute {
ls.limiters.Delete(key)
}
return true
})
}
}
• lastTime 在每次 Allow() 成功后更新
• 清理阈值设 5 分钟,太短会导致频繁重建,太长内存积压
中间件里别直接用 r.Burst() 当剩余数
文档常误写 c.Header("X-RateLimit-Remaining", strconv.FormatInt(int64(r.Burst()), 10)),这是错的。Burst() 返回桶容量(比如 5),不是当前剩余令牌数。
要算真实剩余,得用 r.ReserveN(time.Now(), 1) 再查 .OK 和 .Delay(),但代价高。
生产建议:只返回固定头或干脆不返回,避免误导前端。
真正容易被忽略的是 IP 提取逻辑 —— 反向代理下 c.ClientIP() 拿到的是 Nginx 的内网地址,必须配合 TrustedProxies 设置和 X-Real-IP 头解析,否则限流形同虚设。











