SYSTEM DESIGN · RATE LIMITING

限频的六种姿势
从原理到突发流量

一份写给"懂一点但不完全懂"的工程师的实战指南。我们从最朴素的计数器讲到工业级限频组件, 把每种算法的核心思想、Go 语言实现、以及它如何面对突发流量 (Burst) 一次性讲清楚。

语言 · Go 覆盖 · 6 大算法 + 1 维度 阅读 · ~ 20 min 难度 · 入门 → 进阶

01为什么要限频

限频 (Rate Limiting) 是给系统装一个"流量阀门"。它的目标从来不是"把用户挡在外面", 而是在保护系统不被自己压垮的同时,最大化吞吐量

保护下游
防止雪崩:一个慢接口被压垮,连带整条链路挂掉。
公平调度
防止某个大客户/爬虫/刷子吃掉所有资源。
成本控制
第三方 API / LLM Token 都按调用计费,限频就是省钱。
安全防御
防爆破、防 CC、防撞库的第一道墙。
关键认知

限频的本质是 QoS (服务质量) 决策:当资源不够时,先牺牲谁? 是按用户平均分?是按接口优先级?还是按"谁能付更多钱"? 没有"最好"的算法,只有"最适合业务"的算法。

02核心概念

在进入算法之前,先统一一下语言。

QPS / RPS
每秒请求数,是最常见的限频单位。注意区分"瞬时 QPS"和"平均 QPS"。
突发流量 (Burst)
短时间内请求量陡增。可能来自:秒杀、活动、爬虫、攻击。算法对 Burst 的态度决定了它的脾气。
窗口 (Window)
统计的时间段。1 秒、1 分钟、1 小时是常见选择。窗口越短,反应越灵敏;窗口越长,统计越平滑。
限频维度
按什么分桶:全局 / 用户 ID / IP / API Key / 接口路径。维度越多,状态越多。
拒绝策略
直接 429 / 排队等待 / 降级返回(缓存、默认值)。不同业务态度完全不同。
分布式
单机的 sync.Mutex 不够用时,需要 Redis / Token Server 等中心化方案。这又是另一篇文章了。

03固定窗口计数器

Fixed Window Counter

最简单 · 边界效应
把一天分成 24 个"整点小时",每个小时是一张桌子,满了就不接单。换桌前会把上一桌没坐满的座位也扔掉。

核心思想

把时间切成长度相等的"桶"(比如 1 秒),每进入一个桶时把计数器清零, 之后每个请求 +1,超过阈值就拒绝。

实现只需要两个变量:windowStartcounter

实现(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
}
时间轴 → 12:00:00 12:00:01 12:00:02 12:00:03 100/100 ✗ 0/100 0/100 末尾 100ms 空闲 开头 100ms 200 req 瞬间通过
▲ 边界效应:两个窗口各打满,实际 200ms 内放过 2× 流量

优点

  • 实现极简,2 个变量搞定
  • 内存占用 O(1)
  • 对"周期配额"类需求天然友好(每月 1000 次)

缺点

  • 边界效应:窗口切换瞬间允许 2× 流量
  • 无法处理突发:尾段打满后,下一段从头开始
  • 曲线锯齿状,用户体验不平滑
对突发流量的容忍度
只能被动接受"碰巧"落在窗口切换的突发,没有主动缓冲机制。

04滑动窗口日志

Sliding Window Log

最精确 · 存储重
门口放一个签到本,每来一个访客就记下到达时间。放人前先数:过去 1 分钟内签到过几次?超过 10 人就让后面的人排队。

核心思想

用一条队列(或数组)保存每一次成功请求的时间戳。 每次请求来时,先把队头里"超出窗口"的过期时间戳弹掉, 再看队列长度是否超限。

实现(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
}
t-3 t-2 t-1 t=now 窗口起点 当前请求 窗口内 = 3 次 → 通过
▲ 滑动窗口是"滚动"的,统计精确到毫秒

