《后端架构设计·高性能与高可用》6.1 限流算法与分布式限流

在 arch-lab 订单链路上实现令牌桶与精确滑动窗口限流器,用注入时钟重放同一到达序列,对照放行数、突发容忍与窗口误差;再实现带 TTL 的分布式租约,用确定性模拟量化时钟漂移造成的配额浪费、拒绝率与跨实例不均,并给出下单、支付回调的限流参数与运维边界。

先决定拒绝谁,再决定怎么拒绝

限流的通用动机和常见策略在 限流与熔断 里已经讲过,这里不再重复。本节只回答 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 的实例时钟偏移:

偏移需求放行拒绝浪费令牌协调者已发各实例放行
01506149511(0.73%)5215601001 / 295 / 199
+150ms1506145947(3.12%)921560994 / 279 / 186
-150ms150615006(0.40%)4815521000 / 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_id10/s20RATE_LIMITED正常用户 1–2/s,20 容忍连点
订单查询 GET /orders/{id}令牌桶,按 customer_id50/s100RATE_LIMITED页面轮询与批量刷新
支付回调 POST /payments/callback滑动窗口,按 provider200/s1sRATE_LIMITED渠道重推有整批突发
全局实例配额租约,3 实例150/s60RATE_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 熔断状态机与恢复策略 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查