Gin 项目里加个 IP 限流,网上一搜大把现成代码,长得都差不多:用 golang.org/x/time/rate 给每个 IP 建一个令牌桶,存在 map 里。
我们也是这么写的:
type IPRateLimiter struct {
ips map[string]*rate.Limiter
mu sync.Mutex
r rate.Limit
b int
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
return &IPRateLimiter{
ips: make(map[string]*rate.Limiter),
r: r,
b: b,
}
}
func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter, exists := i.ips[ip]
if !exists {
limiter = rate.NewLimiter(i.r, i.b)
i.ips[ip] = limiter
}
return limiter
}
func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !limiter.GetLimiter(ip).Allow() {
util.Error(c, 429, 429, "Too many requests")
c.Abort()
return
}
c.Next()
}
}
功能上没问题,限流是生效的。但这段代码有个坑,短期跑不出来,长期一定出事。
这个 map 只进不出
看 GetLimiter:IP 不存在就新建一个塞进 map。
然后呢?没有然后了。没有任何地方会把它删掉。
意味着每一个访问过你服务的独立 IP,都会在内存里永久占着一个 rate.Limiter。这个结构本身不大,但架不住 IP 数量是无上限的:
- 公网服务,正常用户就来自成千上万个 IP
- 移动网络 IP 频繁变动,同一个用户一天可能贡献几十个 IP
- 爬虫和扫描器用代理池轮换,一次扫描能给你灌进来几万个
跑上几个月,这个 map 可以轻松到百万级。更糟的是它还在 sync.Mutex 的保护下——map 越大,哈希冲突和内存分配越频繁,而所有请求都在抢同一把锁。
所以这不只是内存泄漏,到后期还会变成吞吐瓶颈。
顺带说第二个问题:全局一把锁
GetLimiter 每次调用都要 i.mu.Lock(),而它在每个请求的中间件里被调用。
也就是说,不管你的服务有多少个 CPU 核心、多少个并发请求,走到这一步都得排队过这把锁。QPS 上去之后这里就是热点。
这个问题在 map 小的时候不明显,锁持有时间极短。但和上面的泄漏叠加起来就会恶化:map 越大 → 单次操作越慢 → 锁持有越久 → 排队越长。
怎么改
第一步,加过期清理。 给每个 limiter 记一个最后访问时间,后台定时扫描,把长时间没用过的删掉:
type entry struct {
limiter *rate.Limiter
lastSeen time.Time
}
func (i *IPRateLimiter) cleanup(ttl time.Duration) {
ticker := time.NewTicker(ttl)
for range ticker.C {
i.mu.Lock()
now := time.Now()
for ip, e := range i.ips {
if now.Sub(e.lastSeen) > ttl {
delete(i.ips, ip)
}
}
i.mu.Unlock()
}
}
删掉一个空闲 IP 的 limiter 是安全的——下次它再来,重建一个满桶的 limiter,效果等同于"它这段时间没发请求所以令牌攒满了",语义是对的。
注意清理的遍历也持锁,map 很大时这一下会卡住所有请求。所以要么分批删,要么换成分片 map。
第二步,用分片降低锁竞争。 按 IP 哈希分成 N 个桶,每个桶一把锁:
type shardedLimiter struct {
shards [256]struct {
mu sync.Mutex
ips map[string]*entry
}
}
func (s *shardedLimiter) shardFor(ip string) *shard {
h := fnv.New32a()
h.Write([]byte(ip))
return &s.shards[h.Sum32()%256]
}
锁竞争直接降到 1/256,清理也可以一个分片一个分片来,不会一次卡住全部。
第三步,考虑直接别自己存。 如果你本来就有 Redis,用 Redis 做限流计数更省心:天然带 TTL(不用自己清理)、天然多实例共享(本地 map 方案下每个 Pod 各限各的,实际限流阈值是 N 倍)。代价是每个请求多一次网络往返。
那我们改了吗
还没有。
说实话,现在这套跑在一个流量不大的服务上,重启也比较频繁(每次发版都会清零),所以泄漏还没到能感知的程度。
但这恰恰是这类问题最阴险的地方:它靠"重启得够勤"掩盖着。哪天服务稳定了、连续跑几个月不重启,它就会以"内存莫名其妙涨"的形式冒出来,而那时候你八成想不到是限流中间件干的。
写这篇一方面是记下来提醒自己,另一方面是——如果你从哪篇教程里抄了这段代码(大概率长得一模一样),建议现在就去看一眼有没有清理逻辑。
一点题外话
c.ClientIP() 本身也值得留意。它会读 X-Forwarded-For / X-Real-IP,而这两个头是客户端可以伪造的,除非你在 Gin 里正确配置了 SetTrustedProxies。
配置不当的话,攻击者每个请求换一个假 IP,就能完全绕过限流——顺便还帮你把那个 map 撑爆。两个问题在这里合流了。