令牌桶和漏桶怎么选:从一次 429 风暴到可运行实现

格 格达软件 2026-10-15 2 次阅读 AI 生成

先复盘:429 是怎么被打出来的

订单导出服务要给第三方物流接口逐个下单,对方文档写着 100 QPS。我们的代码长这样:

// 反例:5 个 worker 把 5000 个请求推出去,没有任何限流
var wg sync.WaitGroup
sem := make(chan struct{}, 5)
for _, o := range orders {
    wg.Add(1)
    go func(o Order) {
        defer wg.Done()
        sem <- struct{}{}
        defer func() { <-sem }()
        callVendorAPI(o) // 单次耗时约 20ms
    }(o)
}
wg.Wait()

算一下:5 个 worker、单次 20ms,稳态就是 250 QPS,超了对方限额的 2.5 倍。这还没完——收到 429 之后我们的重试逻辑 for i := 0; i < 3; i++(间隔 100ms)把实际发出量抬到 750 QPS,对方直接把我们丢进黑名单,整个导出任务全线卡死。

所以限流要解决的问题不是「保护自己」,而是别让自己的重试放大器把下游打穿。下面从实现往回讲。

令牌桶:按时间差补令牌,别起定时器

package ratelimit

import (
	"sync"
	"time"
)

// TokenBucket 令牌桶。
//   rate  —— 每秒补充的令牌数,可以是小数(0.5 表示每 2 秒一个)
//   burst —— 桶容量,同时也是允许的最大瞬时突发量
type TokenBucket struct {
	mu       sync.Mutex
	rate     float64
	burst    float64
	tokens   float64
	lastTime time.Time
}

func NewTokenBucket(rate float64, burst int) *TokenBucket {
	if rate <= 0 || burst <= 0 {
		panic("ratelimit: rate and burst must be > 0")
	}
	return &TokenBucket{
		rate:     rate,
		burst:    float64(burst),
		tokens:   float64(burst), // 冷启动装满,允许一次突发
		lastTime: time.Now(),
	}
}

// AllowN 尝试取走 n 个令牌,不阻塞。
func (b *TokenBucket) AllowN(n int) bool {
	if n <= 0 {
		return true
	}
	if float64(n) > b.burst {
		// 单次要的比桶还多,永远不可能成功。直接拒,别让调用方空转重试。
		return false
	}

	b.mu.Lock()
	defer b.mu.Unlock()

	now := time.Now() // 必须在锁内取,原因见下面「坑三」
	elapsed := now.Sub(b.lastTime).Seconds()
	if elapsed < 0 {
		elapsed = 0
	}
	b.lastTime = now

	// 核心:一次性把这段时间该补的令牌补上,不需要任何后台 goroutine。
	// time.Now() 带单调时钟,Sub 的结果不受 NTP 校时影响。
	b.tokens += elapsed * b.rate
	if b.tokens > b.burst {
		b.tokens = b.burst // 超出容量的部分直接丢弃,不能攒着
	}

	if b.tokens < float64(n) {
		return false
	}
	b.tokens -= float64(n)
	return true
}

func (b *TokenBucket) Allow() bool { return b.AllowN(1) }

这段代码的关键只有一句话:令牌不是被「推」进来的,是被「算」出来的。每次调用时用 now - lastTime 算出这段空窗期该补多少令牌,一次补齐。没有 goroutine,没有定时器,空闲时不消耗任何资源。

坑一:用后台 ticker 补令牌

// 反例:每个限流器配一个 1ms 的定时器补令牌
func NewBadBucket(rate, burst int) *BadBucket {
	b := &BadBucket{burst: burst}
	go func() {
		t := time.NewTicker(time.Millisecond)
		defer t.Stop()
		for range t.C {
			b.mu.Lock()
			if b.tokens < b.burst {
				b.tokens++
			}
			b.mu.Unlock()
		}
	}()
	return b
}

单看一个限流器好像没什么。但如果按商户维度限流,服务里有 500 个商户就是 500 个 goroutine、每秒 50 万次定时器唤醒加锁——而你真正要限的请求可能每秒只有几千个。而且 1ms 的粒度意味着限流精度被锁死在 1000 QPS 以下,想限 5000 QPS 根本做不到。惰性计算版本是零后台开销,且精度只受 float64 限制。

坑二:用 int64 存令牌数

// 反例
b.tokens += int64(elapsed * b.rate)

rate = 0.5(每 2 秒一个令牌)、调用间隔 1.5 秒时,elapsed * rate = 0.75,转 int64 被截断成 0,令牌永远补不上,这个限流器会一直拒绝。低速限流在「某个接口每天只能调 100 次」这类场景很常见,必须用 float64 存令牌。

坑三:now 在锁外取

// 反例
now := time.Now()
b.mu.Lock()
defer b.mu.Unlock()
b.tokens += now.Sub(b.lastTime).Seconds() * b.rate
b.lastTime = now

