凌晨被告警叫醒的时候,我最怕看到一种曲线:入口 QPS 没翻几倍,下游依赖的错误率却像被人一脚踩上去。很多 Go 服务不是没有限流,而是限流器写得像摆设:全局一个开关、所有用户共用一个桶、超时了还在 Wait、返回 429 也没有指标,最后业务方只看到“偶发抖动”。
这篇写 golang.org/x/time/rate,但我不想按 API 说明书讲一遍。我们按一次接口保护的实战来拆:什么时候用 Allow,什么时候用 Wait(ctx),Burst 该怎么定,为什么不要全局一把锁,以及上线前我会看哪些指标。
业务场景:营销入口把下游查券服务打穿了
假设有一个 Go HTTP 服务,入口是 /api/coupon/check。平时 QPS 只有几百,活动开始后某些用户脚本式刷新,请求全打到查券服务。最开始的代码很朴素:
func checkCouponHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
coupon, err := couponClient.Check(r.Context(), userID)
if err != nil {
http.Error(w, "coupon unavailable", http.StatusBadGateway)
return
}
writeJSON(w, coupon)
}
这段代码本身没有并发 bug,但它把所有压力无条件转交给下游。下游慢了以后,入口 goroutine 也会堆起来;客户端重试再进来,流量就会被放大。这个时候“加限流”听起来简单,真正容易踩坑的是限流维度和等待策略。
先复现一个错误版本:全局 sleep 不是限流
我见过有人为了快速止血这么写:
var slowDown = time.Second / 20
func checkCouponHandler(w http.ResponseWriter, r *http.Request) {
time.Sleep(slowDown)
// 继续访问下游
}
这不是限流,这是把每个请求都拖慢。它不会减少并发中的等待请求,只是让 goroutine 占用时间更久;如果客户端有超时重试,还会让重试更集中。另一个常见错误是全局一个 Limiter:
var limiter = rate.NewLimiter(rate.Limit(100), 200)
func checkCouponHandler(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
// 继续访问下游
}
全局桶在某些网关层有价值,但放在业务接口里经常会误伤:一个大客户或异常用户把令牌吃光,正常用户也被 429。我们这类“查券”接口更适合按用户、店铺、接口维度拆桶。
rate.Limiter 的几个事实点
x/time/rate 里的 Limiter 使用 token bucket。可以把它理解成一个会按固定速度补充令牌的桶:请求来时拿到令牌就放行,拿不到就拒绝、等待,或者做预约。官方文档里也明确了三个主入口:Allow、Reserve 和 Wait,并且 Limiter 可以被多个 goroutine 同时使用。
我在业务代码里最常用的是两个:
Allow():快速判断,拿不到令牌就立即返回 429,适合入口保护。Wait(ctx):允许排队等待令牌,但必须绑定请求context,适合后台任务、短等待或对突刺较敏感的内部调用。
Burst 不是越大越好。它决定短时间能吃掉多少突发请求。比如 rate.NewLimiter(rate.Limit(20), 40) 的含义是长期速率接近每秒 20 个事件,桶最多攒 40 个令牌。活动入口如果把 Burst 设置得太大,第一波请求仍然能把下游打满;设置得太小,又会把正常短峰值也挡掉。
生产版:按用户分桶,加过期清理
下面是我更愿意上线的版本。它按用户维护 limiter,超过一段时间没访问就清理,避免 map 无限增长。代码不追求花哨,先把行为写清楚。
package ratelimit
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
type Store struct {
mu sync.Mutex
visitors map[string]*visitor
r rate.Limit
b int
ttl time.Duration
}
func NewStore(r rate.Limit, burst int, ttl time.Duration) *Store {
s := &Store{
visitors: make(map[string]*visitor),
r: r,
b: burst,
ttl: ttl,
}
go s.cleanupLoop()
return s
}
func (s *Store) Limiter(key string) *rate.Limiter {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.visitors[key]
if !ok {
v = &visitor{
limiter: rate.NewLimiter(s.r, s.b),
lastSeen: now,
}
s.visitors[key] = v
return v.limiter
}
v.lastSeen = now
return v.limiter
}
func (s *Store) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for now := range ticker.C {
s.mu.Lock()
for key, v := range s.visitors {
if now.Sub(v.lastSeen) > s.ttl {
delete(s.visitors, key)
}
}
s.mu.Unlock()
}
}
func Middleware(store *Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-User-ID")
if key == "" {
key = r.RemoteAddr
}
limiter := store.Limiter(key)
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
这里有几个细节值得说:
- 锁只保护
visitors这个 map,不包住业务处理。 - 拿到
*rate.Limiter之后直接调用Allow,因为 limiter 自身支持并发使用。 - 清理逻辑只按最近访问时间删桶,不要在请求路径里做太重的扫描。
- key 的选择要贴业务:用户、租户、IP、接口路径,或者多维组合。
什么时候用 Wait(ctx)
Allow 是“拿不到就拒绝”,Wait(ctx) 是“可以等一会”。我通常只在两类场景使用 Wait:
- 内部调用想削峰,但请求方能接受几十毫秒等待。
- 后台 worker 出站请求被第三方限速,需要稳定节奏而不是大量 429。
func callPartner(ctx context.Context, limiter *rate.Limiter, req Request) error {
waitCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
defer cancel()
if err := limiter.Wait(waitCtx); err != nil {
return ErrRateLimited
}
return partnerClient.Send(ctx, req)
}
这里最重要的是:等待必须受 context 控制。请求已经超时了,限流器还在傻等令牌,等到令牌再访问下游,那就是制造尾延迟。
不要忽略指标:没有指标的限流就是黑盒
限流上线后,我至少会看四组指标:
- allow_total:通过请求数,按接口和限流维度聚合。
- reject_total:429 数量和比例,确认没有误伤核心客户。
- wait_duration:使用
Wait(ctx)的等待耗时分位。 - limiter_keys:当前桶数量,防止按用户分桶后 map 暴涨。
如果 429 很高但下游仍然慢,说明限流点太靠后,或者 Burst 太大。如果等待时间 P95 接近请求超时,说明 Wait 策略不适合入口接口,应该改成快速拒绝。
上线前我会做的检查
- 限流维度是不是明确,是否会让异常用户影响正常用户?
rate和burst是否来自配置,是否能灰度调整?- 是否所有
Wait都传了有超时的context? - 429 响应是否有清晰错误码、日志和
Retry-After? - 限流桶是否有 TTL 清理,key 数量是否可观测?
- 压测是否覆盖了短突发、持续高压、单用户异常、多用户均匀请求?
我的经验总结
rate.Limiter 很好用,但它解决的是“节奏控制”,不是所有流量治理问题。入口流量要配合超时、熔断、队列长度、下游容量评估一起看;业务层限流也不能替代网关层防护。
如果只记住一件事,我建议记住这句:先按业务维度拆桶,再决定是立即拒绝还是短暂等待。全局 sleep、无超时 Wait、没有指标的 429,都会让限流器变成摆设。把这些细节补齐,Go 服务面对突发流量时才会更像一个有边界的系统。