
Go 限流用 golang.org/x/time/rate 最稳妥
标准库没有限流,但官方维护的 rate 包足够轻量、线程安全,且不依赖外部服务。它基于令牌桶(token bucket)模型,适合大多数 HTTP API 场景。
常见错误是直接在 handler 里 new rate.Limiter 每次请求都新建——这会让限流失效,因为每个 limiter 独立计数。必须复用同一个实例,比如定义为包级变量或注入到 handler 中。
- 初始化时注意
rate.Limit单位是「每秒请求数」,传10表示 10 QPS,不是每分钟 -
burst参数要合理:设太小(如1)会导致突发流量全被拒;设太大(如1000)等于没限流;一般取 QPS 的 2–5 倍较稳妥 - 不要用
Allow()判断后才处理请求——它不阻塞,但可能刚判断完就被其他 goroutine 抢走 token;优先用Wait()或带超时的TryWait()
HTTP 中间件里嵌入限流要小心 context 超时和 panic
限流逻辑写在中间件里,最容易出问题的是没处理好 Wait() 的阻塞行为。如果客户端断连或网关超时,而你的 Wait() 还在等 token,会拖住整个 goroutine。
正确做法是用带上下文的 WaitN(ctx, n),并确保传入的 ctx 来自 http.Request.Context():
立即学习“go语言免费学习笔记(深入)”;
func rateLimitMiddleware(limiter *rate.Limiter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := limiter.WaitN(r.Context(), 1); err != nil {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
- 别用
time.AfterFunc或独立 goroutine 做限流判断——无法感知请求生命周期,容易泄漏 - 如果路由有动态参数(如
/user/:id),想按用户 ID 限流,就得从 URL 或 header 提取 key,单独维护 map[userID]*rate.Limiter,注意加锁或用sync.Map - 某些反向代理(如 Nginx)会复用连接,导致多个请求共享一个
http.Request上下文,此时需确认r.Context()是否每次都是新实例(通常是)
高并发下 rate.Limiter 性能够用,但别滥用全局单例
rate.Limiter 本身无锁设计(用 atomic 操作),压测中单实例扛 5k+ QPS 没压力。但「全局单 limiter」只适合全站统一限流,实际业务中更常见的是分级限流:登录接口宽松,支付接口严格,管理后台接口更低。
- 按路径前缀分组限流?用 map[string]*rate.Limiter + sync.RWMutex,读多写少时性能影响极小
- 想支持配置热更新(比如运营半夜调低阈值)?别 reload 整个 server,改用原子指针替换 limiter 实例:
atomic.StorePointer(&limiterPtr, unsafe.Pointer(newLimiter)) - 注意
rate.Every(d)构造函数隐含精度限制:底层用 int64 纳秒表示间隔,Every(time.Microsecond)会因舍入失效,最小建议用time.Millisecond
调试时怎么看限流是否生效?别只盯日志
线上出问题,光看「Too Many Requests」日志没用。真正要确认的是:token 消耗是否符合预期、burst 是否被正确填充、有没有 goroutine 积压。
- 用
limiter.Limit()和limiter.Burst()定期打点上报,比日志更及时 - 临时加个 debug handler,返回
limiter.ReserveN(time.Now(), 1).Delay()的值——正数说明排队,0 说明立刻通过,-1 说明已拒绝 - 注意:
ReserveN不消耗 token,只是预估;真要用它得手动Cancel()或Confirm(),否则 token 会漏掉
限流不是加个中间件就完事。最常被忽略的是 burst 填充节奏和上下文传播深度——尤其当 handler 里再起 goroutine 调第三方 API 时,那个子 goroutine 的 context 往往没继承原限流上下文,token 就白扣了。