高并发下几十个 goroutine 堵在锁上,每个人手里的 now 都比它真正拿到锁的时刻早。lastTime 被反复改成一个偏早的值,累积下来实际放行速率会明显低于设定值——你设了 100 QPS,跑出来可能只有 60。先把锁拿到手再取 now,误差就只有锁内那几十纳秒。

漏桶有两种,混着讲会出事

大部分「令牌桶 vs 漏桶」的对比文章其实在拿两种不同的东西比。漏桶至少有下面两个实现,行为差得很远。

实现 A:准入式漏桶

// LeakyBucket 请求能否进来只看水位,进来的请求立即放行。
type LeakyBucket struct {
	mu       sync.Mutex
	rate     float64
	capacity float64
	water    float64
	lastTime time.Time
}

func NewLeakyBucket(rate float64, capacity int) *LeakyBucket {
	return &LeakyBucket{
		rate:     rate,
		capacity: float64(capacity),
		lastTime: time.Now(),
	}
}

func (b *LeakyBucket) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()

	now := time.Now()
	elapsed := now.Sub(b.lastTime).Seconds()
	if elapsed < 0 {
		elapsed = 0
	}
	b.lastTime = now

	// 这段时间漏掉的水,先从水位里扣掉
	b.water -= elapsed * b.rate
	if b.water < 0 {
		b.water = 0
	}

	if b.water+1 > b.capacity {
		return false
	}
	b.water++
	return true
}

说句得罪人的话:这个实现和令牌桶在数学上几乎等价。令牌桶的数从 burst 往下减、空闲时往上补;漏桶的数从 0 往上加、空闲时往下漏。同一套公式换个方向看而已。空闲 10 秒后,两者都允许 burst(或 capacity)个请求一口气发出。

所以如果你看到某篇文章说「令牌桶允许突发、漏桶不允许」,它八成拿的是下面这个版本在比。

实现 B:整形式漏桶(这才是真正的区别)

var ErrBucketFull = errors.New("leaky bucket is full")

// ShapingLeakyBucket 请求排队,后台以固定间隔依次放行,输出端严格平滑。
type ShapingLeakyBucket struct {
	queue chan chan struct{}
	done  chan struct{}
	once  sync.Once
}

func NewShapingLeakyBucket(ratePerSec float64, queueSize int) *ShapingLeakyBucket {
	b := &ShapingLeakyBucket{
		queue: make(chan chan struct{}, queueSize),
		done:  make(chan struct{}),
	}
	go b.drain(ratePerSec)
	return b
}

// drain 每秒放行 ratePerSec 个请求。
// 注意:定时器间隔低于 1ms 时,调度器唤醒抖动会占很大比例,
// 实际放行速率会明显低于设定值。这个实现适合几百到几千 QPS 的量级。
func (b *ShapingLeakyBucket) drain(ratePerSec float64) {
	interval := time.Duration(float64(time.Second) / ratePerSec)
	if interval < time.Microsecond {
		interval = time.Microsecond
	}
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-b.done:
			return
		case <-ticker.C:
			select {
			case ch := <-b.queue:
				close(ch) // 唤醒排在最前面的那个请求
			default:
				// 队列是空的,这一滴白漏了——漏桶就是这样,没水也照漏
			}
		}
	}
}

