当前位置:首页 > 文章列表 > Golang > Go教程 > Go rate.Limiter 实战:别让限流器写成摆设

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

来源:Go 官方扩展包文档 2026-06-05 10:34:43 0浏览 收藏

凌晨被告警叫醒的时候,我最怕看到一种曲线:入口 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删除
MySQL 8.4 自增主键并发写入实战:AUTO_INCREMENT 锁模式别再凭感觉调MySQL 8.4 自增主键并发写入实战:AUTO_INCREMENT 锁模式别再凭感觉调
上一篇
MySQL 8.4 自增主键并发写入实战:AUTO_INCREMENT 锁模式别再凭感觉调
下一篇
下一篇
暂无
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • ChatExcel酷表:告别Excel难题,北大团队AI助手助您轻松处理数据
    ChatExcel酷表
    ChatExcel酷表是由北京大学团队打造的Excel聊天机器人,用自然语言操控表格,简化数据处理,告别繁琐操作,提升工作效率!适用于学生、上班族及政府人员。
    6159次使用
  • Any绘本:开源免费AI绘本创作工具深度解析
    Any绘本
    探索Any绘本(anypicturebook.com/zh),一款开源免费的AI绘本创作工具,基于Google Gemini与Flux AI模型,让您轻松创作个性化绘本。适用于家庭、教育、创作等多种场景,零门槛,高自由度,技术透明,本地可控。
    6569次使用
  • 可赞AI:AI驱动办公可视化智能工具,一键高效生成文档图表脑图
    可赞AI
    可赞AI,AI驱动的办公可视化智能工具,助您轻松实现文本与可视化元素高效转化。无论是智能文档生成、多格式文本解析,还是一键生成专业图表、脑图、知识卡片,可赞AI都能让信息处理更清晰高效。覆盖数据汇报、会议纪要、内容营销等全场景,大幅提升办公效率,降低专业门槛,是您提升工作效率的得力助手。
    6374次使用
  • 星月写作:AI网文创作神器,助力爆款小说速成
    星月写作
    星月写作是国内首款聚焦中文网络小说创作的AI辅助工具,解决网文作者从构思到变现的全流程痛点。AI扫榜、专属模板、全链路适配,助力新人快速上手,资深作者效率倍增。
    8340次使用
  • MagicLight.ai:叙事驱动AI动画视频创作平台 | 高效生成专业级故事动画
    MagicLight
    MagicLight.ai是全球首款叙事驱动型AI动画视频创作平台,专注于解决从故事想法到完整动画的全流程痛点。它通过自研AI模型,保障角色、风格、场景高度一致性,让零动画经验者也能高效产出专业级叙事内容。广泛适用于独立创作者、动画工作室、教育机构及企业营销,助您轻松实现创意落地与商业化。
    6985次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议隐私政策
返回登录
  • 重置密码