go标准库rate.limiter实为漏桶变体,并非rfc 2697令牌桶;它不维护令牌计数器,而是基于时间戳推算最早放行时间,burst参数表示最大容忍延迟而非桶容量。

Go 标准库没有真正的令牌桶,golang.org/x/time/rate 是漏桶变体;真要按 RFC 2697 实现带容量上限、可透支、跨 goroutine 观察水位的令牌桶,得手写或引入第三方库(如 juju/ratelimit)。
rate.Limiter 不是令牌桶,是滑动窗口式漏桶
调用 rate.NewLimiter(10, 5) 并不表示“每秒 10 个令牌、桶容量 5”,它底层不维护令牌计数器,而是靠时间戳推算“最早能放行的时间”。行为等价于:允许最多 5 次瞬时突发,之后强制压到平均 10 QPS。
常见误判现象:
- 以为
burst=5是桶里最多存 5 个 token,实际它是“最大允许延迟 × rate”,即最多容忍 500ms 的累积延迟 - 传入截断到秒的
time.Now().Unix()会导致窗口错位,放行过多 - 用
Allow()后立刻查剩余令牌?不行——它不暴露tokens字段,也没法同步水位
要用真令牌桶,别碰 rate.Limiter
当需要以下任一能力时,rate.Limiter 就不够用了:
- 动态调速(比如根据负载实时调整 fill rate)
- 跨 goroutine 查询当前剩余令牌数(比如在 Prometheus 指标中暴露
tokens_remaining) - 与 Redis 联动做分布式水位同步(比如把
tokens和lastRefill存进 Redis Hash) - 支持透支(borrow)并记录负值,后续补足即可恢复
推荐方案:
- 直接用
juju/ratelimit:它提供Bucket类型,有Take(1)、Available()、Capacity()等方法,语义清晰 - 手写需满足三点:
atomic.Int64存令牌数、用runtime.nanotime()做单调时钟、每次Take()时按纳秒差重算填充量 - 避免浮点累加——
float64累积几小时后误差可达几十 token
HTTP 中间件里限流,别在 WriteHeader 之后判断
限流逻辑必须在读取请求体之前、写响应头之前完成。否则容易触发 http: multiple response.WriteHeader calls 错误。
典型错误写法:
if !limiter.Allow() {
w.WriteHeader(http.StatusTooManyRequests)
return // ❌ 此时 header 已发,但下游 handler 可能再写一次
}
next.ServeHTTP(w, r)
正确姿势:
- 用
limiter.Wait(r.Context())阻塞等待,配合ctx.WithTimeout防卡死 - 失败立即
http.Error(w, "...", http.StatusTooManyRequests)并return - 路径匹配别用
r.RequestURI(含 query,易绕过),改用r.URL.Path做前缀判断
测试限流逻辑必须 mock time.Now
单元测试里直接跑 rate.Limiter 行为不可控:一秒内发 20 次请求,结果有时放过 1 次,有时放过 5 次——因为依赖真实时钟漂移和调度延迟。
解决办法:
- 用
github.com/benbjohnson/clock或自己封装func() time.Time注入到 Limiter 构造函数中 - 对
juju/ratelimit.Bucket,可传入自定义Clock接口实现 - 别测“它是不是每秒放 10 个”,而测“连续 11 次调用
Take(),第 11 次是否返回 false”
真正难的是并发下状态一致性——比如两个 goroutine 同时 Take(),一个刚算完填充量、还没更新 lastRefill,另一个就进来了。手写必须用 atomic.CompareAndSwapInt64 + 单次 CAS 更新整个状态,不能分开更新时间戳和令牌数。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











