背景

服务刚上线时,大家最担心的是没流量。等真有流量了,新的问题马上出现:

  • 某个接口被脚本打爆
  • 下游数据库扛不住瞬时峰值
  • 重试风暴把正常请求一起拖死

这时候如果没有限流,服务再快也会被拖垮。

Go 很适合写高并发服务,限流逻辑做成中间件也很自然。今天聊一个最常见、也最实用的方案:令牌桶

为什么是令牌桶

令牌桶的规则很简单:

  1. 系统按固定速率往桶里放令牌
  2. 请求到来时先拿一个令牌
  3. 拿到就放行,拿不到就拒绝或排队

它的好处是同时兼顾两件事:

  • 控制平均速率
  • 允许短时间突发

比如接口平时稳定在每秒 100 个请求,但偶尔瞬间冲到 150,只要桶里有积累的令牌,就不一定要马上拒绝。

单机场景:HTTP 中间件

Go 官方扩展库里已经有现成实现:golang.org/x/time/rate

package main

import (
    "net/http"
    "time"

    "golang.org/x/time/rate"
)

func RateLimitMiddleware(limiter *rate.Limiter, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            w.WriteHeader(http.StatusTooManyRequests)
            _, _ = w.Write([]byte("rate limit exceeded"))
            return
        }

        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte("ok"))
    })

    limiter := rate.NewLimiter(rate.Every(10*time.Millisecond), 20)

    server := &http.Server{
        Addr:    ":8080",
        Handler: RateLimitMiddleware(limiter, mux),
    }

    _ = server.ListenAndServe()
}

这段配置的含义:

  • 平均每 10ms 生成一个令牌,也就是 100 QPS
  • 桶容量 20,允许最多 20 个突发请求

如果你的服务接口很轻,但下游 MySQL 或 Redis 比较脆,这个做法能立刻挡掉一批危险流量。

按用户维度限流

全局限流通常不够。真实业务里,更常见的是:每个用户、每个 IP、每个租户分别限流

type ClientLimiter struct {
    visitors map[string]*rate.Limiter
    mu       sync.Mutex
    rate     rate.Limit
    burst    int
}

func NewClientLimiter(r rate.Limit, burst int) *ClientLimiter {
    return &ClientLimiter{
        visitors: make(map[string]*rate.Limiter),
        rate:     r,
        burst:    burst,
    }
}

func (c *ClientLimiter) Get(key string) *rate.Limiter {
    c.mu.Lock()
    defer c.mu.Unlock()

    limiter, ok := c.visitors[key]
    if !ok {
        limiter = rate.NewLimiter(c.rate, c.burst)
        c.visitors[key] = limiter
    }

    return limiter
}

在中间件里可以按 API Key、用户 ID 或客户端 IP 获取对应的 limiter。

func PerUserRateLimit(cl *ClientLimiter, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := r.Header.Get("X-User-ID")
        if userID == "" {
            userID = r.RemoteAddr
        }

        if !cl.Get(userID).Allow() {
            w.WriteHeader(http.StatusTooManyRequests)
            _, _ = w.Write([]byte("too many requests"))
            return
        }

        next.ServeHTTP(w, r)
    })
}

一个容易忽略的问题:内存增长

上面这个实现能跑,但有个明显问题:visitors 会一直增长。

如果每天都有新 IP 打进来,map 会越来越大,最后限流中间件本身变成内存泄漏点。

通常要加一个清理协程,把长时间没访问的 key 删除掉。

type visitor struct {
    limiter  *rate.Limiter
    lastSeen time.Time
}

type VisitorStore struct {
    visitors map[string]*visitor
    mu       sync.Mutex
}

func (s *VisitorStore) Cleanup(maxIdle time.Duration) {
    ticker := time.NewTicker(time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        cutoff := time.Now().Add(-maxIdle)

        s.mu.Lock()
        for key, item := range s.visitors {
            if item.lastSeen.Before(cutoff) {
                delete(s.visitors, key)
            }
        }
        s.mu.Unlock()
    }
}

分布式服务怎么办

如果服务只部署一个实例,进程内限流就够了。

但线上往往是多副本:

  • 3 个 Pod
  • 1 个 Nginx 或 Ingress
  • 请求随机落到不同实例

这时单机 limiter 只能限制“单个实例”的流量,无法限制整个集群的总量。

常见做法有三种:

  1. 入口层限流 在 Nginx、Envoy、Ingress 层直接做,适合统一入口控制。
  2. Redis 分布式限流 用 Lua 保证原子性,适合业务维度限流。
  3. 服务本地限流 + 网关兜底 本地保护实例,网关控制整体流量,通常更稳。

我更倾向第三种。原因很现实:所有限流都压到 Redis 或网关上,会让入口层变成新的瓶颈;完全不做本地保护,实例又很容易被局部流量打穿。

限流不是越严越好

限流参数配错,比不配还麻烦。

几个经验值:

  1. QPS 不要直接照搬压测峰值 线上有抖动、有 GC、有慢查询,给自己留余量。
  2. 区分读接口和写接口 写操作更贵,限额应更严格。
  3. 429 要带清晰语义 最好返回统一错误码,让客户端知道该退避重试。
  4. 限流一定要打监控 否则你只知道用户报错,不知道是被限流了。

总结

令牌桶不是新东西,但它在工程里一直非常好用:实现简单、效果直接、可预测性强。

如果你的 Go 服务已经对数据库连接池、缓存命中率、goroutine 数量做了很多优化,那限流往往就是下一步必须补上的保护层。


系统设计里,稳定性从来不是靠某一个“大招”解决的,往往是这些朴素的保护措施一层层堆出来的。