// Acquire 排队等待放行。队满立即返回 ErrBucketFull,不做无限等待。
func (b *ShapingLeakyBucket) Acquire(ctx context.Context) error {
	ch := make(chan struct{})
	select {
	case b.queue <- ch:
	default:
		return ErrBucketFull
	}
	select {
	case <-ch:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func (b *ShapingLeakyBucket) Close() {
	b.once.Do(func() { close(b.done) })
}

这个版本里,请求是真的在等。空闲再久,队列也是空的,不会攒出额外容量,来一波请求只能一个一个按 1/rate 的节奏出去。这是它和令牌桶唯一实质性的差别。

代价也很直接:每个被限流的请求都要挂一个 goroutine 等结果,而且定时器精度撑不住太高的 QPS。别指望用它去整形一个 50000 QPS 的入口。

对比表和参数决策

令牌桶 准入式漏桶 整形式漏桶
内部状态 剩余令牌数 当前水位 一个待放行队列
空闲之后 攒满 burst 个令牌 水位归零 队列为空,无积累
能否突发 能,上限 burst 能,上限 capacity 不能,严格按 1/rate 间隔
调用方等待 不等待 不等待 等待
实现复杂度 一把锁 一把锁 后台 goroutine + channel
高 QPS 适应性 好 好 差,受定时器精度限制
典型场景 API 网关、允许瞬时峰值的下游 同左 严格平滑输出的链路

burst 不是越大越好,窗口边界会咬人

回到最开始那个第三方 100 QPS。如果把桶设成 NewTokenBucket(100, 100),冷启动瞬间就能发出 100 个请求。如果对方用的是固定窗口计数(大部分第三方都是),假设它们的窗口从 t=0.5s 开始计时,我们会在 0.5 秒里发出 150 个,照样超。

稳妥的取值是 burst <= rate,甚至 burst = rate / 2。桶容量应该按「下游在任意一个 1 秒窗口内最多能扛住多少」来定,不是按「我们最多能攒多少」来定。

同一件事换个角度:burst 保护的是你的重试逻辑,不是你的正常流量。 正常流量应该被 rate 卡住,burst 只用来吸收调度抖动带来的瞬时堆积。

单机限流在多副本下会失效

假设服务部署了 3 个副本,每个副本各跑一个 NewTokenBucket(100, 100),加起来就是 300 QPS——直接超了对方限额的 3 倍。两条路:

  1. 静态切分:每个副本分 rate/n。简单,但扩缩容那一刻配额是错的,HPA 从 3 个扩到 10 个会有几秒钟的过冲。
  2. 集中式:用 Redis 存桶状态,Lua 脚本保证原子性。但每次请求都要打一次 Redis,多加 0.5~1ms 延迟。

Redis 版本的 Lua(关键是 now 用 Redis 自己的 TIME,别信应用服务器的时钟,NTP 校时那一下会让你多补或少补令牌):

-- KEYS[1]: 桶的 key
-- ARGV[1]: rate  每秒补充令牌数
-- ARGV[2]: burst 桶容量
-- ARGV[3]: 本次要取的令牌数
local rate  = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local want  = tonumber(ARGV[3])

local t   = redis.call('TIME')            -- {秒, 微秒},脚本内取到的是固定值
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local data   = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or burst  -- 首次访问,桶是满的
local ts     = tonumber(data[2]) or now

local elapsed = math.max(0, now - ts) / 1000  -- 时钟回拨时按 0 处理

tokens = math.min(burst, tokens + elapsed * rate)

local allowed = 0
if tokens >= want then
  tokens = tokens - want
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- 桶从空到满需要 burst/rate 秒,超过这个时间 key 就没必要留着了
redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 1000)

return allowed

顺带说一句:Redis 版本的限流精度受 RTT 影响,脚本执行本身是原子的,但从应用发出请求到 Redis 执行完这段时间里,别的副本可能已经抢走了令牌。所以实际放行速率会略低于设定值,这个偏差在 100 QPS 量级大概 1%~3%,高 QPS 下可以忽略。要精确的话只能上分布式协调,不值得。

一定会踩到的边界

golang.org/x/time/rate 的 WaitN 在 n 超过 burst 时直接报错,不是慢等。

lim := rate.NewLimiter(10, 5) // 10 QPS, burst 5
err := lim.WaitN(ctx, 10)
// 立即返回: rate: Wait(n=10) exceeds limiter's burst 5

很多人以为它会排队等到攒够 10 个令牌。不会,token bucket 的 burst 是硬上限,一次要的比桶还大就永远满足不了。我的 AllowN 里也做了同样的处理,直接返回 false 而不是让调用方空转。

burst = 0 会让限流器永远拒绝。 rate.NewLimiter(rate.Limit(100), 0) 之后任何 Allow() 都返回 false。构造时加个断言,别等到线上才发现。

被拒绝之后不要立刻重试。 这是 429 风暴的真正成因。正确做法是返回 429 加 Retry-After 头,客户端按指数退避。如果服务端自己就是客户端,用 rate.Limiter.Wait(ctx) 阻塞等而不是轮询 Allow()——Wait 内部是按需休眠,不是自旋。

动态调速率要小心已经有请求在等。 x/time/rate 的 SetLimit / SetBurst 是线程安全的,但把 burst 调小的瞬间,正在等待的请求可能因为 n 大于新的 burst 而直接失败。控制面下发新配置时最好加个 5 秒的渐变窗口。

单桶的锁在极端场景下会变成瓶颈。 按 key 分片能缓解,但注意分片之后每个桶的 rate 该设多少:如果所有分片共享一份总配额,每个分片只能分到 1/N;如果每个 key 独立限流,分片只是 hash 落点。搞混了会把总速率放大 N 倍。

最后给个判断标准

选之前先回答一个问题:你要的是「把多余的请求拒掉」,还是「让请求平滑地排队」?

拒掉用令牌桶——实现简单、无等待、高 QPS 扛得住,代价是被拒绝的调用方要自己处理 429 和退避。

排队用整形式漏桶——输出端绝对平滑,代价是每个请求都要挂一个等待的 goroutine,而且速率上限被定时器精度卡在几千 QPS。

如果服务是单进程、又没有非要用 Redis 的分布式需求,直接上 golang.org/x/time/rate,别自己写。它的实现比上面这段多处理了「等待队列里的请求按顺序唤醒」这类细节,自己造轮子大概率会在并发压力下翻车。真到了 bucket 上的锁成为瓶颈那天,再考虑按 key 分片或者上本地 + 中心的两级限流。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。