01为什么要限频
限频 (Rate Limiting) 是给系统装一个"流量阀门"。它的目标从来不是"把用户挡在外面", 而是在保护系统不被自己压垮的同时,最大化吞吐量。
限频的本质是 QoS (服务质量) 决策:当资源不够时,先牺牲谁? 是按用户平均分?是按接口优先级?还是按"谁能付更多钱"? 没有"最好"的算法,只有"最适合业务"的算法。
02核心概念
在进入算法之前,先统一一下语言。
sync.Mutex 不够用时,需要 Redis / Token Server 等中心化方案。这又是另一篇文章了。03固定窗口计数器
Fixed Window Counter
最简单 · 边界效应核心思想
把时间切成长度相等的"桶"(比如 1 秒),每进入一个桶时把计数器清零, 之后每个请求 +1,超过阈值就拒绝。
实现只需要两个变量:windowStart 和 counter。
实现(Go)
type FixedWindow struct { mu sync.Mutex window time.Duration limit int counter int windowStart time.Time } func NewFixedWindow(window time.Duration, limit int) *FixedWindow { return &FixedWindow{ window: window, limit: limit, windowStart: time.Now(), } } func (fw *FixedWindow) Allow() bool { fw.mu.Lock() defer fw.mu.Unlock() now := time.Now() if now.Sub(fw.windowStart) >= fw.window { fw.windowStart = now fw.counter = 0 } if fw.counter >= fw.limit { return false } fw.counter++ return true }
优点
- 实现极简,2 个变量搞定
- 内存占用 O(1)
- 对"周期配额"类需求天然友好(每月 1000 次)
缺点
- 边界效应:窗口切换瞬间允许 2× 流量
- 无法处理突发:尾段打满后,下一段从头开始
- 曲线锯齿状,用户体验不平滑
04滑动窗口日志
Sliding Window Log
最精确 · 存储重核心思想
用一条队列(或数组)保存每一次成功请求的时间戳。 每次请求来时,先把队头里"超出窗口"的过期时间戳弹掉, 再看队列长度是否超限。
实现(Go)
type SlidingLog struct { mu sync.Mutex window time.Duration limit int log []time.Time // 每次成功请求的时间戳 } func (l *SlidingLog) Allow() bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() cutoff := now.Add(-l.window) // 弹出已过期的旧时间戳 i := 0 for i < len(l.log) && l.log[i].Before(cutoff) { i++ } l.log = l.log[i:] if len(l.log) >= l.limit { return false } l.log = append(l.log, now) return true }
优点
- 最精确:完全没有边界效应
- 行为可预测:限频曲线完全平滑
- 实现并不复杂,是教科书写法
缺点
- 内存 O(N):每个请求都要存时间戳
- 限频越大、窗口越长,存储越炸
- 不适合"每分钟 100 万次"这种高频场景
05滑动窗口计数器
Sliding Window Counter
折中方案 · 工程最爱核心思想
把时间切成固定窗口,但统计时用上一个完整窗口的计数加权估算"过去"。 这是 Cloudflare 在工程上大量使用的方案——精度够用、内存 O(1)。
公式:
estimate = prevCount × (1 - elapsed/window) + currCount
实现(Go)
type SlidingCounter struct { mu sync.Mutex window time.Duration limit int currCount int prevCount int currStart time.Time } func (s *SlidingCounter) Allow() bool { s.mu.Lock() defer s.mu.Unlock() now := time.Now() elapsed := now.Sub(s.currStart) if elapsed >= s.window { s.prevCount = s.currCount s.currCount = 0 s.currStart = now elapsed = 0 } weight := 1 - float64(elapsed)/float64(s.window) estimate := float64(s.prevCount)*weight + float64(s.currCount) if int(estimate) >= s.limit { return false } s.currCount++ return true }
优点
- 内存 O(1),可分布式
- 对边界效应有显著缓解
- Redis 单命令
INCR+ 过期即可实现
缺点
- 加权是一种估算,不是真精确
- 窗口越短,估算越准;窗口越长,越像 Fixed
- 不擅长处理"窗口内集中爆发"
06令牌桶(Token Bucket)
Token Bucket
工业标准 · Burst Friendly核心思想
维护一个容量为 capacity 的桶,以速率 rate 匀速往桶里放令牌,
桶满则新令牌丢弃。每个请求消耗 1 个令牌,没令牌就拒绝。
两个旋钮:rate(平均速率)+ capacity(桶容量 = 突发上限)。
实现(Go)
type TokenBucket struct { mu sync.Mutex rate float64 // 每秒放几个令牌 capacity int // 桶最大容量 tokens float64 // 当前令牌数 lastToken time.Time // 上次补充时间 } func (tb *TokenBucket) Allow() bool { tb.mu.Lock() defer tb.mu.Unlock() now := time.Now() elapsed := now.Sub(tb.lastToken).Seconds() // 补充令牌:最多补到 capacity tb.tokens = math.Min( float64(tb.capacity), tb.tokens + elapsed*tb.rate, ) tb.lastToken = now if tb.tokens < 1 { return false } tb.tokens-- return true }
优点
- 原生支持突发:桶就是缓冲区
- 两个独立参数:均值和峰值分开调
- 工业事实标准:AWS API Gateway、Stripe、Cloudflare 都在用
缺点
- 需要记录"上次时间戳"和浮点计算
- 分布式版本需中心化协调(Redis Lua)
- 两个参数 看起来 不直观,PM 容易配错
令牌桶是唯一天生为突发设计的算法。 平时桶是满的 → 流量来时先把积攒的令牌用光 → 用完后开始按 rate 排队。 这就是为什么 API 网关、支付系统、LLM 推理几乎都用它。
07漏桶(Leaky Bucket)
Leaky Bucket
整形利器 · 削峰填谷核心思想
请求进入桶中排队,桶以固定速率处理它们。 桶满时新请求被拒绝。它和令牌桶是镜像关系: 一个是"攒够了再发",一个是"匀速发出"。
实现(Go · 单机版)
type LeakyBucket struct { mu sync.Mutex rate time.Duration // 每隔多久漏一个 capacity int // 桶容量 queue chan struct{} stop chan struct{} } func NewLeakyBucket(rate time.Duration, cap int) *LeakyBucket { lb := &LeakyBucket{ rate: rate, capacity: cap, queue: make(chan struct{}, cap), stop: make(chan struct{}), } go lb.leak() return lb } func (lb *LeakyBucket) leak() { t := time.NewTicker(lb.rate) defer t.Stop() for { select { case <-t.C: <-lb.queue // 漏一个 case <-lb.stop: return } } } func (lb *LeakyBucket) Allow() bool { select { case lb.queue <- struct{}{}: return true default: return false // 桶满,拒绝 } }
优点
- 输出绝对平滑,对下游最友好
- 适合流量整形(Traffic Shaping)场景
- 排队机制天然支持削峰
缺点
- 不能"加速":突发反而被压在门外
- 排队请求会增加延迟,超时反而更难处理
- 常和"队列"耦合,实现比令牌桶重
同样的数学结构,位置不同:
令牌桶是"客户端囤积许可"——空闲期攒额度,突发期花掉;
漏桶是"服务端匀速处理"——不管你多急,我都按节奏出。
选哪个?看你的下游是否能承受波动:能承受 → 令牌桶;不能 → 漏桶。
08并发限流
Concurrency Limiter
信号量 · 防打满核心思想
上面所有算法限制的都是"单位时间内的请求数"。但实际生产中更危险的是: 同一瞬间有 1000 个慢查询堆积,每个 10 秒,QPS 才 100,但服务一样挂。
并发限频用一个信号量 (Semaphore) 限制同时处理的请求数。 它不关心"来过几个",只关心"现在有几个人在"。
实现(Go)
type ConcurrencyLimiter struct { sem chan struct{} } func New(limit int) *ConcurrencyLimiter { return &ConcurrencyLimiter{ sem: make(chan struct{}, limit), } } func (c *ConcurrencyLimiter) Do(ctx context.Context, fn func() error) error { select { case c.sem <- struct{}{}: // 拿到许可 case <-ctx.Done(): return ctx.Err() // 超时直接拒 } defer func() { <-c.sem }() return fn() } // 用法 limiter := New(100) for _, req := range requests { go limiter.Do(ctx, req.Handle) }
优点
- 最直接地保护"线程/连接"类资源
- 和 QPS 限频互补,不是替代
- 实现极简(Go channel / Java Semaphore)
缺点
- 无法承诺速率:100 个慢请求持续 1 小时也算"合规"
- 槽位被占满会拖垮上游(需要配超时)
- 不适合做"业务配额"
09横向对比
把上面所有算法放到一张表里看。
| 算法 | 空间复杂度 | 精度 | 突发支持 | 实现难度 | 典型场景 |
|---|---|---|---|---|---|
| 固定窗口 | O(1) | 低(边界) | 无 | ★ | 周期配额、统计报表 |
| 滑动日志 | O(N) | 精确 | 无 | ★★ | 低频关键接口、风控 |
| 滑动计数器 | O(1) | 近似 | 弱 | ★★ | 通用 API、Cloudflare 风格 |
| 令牌桶 | O(1) | 近似 | 强 | ★★★ | API 网关、支付、LLM |
| 漏桶 | O(N)(队列) | 输出平滑 | 消除突发 | ★★★ | 流量整形、消息队列消费 |
| 并发限流 | O(1) | — | 排队式 | ★ | DB 连接、线程池、慢接口 |
10突发流量专题
回到用户的问题:这些算法分别是怎么解决突发流量的?
把"突发"拆开看,本质有三件事:
代表:令牌桶、漏桶、并发限流的信号量。
代表:固定窗口、滑动窗口、滑动日志。
代表:漏桶、消息队列。
代表:Guava RateLimiter 的 warmUp。
组合拳:分层限频
真实系统从来不是单层限频。典型架构是漏桶 + 令牌桶 + 并发三层叠加:
分级、可降级、可观测。 突发流量不可能完全消除,只能挪位置: 要么挪到"队列里等"(漏桶、并发), 要么挪到"令牌桶里存"(令牌桶), 要么挪到"用户的客户端重试"(HTTP 429 + Retry-After)。 最差的选择是"系统级 500",那意味着你没限住。
工业级参考:Sentinel 的设计
Alibaba Sentinel 是国内最常用的限频框架之一,它的策略抽象很有学习价值:
11怎么选?一份决策清单
不要背算法,回答这几个问题就能选出来:
- 我要承诺的是均值还是峰值?
要承诺均值 → 任意算法都行。要承诺"突发不能超过 X" → 令牌桶。 - 下游能不能接受波动?
能 → 令牌桶(AWS/Stripe 风格)。不能 → 漏桶。 - 担心的是"被打爆"还是"被拖垮"?
被打爆(瞬时 QPS 太高)→ QPS 类算法。被拖垮(慢查询堆积)→ 并发限流。 - 限频规模有多大?
千万级 / 需要全局准确 → 滑动日志;百万级 → 滑动计数器;亿级 → 令牌桶(成本最低)。 - 是不是分布式部署?
是 → 状态中心化(Redis + Lua),优先选令牌桶/计数器,避免滑动日志。
90% 的业务场景,令牌桶 + 并发限流这两层就够了。 剩下的 10% 是"高 QPS 强一致"(如支付清算、抢券),再加一层滑动计数器兜底。 不要一开始就上复杂方案,简单能跑起来比复杂跑不起来强一万倍。