优点

  • 最精确:完全没有边界效应
  • 行为可预测:限频曲线完全平滑
  • 实现并不复杂,是教科书写法

缺点

  • 内存 O(N):每个请求都要存时间戳
  • 限频越大、窗口越长,存储越炸
  • 不适合"每分钟 100 万次"这种高频场景
对突发流量的容忍度 低 · 但精确
不提供任何"缓冲",满就拒绝。优点是承诺精确,缺点是没有弹性。

05滑动窗口计数器

Sliding Window Counter

折中方案 · 工程最爱
不再记每一笔账,而是上一秒记 60 笔、这一秒记 30 笔。在 12:00:00.7 这个瞬间,要算的是"前 0.3 秒用 30% 的这一秒 + 后 0.7 秒用 100% 的上一秒"。

核心思想

把时间切成固定窗口,但统计时用上一个完整窗口的计数加权估算"过去"。 这是 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
}
上一窗口 100 当前 30 elapsed 30% estimate = 100 × 0.7 + 30 = 100 → 临界拒绝 限频 100 时,新请求会被拒绝
▲ 用"上一窗口剩余比例"做加权,是 Fixed 与 Log 的折中

优点

  • 内存 O(1),可分布式
  • 对边界效应有显著缓解
  • Redis 单命令 INCR + 过期即可实现

缺点

  • 加权是一种估算,不是真精确
  • 窗口越短,估算越准;窗口越长,越像 Fixed
  • 不擅长处理"窗口内集中爆发"
对突发流量的容忍度 中低
比 Fixed 好,但没有"桶"来蓄水;本质上还是计数器。

06令牌桶(Token Bucket)

Token Bucket

工业标准 · Burst Friendly
水龙头以固定速度往水箱里滴水(令牌),水箱最多装 N 滴水。每个请求想进门就要从水箱里拿走一滴;水箱空了就站门外等。N 就是"最大突发"的上限。

核心思想

维护一个容量为 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
}
空闲期(攒令牌) 突发期(消耗令牌) 桶满 capacity=9 突发 9 个请求瞬间通过 之后按 rate 匀速放行
▲ rate=10/s, capacity=9:可瞬时吃满 9 个,再以 10/s 持续

优点

  • 原生支持突发:桶就是缓冲区
  • 两个独立参数:均值和峰值分开调
  • 工业事实标准: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)场景
  • 排队机制天然支持削峰

缺点

  • 不能"加速":突发反而被压在门外
  • 排队请求会增加延迟,超时反而更难处理
  • 常和"队列"耦合,实现比令牌桶重
对突发流量的容忍度 低 · 主动削峰
漏桶的设计哲学是"消灭突发",不是"接纳突发"。
令牌桶 vs 漏桶

同样的数学结构,位置不同: 令牌桶是"客户端囤积许可"——空闲期攒额度,突发期花掉; 漏桶是"服务端匀速处理"——不管你多急,我都按节奏出。

选哪个?看你的下游是否能承受波动:能承受 → 令牌桶;不能 → 漏桶。

08并发限流

Concurrency Limiter

信号量 · 防打满
餐厅只有 10 张桌子,第 11 个客人来了就得等位或走人。和 QPS 限频不同,这限制的不是"频率",而是"瞬时在店里的人数"。

核心思想

上面所有算法限制的都是"单位时间内的请求数"。但实际生产中更危险的是: 同一瞬间有 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)
}
在飞请求 ≤ 3 请求 A 请求 B 请求 C 拒绝 D 拒绝 E 拒绝 F 等 A/B/C 任一完成,释放槽位 D/E/F 中最先到达的补上
▲ Go 的 channel 就是天然信号量

优点

  • 最直接地保护"线程/连接"类资源
  • 和 QPS 限频互补,不是替代
  • 实现极简(Go channel / Java Semaphore)

缺点

  • 无法承诺速率:100 个慢请求持续 1 小时也算"合规"
  • 槽位被占满会拖垮上游(需要配超时)
  • 不适合做"业务配额"
