我们那个 IP 限流中间件,跑久了会把内存吃光

22 阅读4分钟

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 撑爆。两个问题在这里合流了。