背景
服务刚上线时,大家最担心的是没流量。等真有流量了,新的问题马上出现:
- 某个接口被脚本打爆
- 下游数据库扛不住瞬时峰值
- 重试风暴把正常请求一起拖死
这时候如果没有限流,服务再快也会被拖垮。
Go 很适合写高并发服务,限流逻辑做成中间件也很自然。今天聊一个最常见、也最实用的方案:令牌桶。
为什么是令牌桶
令牌桶的规则很简单:
- 系统按固定速率往桶里放令牌
- 请求到来时先拿一个令牌
- 拿到就放行,拿不到就拒绝或排队
它的好处是同时兼顾两件事:
- 控制平均速率
- 允许短时间突发
比如接口平时稳定在每秒 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 只能限制“单个实例”的流量,无法限制整个集群的总量。
常见做法有三种:
- 入口层限流 在 Nginx、Envoy、Ingress 层直接做,适合统一入口控制。
- Redis 分布式限流 用 Lua 保证原子性,适合业务维度限流。
- 服务本地限流 + 网关兜底 本地保护实例,网关控制整体流量,通常更稳。
我更倾向第三种。原因很现实:所有限流都压到 Redis 或网关上,会让入口层变成新的瓶颈;完全不做本地保护,实例又很容易被局部流量打穿。
限流不是越严越好
限流参数配错,比不配还麻烦。
几个经验值:
- QPS 不要直接照搬压测峰值 线上有抖动、有 GC、有慢查询,给自己留余量。
- 区分读接口和写接口 写操作更贵,限额应更严格。
- 429 要带清晰语义 最好返回统一错误码,让客户端知道该退避重试。
- 限流一定要打监控 否则你只知道用户报错,不知道是被限流了。
总结
令牌桶不是新东西,但它在工程里一直非常好用:实现简单、效果直接、可预测性强。
如果你的 Go 服务已经对数据库连接池、缓存命中率、goroutine 数量做了很多优化,那限流往往就是下一步必须补上的保护层。
系统设计里,稳定性从来不是靠某一个“大招”解决的,往往是这些朴素的保护措施一层层堆出来的。