上个月的一个周一早上九点整,监控群里突然炸了:下游的支付网关返回大量 429 Too Many Requests。奇怪的是,我们明明在调用方加了限流,配置是 每秒 100 次,而对方的配额是每秒 150 次。
排查之后发现,问题出在我们用的是最朴素的固定窗口计数器。这篇文章记录了我是如何把它换成令牌桶的,以及实现过程中几个容易被忽视的细节。
固定窗口的"边界突刺"
固定窗口的逻辑很简单:每秒一个桶,计数到 100 就拒绝,下一秒清零。但它有一个致命的问题——两个窗口交界处的瞬时流量可以达到限额的两倍。
周一九点,大量定时任务同时触发,恰好踩在了这个边界上。
令牌桶:把"速率"和"突发"分开
令牌桶的模型可以用一句话描述:桶里最多放 burst 个令牌,以 rate 的速度持续补充,每个请求消耗一个令牌。
它的好处是两个参数各管一件事:rate 控制长期平均速率,burst 控制允许的瞬时突发。固定窗口把这两件事混在了一起。
限流的本质不是"数数",而是对时间的建模。
惰性填充
很多教程会起一个后台 goroutine 用 time.Ticker 定时往桶里加令牌。这既浪费资源,精度也受 ticker 影响。更好的做法是在每次请求到来时,根据距上次请求经过的时间,一次性算出应补充多少令牌。
type Limiter struct {
mu sync.Mutex
rate float64 // 每秒补充的令牌数
burst float64 // 桶容量
tokens float64
last time.Time
}
func (l *Limiter) Allow() bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
elapsed := now.Sub(l.last).Seconds()
l.last = now
// 惰性填充:一次性补齐这段时间内应得的令牌
l.tokens = min(l.burst, l.tokens+elapsed*l.rate)
if l.tokens < 1 {
return false
}
l.tokens -= 1
return true
}
time.Now() 自带单调时钟读数,Sub 会优先使用它,所以 NTP 回拨不会让 elapsed 变成负数。但如果你把时间序列化成 Unix 时间戳再比较,这个保护就没了。等待而不是拒绝
调用第三方 API 时,我们往往更希望排队等待而不是直接失败。可以加一个 Wait(ctx) 方法:先"预支"令牌,算出需要等待的时间,再在锁外睡眠。
func (l *Limiter) Wait(ctx context.Context) error {
l.mu.Lock()
now := time.Now()
l.tokens = min(l.burst, l.tokens+now.Sub(l.last).Seconds()*l.rate)
l.last = now
l.tokens -= 1 // 允许为负:表示"欠账"
delay := time.Duration(-l.tokens / l.rate * float64(time.Second))
l.mu.Unlock()
if delay <= 0 {
return nil
}
t := time.NewTimer(delay)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
// TODO: 归还预支的令牌
return ctx.Err()
}
}
"允许令牌为负"是这里的关键技巧:它天然形成了一个公平的 FIFO 队列,后来的请求会等待更久,而不需要显式维护等待队列。
ctx 取消时要还账
如果请求在等待中被取消,它预支的令牌应该归还,否则后面的请求会被无谓地延迟。完整实现里我用了一个 reservation 结构来记录预支时间,取消时按比例回补。
结果与反思
上线后,周一九点的突刺完全消失了,下游的 429 归零。回头看,这次事故教会我的有三件事:
- 限流算法的选择要看下游怎么计数,而不只是看自己的需求;
- 能用纯计算解决的事,不要引入后台协程和定时器;
- 生产环境优先用 golang.org/x/time/rate,自己实现是为了理解它。
哈哈哈,读完啦!感谢阅读 · 有问题欢迎发邮件到 lotusecho0803@gmail.com