对突发流量的容忍度 看槽位
N 个槽位 → 至多 N 个请求同时进入。突发是"积压在门外"而不是"打满系统"。

09横向对比

把上面所有算法放到一张表里看。

算法 空间复杂度 精度 突发支持 实现难度 典型场景
固定窗口 O(1) 低(边界) 周期配额、统计报表
滑动日志 O(N) 精确 ★★ 低频关键接口、风控
滑动计数器 O(1) 近似 ★★ 通用 API、Cloudflare 风格
令牌桶 O(1) 近似 ★★★ API 网关、支付、LLM
漏桶 O(N)(队列) 输出平滑 消除突发 ★★★ 流量整形、消息队列消费
并发限流 O(1) 排队式 DB 连接、线程池、慢接口

10突发流量专题

回到用户的问题:这些算法分别是怎么解决突发流量的?

把"突发"拆开看,本质有三件事:

1. 缓冲(BUFFER)
把"瞬时尖刺"暂存起来,让下游按节奏消费。
代表:令牌桶、漏桶、并发限流的信号量
2. 削峰(SHED)
直接拒绝超出部分,宁可 429 也不要把系统打挂。
代表:固定窗口、滑动窗口、滑动日志
3. 整形(SHAPE)
把不均匀的输入转成均匀的输出,保护下游。
代表:漏桶、消息队列
4. 预热(WARM-UP)
冷启动时不要按"满速"放行,避免把刚启动的服务打挂。
代表:Guava RateLimiter 的 warmUp

组合拳:分层限频

真实系统从来不是单层限频。典型架构是漏桶 + 令牌桶 + 并发三层叠加:

客户端 ① 全局限频(令牌桶) ② 用户限频(滑动计数器) ③ 接口并发(信号量) App / H5 rate=10万/s · cap=2万 每用户 100/min 并发 ≤ 500 业务服务 挡整体尖刺 挡单用户滥用 挡慢查询堆积
面对突发的原则

分级、可降级、可观测。 突发流量不可能完全消除,只能挪位置: 要么挪到"队列里等"(漏桶、并发), 要么挪到"令牌桶里存"(令牌桶), 要么挪到"用户的客户端重试"(HTTP 429 + Retry-After)。 最差的选择是"系统级 500",那意味着你没限住。

工业级参考:Sentinel 的设计

Alibaba Sentinel 是国内最常用的限频框架之一,它的策略抽象很有学习价值:

流控规则
QPS / 并发线程数 两种维度,可基于调用关系、链路、来源做精细化控制。
流控效果
快速失败 / Warm Up(预热)/ 排队等待(≈ 漏桶),选一种应对方式。
熔断降级
当平均响应时间或异常比例超阈值,直接短路,相当于软限频。
系统自适应
根据 CPU、Load、入口 QPS 动态调整阈值——这是真正的"AI 限频"。

11怎么选?一份决策清单

不要背算法,回答这几个问题就能选出来:

  1. 我要承诺的是均值还是峰值?
    要承诺均值 → 任意算法都行。要承诺"突发不能超过 X" → 令牌桶。
  2. 下游能不能接受波动?
    能 → 令牌桶(AWS/Stripe 风格)。不能 → 漏桶。
  3. 担心的是"被打爆"还是"被拖垮"?
    被打爆(瞬时 QPS 太高)→ QPS 类算法。被拖垮(慢查询堆积)→ 并发限流。
  4. 限频规模有多大?
    千万级 / 需要全局准确 → 滑动日志;百万级 → 滑动计数器;亿级 → 令牌桶(成本最低)。
  5. 是不是分布式部署?
    是 → 状态中心化(Redis + Lua),优先选令牌桶/计数器,避免滑动日志
我的建议

90% 的业务场景,令牌桶 + 并发限流这两层就够了。 剩下的 10% 是"高 QPS 强一致"(如支付清算、抢券),再加一层滑动计数器兜底。 不要一开始就上复杂方案,简单能跑起来比复杂跑不起来强一万倍。