用 Go 标准库写一个限速器

举报
yd_271078175 发表于 2026/11/06 08:22:00 2026/11/06
【摘要】 调用外部接口时被打回 429,第一反应是加重试,第二反应才是限速。其实顺序应该反过来:先控制发出去的速率,重试只处理偶发失败。这篇说说用标准库怎么把速率控制住。

调用外部接口时被打回 429,第一反应是加重试,第二反应才是限速。其实顺序应该反过来:先控制发出去的速率,重试只处理偶发失败。这篇说说用标准库怎么把速率控制住。

一、先分清限的是速率还是并发

这两个词经常混着用,实际管的是不同的东西。

  • 并发:同时有多少个请求在路上。用一个带缓冲的 channel 当信号量就能限制。
  • 速率:每秒发出多少个请求。跟并发无关,一个请求挂 10 秒也能只发 1 个/秒。

对端说「每秒最多 5 次」,要限的是速率。做并发限制只能保证同时在飞的请求数,不能保证一秒内发了多少。

二、最简的令牌桶:ticker 加 channel

令牌桶的思路是:桶里按固定速率补充令牌,请求来的时候取一个,取不到就等。

标准库里 time.Ticker 正好是「按固定间隔做一件事」,配一个带缓冲的 channel 就是现成的令牌桶:

type Limiter struct {
    tokens chan struct{}
    stop   chan struct{}
}

func NewLimiter(perSecond int) *Limiter {
    l := &Limiter{
        tokens: make(chan struct{}, perSecond),
        stop:   make(chan struct{}),
    }
    go func() {
        t := time.NewTicker(time.Second / time.Duration(perSecond))
        defer t.Stop()
        for {
            select {
            case <-t.C:
                select {
                case l.tokens <- struct{}{}:
                default: // 桶满了就丢掉,最多攒一秒的量
                }
            case <-l.stop:
                return
            }
        }
    }()
    return l
}

func (l *Limiter) Wait(ctx context.Context) error {
    select {
    case <-l.tokens:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

用的时候每次请求前 Wait 一下,退出时 close(l.stop)。

桶容量设成每秒的速率,意味着最多能攒一秒的令牌。这个设计带来一个副作用:空闲一会再突然来一批请求,第一批会瞬间发出去。这就是「允许突发」,多数场景正是想要的。

三、不需要突发就设容量为 1

如果不希望一瞬间打出去一串,把 make(chan struct{}, perSecond) 换成容量 1:

tokens: make(chan struct{}, 1),

这时候令牌攒不住,请求必须严格按间隔排队,速率非常平滑。代价是每次都要等到一个完整的间隔,整体比允许突发的方案慢一点。

判断标准很简单:对端说的是「每秒 N 次」,容量给 N;说的是「两次之间至少间隔 X 毫秒」,容量给 1。

四、取不到令牌时,等还是直接返回

两个选择对应两种产品行为:

  • 等:适合批处理。任务反正要跑完,慢一点没关系,代码里就是一个 Wait。
  • 直接拒绝:适合对外提供服务的场景。排队会让延迟无上限地涨,不如立刻返回「忙」。

第二种只需要把取令牌的那段改成非阻塞:

select {
case <-l.tokens:
    return nil
default:
    return ErrRateLimited
}

混合模式也可以做:先等最多 200 毫秒,等不到再拒绝。相当于给调用方一个「短时间排队」的机会,同时不把延迟拖到不可控。

五、多个 goroutine 共用一个限速器

上面的实现天然是并发安全的,channel 本身就是同步原语,几十个 goroutine 同时 Wait 也不会出错。

要注意的是谁负责关闭。close(l.stop) 只能调用一次,重复调用会 panic。通常交给创建它的函数,用 defer 关掉,而不是每个 worker 各关一次。

还有一个容易忽略的点:如果限速器是按批创建的,比如每次调用都 NewLimiter,那限速就失效了——每个批次的桶是独立的,总量还是超。限速器要在整个进程生命周期内共享一个。

六、小结

三句话:

1. 先分清要限的是每秒次数还是同时数量; 2. Ticker 加带缓冲 channel 就是一个够用的令牌桶; 3. 桶容量决定能不能突发,取不到令牌时的行为决定延迟上限。

标准库没有现成的限速器,但需要的原语都有。三十行代码,不引依赖,出了问题也看得懂。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。