← 全部文章
后端Go并发

用 120 行 Go 写一个可靠的令牌桶限流器

从一个线上事故讲起:为什么朴素的计数器限流会在整点打穿服务,以及令牌桶如何用"惰性填充"做到 O(1) 时间、零后台协程。

上个月的一个周一早上九点整,监控群里突然炸了:下游的支付网关返回大量 429 Too Many Requests。奇怪的是,我们明明在调用方加了限流,配置是 每秒 100 次,而对方的配额是每秒 150 次。

排查之后发现,问题出在我们用的是最朴素的固定窗口计数器。这篇文章记录了我是如何把它换成令牌桶的,以及实现过程中几个容易被忽视的细节。

固定窗口的"边界突刺"

固定窗口的逻辑很简单:每秒一个桶,计数到 100 就拒绝,下一秒清零。但它有一个致命的问题——两个窗口交界处的瞬时流量可以达到限额的两倍。

09:00:0009:00:0109:00:02 ← 1 秒内 200 次请求
图 1 — 第一个窗口末尾的 100 次 + 第二个窗口开头的 100 次

周一九点,大量定时任务同时触发,恰好踩在了这个边界上。

令牌桶:把"速率"和"突发"分开

令牌桶的模型可以用一句话描述:桶里最多放 burst 个令牌,以 rate 的速度持续补充,每个请求消耗一个令牌。

它的好处是两个参数各管一件事:rate 控制长期平均速率,burst 控制允许的瞬时突发。固定窗口把这两件事混在了一起。

限流的本质不是"数数",而是对时间的建模。

惰性填充

很多教程会起一个后台 goroutine 用 time.Ticker 定时往桶里加令牌。这既浪费资源,精度也受 ticker 影响。更好的做法是在每次请求到来时,根据距上次请求经过的时间,一次性算出应补充多少令牌。

limiter.go
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
}
!
注意单调时钟。Go 的 time.Now() 自带单调时钟读数,Sub 会优先使用它,所以 NTP 回拨不会让 elapsed 变成负数。但如果你把时间序列化成 Unix 时间戳再比较,这个保护就没了。

等待而不是拒绝

调用第三方 API 时,我们往往更希望排队等待而不是直接失败。可以加一个 Wait(ctx) 方法:先"预支"令牌,算出需要等待的时间,再在锁外睡眠。

wait.go
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