登录
首页 >  Golang >  Go教程

Go rate.Limiter 实战:别让限流器写成摆设

来源:Go 官方扩展包文档

时间:2026-06-05 10:34:43 441浏览 收藏

凌晨被告警叫醒的时候,我最怕看到一种曲线:入口 QPS 没翻几倍,下游依赖的错误率却像被人一脚踩上去。很多 Go 服务不是没有限流,而是限流器写得像摆设:全局一个开关、所有用户共用一个桶、超时了还在 Wait、返回 429 也没有指标,最后业务方只看到“偶发抖动”。

这篇写 golang.org/x/time/rate,但我不想按 API 说明书讲一遍。我们按一次接口保护的实战来拆:什么时候用 Allow,什么时候用 Wait(ctx)Burst 该怎么定,为什么不要全局一把锁,以及上线前我会看哪些指标。

Go rate.Limiter 限流实战封面
rate.Limiter 的核心不是“挡请求”,而是把突发流量变成可控的服务节奏。

业务场景:营销入口把下游查券服务打穿了

假设有一个 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 限流治理脑图
先定限流维度,再选 Allow、Wait 或 Reserve;不要一上来就写全局桶。

rate.Limiter 的几个事实点

x/time/rate 里的 Limiter 使用 token bucket。可以把它理解成一个会按固定速度补充令牌的桶:请求来时拿到令牌就放行,拿不到就拒绝、等待,或者做预约。官方文档里也明确了三个主入口:AllowReserveWait,并且 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 控制。请求已经超时了,限流器还在傻等令牌,等到令牌再访问下游,那就是制造尾延迟。

Go 限流落地流程
落地时把“定义维度、创建 Limiter、拒绝/等待、指标记录”拆清楚,代码就不会乱。

不要忽略指标:没有指标的限流就是黑盒

限流上线后,我至少会看四组指标:

  • allow_total:通过请求数,按接口和限流维度聚合。
  • reject_total:429 数量和比例,确认没有误伤核心客户。
  • wait_duration:使用 Wait(ctx) 的等待耗时分位。
  • limiter_keys:当前桶数量,防止按用户分桶后 map 暴涨。

如果 429 很高但下游仍然慢,说明限流点太靠后,或者 Burst 太大。如果等待时间 P95 接近请求超时,说明 Wait 策略不适合入口接口,应该改成快速拒绝。

上线前我会做的检查

  • 限流维度是不是明确,是否会让异常用户影响正常用户?
  • rateburst 是否来自配置,是否能灰度调整?
  • 是否所有 Wait 都传了有超时的 context
  • 429 响应是否有清晰错误码、日志和 Retry-After
  • 限流桶是否有 TTL 清理,key 数量是否可观测?
  • 压测是否覆盖了短突发、持续高压、单用户异常、多用户均匀请求?
Go 限流代码 Review 对比
真正能上线的限流代码,重点不是“有个 limiter”,而是维度、等待、降级和观测都收得住。

我的经验总结

rate.Limiter 很好用,但它解决的是“节奏控制”,不是所有流量治理问题。入口流量要配合超时、熔断、队列长度、下游容量评估一起看;业务层限流也不能替代网关层防护。

如果只记住一件事,我建议记住这句:先按业务维度拆桶,再决定是立即拒绝还是短暂等待。全局 sleep、无超时 Wait、没有指标的 429,都会让限流器变成摆设。把这些细节补齐,Go 服务面对突发流量时才会更像一个有边界的系统。

声明:本文转载于:Go 官方扩展包文档 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>