先决定拒绝谁,再决定怎么拒绝
限流的通用动机和常见策略在 限流与熔断 里已经讲过,这里不再重复。本节只回答 arch-lab 的两个具体问题:同一到达序列下,令牌桶和滑动窗口分别会放行多少、拒绝多少;以及三个订单实例共享 150 QPS 配额时,租约、TTL 和时钟漂移会把误差放大到什么程度。
令牌桶:容量买突发,速率买稳定
令牌桶的状态只有两个:当前令牌数和上次补充时间。每次请求先按流逝时间补充 elapsed × rate,上限是容量,再尝试扣一个令牌。容量决定冷启动能扛多大的突发,速率决定长期平均放行量。注入 Clock 的目的不是“测试友好”这么简单:限流器的时间来源必须和业务时钟解耦,否则测试只能靠 sleep,跨时区、闰秒和时钟回拨也无法构造。
Allow 对负的 elapsed 做了保护:时钟回拨时只把锚点移到当前时间,不补令牌。否则一次 NTP 回拨就能凭空造出一整桶额度。
滑动窗口日志:精确,但有边界悬崖
滑动窗口保留每次放行的时间戳,窗口内达到 limit 就拒绝,并返回“最早一条过期还需要多久”作为 RetryAfter。它比令牌桶更贴近“任意 1 秒内不超过 N 次”的合同,代价是内存随放行数增长,所以实现里用 times[:0] 原地过滤,长度天然被 limit 限制。
两种算法的差别不在冷启动突发:容量等于窗口上限时,两者都能一次放行 10 个。差别在突发之后:令牌桶每 100ms 补一个令牌,滑动窗口必须等最早那批请求整体滑出窗口,期间一个都不放。这就是“窗口悬崖”。
同一到达序列的放行对照
到达序列是 t=0 一次性 15 个,之后每 100ms 一个,共 35 个,观察 2 秒。令牌桶容量 10、速率 10/s;滑动窗口上限 10、窗口 1s;另加一个容量 20 的令牌桶看突发容忍。
// file: internal/platform/ratelimit/ratelimit.go
// tier: L1
// verify: go test ./internal/platform/ratelimit/
package ratelimit
import (
"math"
"sync"
"time"
)
// Clock is injected so every limiter can be tested without sleeping.
type Clock interface{ Now() time.Time }
type ManualClock struct{ now time.Time }
func NewManualClock(t time.Time) *ManualClock { return &ManualClock{now: t} }
func (c *ManualClock) Now() time.Time { return c.now }
func (c *ManualClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
type Decision struct {
Allowed bool
RetryAfter time.Duration
}
type Limiter interface{ Allow() Decision }
type TokenBucket struct {
mu sync.Mutex
clock Clock
capacity, rate, tokens float64
last time.Time
}
func NewTokenBucket(clock Clock, capacity int, ratePerSecond float64) *TokenBucket {
return &TokenBucket{clock: clock, capacity: float64(capacity), rate: ratePerSecond,
tokens: float64(capacity), last: clock.Now()}
}
func (b *TokenBucket) Allow() Decision {
b.mu.Lock()
defer b.mu.Unlock()
now := b.clock.Now()
elapsed := now.Sub(b.last).Seconds()
if elapsed < 0 {
b.last = now
elapsed = 0
}
if elapsed > 0 {
b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.rate)
b.last = now
}
if b.tokens >= 1 {
b.tokens--
return Decision{Allowed: true}
}
need := (1 - b.tokens) / b.rate
return Decision{RetryAfter: time.Duration(math.Ceil(need * float64(time.Second)))}
}
// SlidingWindow keeps admission timestamps and rejects while the window is full.
type SlidingWindow struct {
mu sync.Mutex
clock Clock
limit int
window time.Duration
times []time.Time
}
func NewSlidingWindow(clock Clock, limit int, window time.Duration) *SlidingWindow {
return &SlidingWindow{clock: clock, limit: limit, window: window}
}
func (w *SlidingWindow) Allow() Decision {
w.mu.Lock()
defer w.mu.Unlock()
now := w.clock.Now()
kept := w.times[:0]
for _, t := range w.times {
if t.After(now.Add(-w.window)) {
kept = append(kept, t)
}
}
w.times = kept
if len(w.times) < w.limit {
w.times = append(w.times, now)
return Decision{Allowed: true}
}
retry := w.times[0].Add(w.window).Sub(now)
if retry < 0 {
retry = 0
}
return Decision{RetryAfter: retry}
}
本机 Go 1.26.0(darwin/arm64),命令 go test -v ./... -count=1,原始输出在 .local/reports/be-arch/be-arch-perf-06-1-rate-limit-algorithms-distributed-tests.txt。这是确定性模拟,不是线上测量:
bucket allowed=30 denied=5 max_at_once=10
window allowed=21 denied=14 max_at_once=10
bucket20 allowed=35 denied=0 max_at_once=15
理论放行量是 10(初始突发)+ 10/s × 2s = 30,令牌桶正好 30,误差 0;滑动窗口只有 21,比理论少 9,原因是 1 秒内那批突发占满了窗口,后面 900ms 的补流全部被拒。容量 20 的桶放行 35 个,不是因为它更快,而是 2 秒内只来了 35 个,最大同时放行 15;这说明“突发容忍”必须以容量和实际到达密度一起看,单看 QPS 会误判。
分布式租约:配额切成有期限的块
本地桶在三个实例上会变成三倍配额。共享配额有两种做法:每次请求都问中心(延迟高、中心成瓶颈),或者批量租约——实例向协调者申请一块令牌,用完再申请,块带 TTL,过期未用即作废。下面是与 Redis 无关的进程内协调者,只建模算术和两个时钟;真要上 Redis,RequestLease 对应一段 Lua,原子性由 Redis 保证。
// file: internal/platform/ratelimit/lease.go
// tier: L1
// verify: go test ./internal/platform/ratelimit/
package ratelimit
import (
"math"
"sync"
"time"
)
// Coordinator is an in-process stand-in for a shared quota service: it models
// only the arithmetic and the two clocks, not Redis.
type Coordinator struct {
mu sync.Mutex
clock Clock
capacity float64
rate float64
tokens float64
last time.Time
leaseTokens int
ttl time.Duration
granted int
}
func NewCoordinator(clock Clock, rate float64, burst, leaseTokens int, ttl time.Duration) *Coordinator {
return &Coordinator{clock: clock, capacity: float64(burst), rate: rate, tokens: float64(burst),
last: clock.Now(), leaseTokens: leaseTokens, ttl: ttl}
}
func (c *Coordinator) RequestLease() (int, time.Time, bool) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.clock.Now()
if elapsed := now.Sub(c.last).Seconds(); elapsed > 0 {
c.tokens = math.Min(c.capacity, c.tokens+elapsed*c.rate)
c.last = now
}
n := int(math.Floor(c.tokens))
if n > c.leaseTokens {
n = c.leaseTokens
}
if n < 1 {
return 0, time.Time{}, false
}
c.tokens -= float64(n)
c.granted += n
return n, now.Add(c.ttl), true
}
type InstanceLeaseLimiter struct {
mu sync.Mutex
clock Clock
skew time.Duration
coord *Coordinator
tokens float64
expiresAt time.Time
allowed int
denied int
wasted int
}
func NewInstanceLeaseLimiter(clock Clock, skew time.Duration, coord *Coordinator) *InstanceLeaseLimiter {
return &InstanceLeaseLimiter{clock: clock, skew: skew, coord: coord}
}
func (i *InstanceLeaseLimiter) Allow() Decision {
i.mu.Lock()
defer i.mu.Unlock()
now := i.clock.Now().Add(i.skew)
if !i.expiresAt.IsZero() && !now.Before(i.expiresAt) {
// Expired: unused tokens are lost; the coordinator never reissues them.
if i.tokens >= 1 {
i.wasted += int(math.Floor(i.tokens))
}
i.tokens = 0
i.expiresAt = time.Time{}
}
if i.tokens < 1 {
if n, expires, ok := i.coord.RequestLease(); ok {
i.tokens = math.Min(float64(i.coord.leaseTokens), i.tokens+float64(n))
i.expiresAt = expires
}
}
if i.tokens >= 1 {
i.tokens--
i.allowed++
return Decision{Allowed: true}
}
i.denied++
return Decision{}
}
租约的关键不变量是“协调者只发一次”:令牌从共享池扣掉后才交给实例,过期不回收,所以实例无论怎么漂移都不会超过总配额。代价是浪费:实例申请 20 个、TTL 内只用了 12 个,剩下 8 个就蒸发了,而它下一次申请还要和别的实例抢。
测试用同一到达序列重放三种限流器,再用 1ms 步进驱动三个实例和 150 QPS 协调者;所有时间都来自 ManualClock,没有 sleep,同一份代码重复运行结果一致。
// file: internal/platform/ratelimit/ratelimit_test.go
// tier: L1
// verify: go test ./internal/platform/ratelimit/
package ratelimit
import (
"testing"
"time"
)
var base = time.Date(2026, 10, 18, 8, 0, 0, 0, time.UTC)
type arrival struct {
at time.Duration
n int
}
type trace struct {
allowed, denied, burst int
}
func replay(l Limiter, clock *ManualClock, as []arrival) trace {
tr := trace{}
var cursor time.Duration
for _, a := range as {
clock.Advance(a.at - cursor)
cursor = a.at
group := 0
for n := 0; n < a.n; n++ {
if l.Allow().Allowed {
tr.allowed++
group++
} else {
tr.denied++
}
}
if group > tr.burst {
tr.burst = group
}
}
return tr
}
func TestTokenBucket(t *testing.T) {
clock := NewManualClock(base)
b := NewTokenBucket(clock, 10, 10)
for i := 0; i < 10; i++ {
if !b.Allow().Allowed {
t.Fatalf("burst %d rejected", i+1)
}
}
if d := b.Allow(); d.Allowed || d.RetryAfter != 100*time.Millisecond {
t.Fatalf("11th=%+v", d)
}
clock.Advance(100 * time.Millisecond)
if !b.Allow().Allowed {
t.Fatal("100ms must refill one token")
}
clock.Advance(-time.Hour)
if b.Allow().Allowed {
t.Fatal("backwards clock must not mint tokens")
}
}
func TestReplayComparison(t *testing.T) {
as := []arrival{{0, 15}}
for i := 1; i <= 20; i++ {
as = append(as, arrival{time.Duration(i) * 100 * time.Millisecond, 1})
}
clock := NewManualClock(base)
bucket := replay(NewTokenBucket(clock, 10, 10), clock, as)
clock = NewManualClock(base)
window := replay(NewSlidingWindow(clock, 10, time.Second), clock, as)
clock = NewManualClock(base)
big := replay(NewTokenBucket(clock, 20, 10), clock, as)
t.Logf("bucket allowed=%d denied=%d max_at_once=%d", bucket.allowed, bucket.denied, bucket.burst)
t.Logf("window allowed=%d denied=%d max_at_once=%d", window.allowed, window.denied, window.burst)
t.Logf("bucket20 allowed=%d denied=%d max_at_once=%d", big.allowed, big.denied, big.burst)
if bucket.allowed != 30 || window.allowed != 21 || bucket.burst != 10 || window.burst != 10 || big.burst != 15 {
t.Fatalf("comparison mismatch: %+v %+v %+v", bucket, window, big)
}
}
func TestLeaseSkew(t *testing.T) {
every := []time.Duration{10 * time.Millisecond, 33 * time.Millisecond, 50 * time.Millisecond}
for _, skew := range []time.Duration{0, 150 * time.Millisecond, -150 * time.Millisecond} {
clock := NewManualClock(base)
coord := NewCoordinator(clock, 150, 60, 20, 400*time.Millisecond)
insts := make([]*InstanceLeaseLimiter, len(every))
for k := range every {
insts[k] = NewInstanceLeaseLimiter(clock, skew, coord)
}
allowed, denied, wasted := 0, 0, 0
for ms := 0; ms <= 10000; ms++ {
clock.Advance(time.Millisecond)
for k, e := range every {
if ms%int(e/time.Millisecond) == 0 && insts[k].Allow().Allowed {
allowed++
}
}
}
shares := make([]int, len(insts))
for k, inst := range insts {
denied += inst.denied
wasted += inst.wasted
shares[k] = inst.allowed
}
t.Logf("skew=%-6s demand=%d allowed=%d denied=%d wasted=%d granted=%d shares=%v",
skew, allowed+denied, allowed, denied, wasted, coord.granted, shares)
if allowed > coord.granted {
t.Fatalf("skew=%s allowed=%d exceeds granted=%d", skew, allowed, coord.granted)
}
}
}
时钟漂移、过期与跨实例误差
三个实例的到达间隔分别是 10ms、33ms、50ms(约 100、30、20 QPS),共享 150 QPS、初始突发 60、每块 20 个、TTL 400ms,模拟 10 秒。同一到达序列分别注入 0、+150ms、-150ms 的实例时钟偏移:
| 偏移 | 需求 | 放行 | 拒绝 | 浪费令牌 | 协调者已发 | 各实例放行 |
|---|---|---|---|---|---|---|
| 0 | 1506 | 1495 | 11(0.73%) | 52 | 1560 | 1001 / 295 / 199 |
| +150ms | 1506 | 1459 | 47(3.12%) | 92 | 1560 | 994 / 279 / 186 |
| -150ms | 1506 | 1500 | 6(0.40%) | 48 | 1552 | 1000 / 300 / 200 |
三个结果都不是“配额不够”:协调者 10 秒内发出 1552–1560 个,接近理论上限 60 + 150×10 = 1560,拒绝来自租约过期造成的浪费。时钟快 150ms 时,实例提前 150ms 认为租约到期,未用完的令牌被丢弃,浪费从 52 涨到 92,拒绝率从 0.73% 涨到 3.12%。时钟慢 150ms 时浪费最少(48),但实例会在协调者时钟的过期点之后继续消费已扣减的令牌;总量不会超,时间分布却会后移,形成一次小突发。
跨实例误差同样明显:0 偏移下 100 QPS 的实例一个没拒,30 QPS 的拒了 9 个,20 QPS 的拒了 2 个。低速率实例每块 20 个用不完,TTL 一到就浪费,等于替高速率实例买单。租约方案没有公平性,只有总量上限;要公平,必须按实例份额分别发放,而不是抢同一个池。
arch-lab 的限流参数
| 入口 | 限流器 | 速率/上限 | 突发/窗口 | 拒绝码 | 依据 |
|---|---|---|---|---|---|
| 下单 POST /orders | 令牌桶,按 customer_id | 10/s | 20 | RATE_LIMITED | 正常用户 1–2/s,20 容忍连点 |
| 订单查询 GET /orders/{id} | 令牌桶,按 customer_id | 50/s | 100 | RATE_LIMITED | 页面轮询与批量刷新 |
| 支付回调 POST /payments/callback | 滑动窗口,按 provider | 200/s | 1s | RATE_LIMITED | 渠道重推有整批突发 |
| 全局实例配额 | 租约,3 实例 | 150/s | 60 | RATE_LIMITED | 下游库存服务容量 |
租约 TTL 有一条硬约束:TTL > leaseTokens / 单实例速率 + 最大时钟漂移。模拟里 30 QPS 的实例一块 20 个能撑 667ms,TTL 却只有 400ms,必然每块浪费约 8 个;要么把 TTL 提到 800ms,要么把块缩小到 10 个。所有拒绝都带 RetryAfter,客户端按它退避;不带这个字段,限流会把重试风暴原样还给下游。
常见错误与边界
第一,用本地桶假装分布式。三个实例各配 50/s,实际上限是 150/s 还是 300/s 取决于负载均衡,故障切换后还会翻倍;共享配额必须有协调者或租约。
第二,租约 TTL 与消费速率不匹配。TTL 太短,低速率实例每块都浪费;TTL 太长,实例宕机后那块配额长期不可用。两者都要按“块大小 / 实例速率”算。
第三,忽略时钟漂移。租约过期判断用的是实例本地时钟,NTP 跳变、容器暂停都会改变有效 TTL;要么用协调者时间做租约到期,要么把漂移预算写进 TTL。
第四,滑动窗口日志无上限。实现必须保证记录数不超过 limit,否则被限流的请求越多,内存越大;精确日志适合单键上限较小的入口,超大基数改用加权计数器并接受近似误差。
第五,只报 QPS 不报突发和 RetryAfter。平均值相同的两个入口,一个允许 20 个瞬时突发,一个只允许 1 个,用户体验和下游压力完全不同。
小结
令牌桶用容量换突发、用速率换稳定;滑动窗口日志给出精确的“任意窗口内不超过 N 次”,但突发后会经历一段窗口悬崖。同一序列下两者放行 30 与 21,差 9 个,这个差就是算法选择的业务代价。分布式限流用租约把配额切成有期限的块,协调者只发一次保证不超卖;真实误差来自过期浪费和时钟漂移,模拟中 0.73% 的拒绝可以涨到 3.12%。下一节把这些拒绝和下游故障接起来,讨论熔断状态机怎样决定“何时停止调用”。
阅读导航:上一节:5.3 慢调用与故障扩散 · 下一节:6.2 熔断状态机与恢复策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。