用 Go 标准库写一个限速器
调用外部接口时被打回 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. 桶容量决定能不能突发,取不到令牌时的行为决定延迟上限。
标准库没有现成的限速器,但需要的原语都有。三十行代码,不引依赖,出了问题也看得懂。
- 点赞
- 收藏
- 关注作者
评论(0)