令牌桶算法原理

令牌桶(Token Bucket)是限流领域最经典的算法之一,也是 Go 标准库 rate.Limiter 的底层实现。理解它的数学模型与源码细节,是掌握 Go 限流能力的关键一步。

4.1 数学模型

令牌桶的核心思想是:系统以恒定速率向桶中放入令牌,每个请求必须从桶中获取一个令牌才能被处理。桶有最大容量,超出部分令牌被丢弃。

参数 符号 说明
令牌放入速率 r (tokens/sec) 系统每秒向桶中添加的令牌数
桶容量 b (tokens) 桶最多能存放的令牌数
当前令牌数 tokens 桶中当前可用的令牌数量
上次更新时间 lastTime 上次计算令牌数的时间戳

令牌桶的数学模型可以用以下公式描述:

// 当前令牌数 = min(桶容量, 上次令牌数 + (当前时间 - 上次更新时间) * 放入速率)
tokens = min(b, tokens + (now - lastTime) * r)

// 请求消耗 n 个令牌,成功条件:
if tokens >= n {
    tokens -= n
    return true  // 允许请求
}
return false     // 拒绝请求

这个模型保证了两个关键特性:

  • 平滑突发:桶中积累的令牌允许短时间内的突发流量,突发上限为桶容量 b。
  • 长期限速:长期平均速率不会超过 r,因为令牌的补充速率是恒定的。
关键理解:令牌桶不是简单的“每秒放 N 个令牌”,而是通过“桶容量”和“放入速率”两个维度,同时控制突发大小和平均速率。桶容量越大,允许的突发峰值越高;放入速率越慢,长期吞吐越低。

4.2 令牌桶状态机

下图展示了令牌桶在请求到达时的完整状态流转:

令牌桶状态机 初始状态 tokens = b 令牌补充 tokens += Δt * r 请求到达 需要 n 个令牌 tokens ≥ n? 允许请求 tokens -= n 拒绝请求 等待重试 时间推进,循环补充

4.3 Go 标准库 rate.Limiter 源码分析

Go 标准库 golang.org/x/time/rate 包中的 Limiter 是令牌桶的工业级实现。核心结构体如下:

type Limiter struct {
    mu     sync.Mutex
    limit  Limit        // 放入速率 r(每秒令牌数)
    burst  int          // 桶容量 b
    tokens float64      // 当前令牌数
    last   time.Time    // 上次更新令牌数的时间
    lastEvent time.Time // 上次限流事件的时间(用于 Wait 系列方法)
}

4.4 核心方法 reserveN 源码解析

reserveN 是 Limiter 最核心的内部方法,所有公开方法(Allow、Reserve、Wait)最终都调用它。以下是简化后的核心逻辑:

func (lim *Limiter) reserveN(now time.Time, n int, maxFutureReserve time.Duration) Reservation {
    lim.mu.Lock()
    defer lim.mu.Unlock()

    // 1. 计算时间差,补充令牌
    last := lim.last
    tokens := lim.tokens
    elapsed := now.Sub(last)
    delta := elapsed.Seconds() * float64(lim.limit)
    tokens += delta
    if tokens > float64(lim.burst) {
        tokens = float64(lim.burst)
    }

    // 2. 计算消耗 n 个令牌后的令牌数
    tokens -= float64(n)

    // 3. 如果令牌不足,计算需要等待的时间
    var waitDuration time.Duration
    if tokens < 0 {
        waitDuration = lim.limit.durationFromTokens(-tokens)
    }

    // 4. 判断是否允许
    ok := n <= lim.burst && waitDuration <= maxFutureReserve

    // 5. 更新状态
    if ok {
        lim.last = now
        lim.tokens = tokens
        lim.lastEvent = now.Add(waitDuration)
    } else {
        lim.last = now
    }

    return Reservation{
        ok:    ok,
        lim:   lim,
        tokens: n,
        timeToAct: now.Add(waitDuration),
    }
}
源码洞察:reserveN 的“补充令牌”操作是惰性计算的——它不是在定时器中补充,而是在每次请求到达时,根据时间差一次性计算应补充的令牌数。这种设计避免了定时器开销,性能极高。

4.5 三种调用方式对比

方法 行为 阻塞 适用场景
Allow() 立即返回是否允许,不允许则丢弃 否 可丢弃的请求,如日志、监控
Reserve() 返回 Reservation,可查询等待时间 否 需要预知等待时间的场景
Wait() 阻塞直到有足够令牌 是 必须执行的请求,如数据库写入

4.6 令牌桶 vs 漏桶

特性 令牌桶 漏桶
突发处理 允许突发(上限为桶容量) 严格平滑,不允许突发
实现复杂度 中等 简单
Go 标准库支持 rate.Limiter 无
适用场景 API 限流、微服务网关 流量整形、网络 QoS
常见误区:不要将 rate.Limiter 的 burst 参数设置为 1,否则令牌桶退化为“固定窗口计数器”,失去突发能力。通常建议 burst 至少为 limit 的 2~5 倍,以允许合理的突发流量。

4.7 实战:构建一个带超时的限流器

以下示例展示了如何使用 rate.Limiter 实现一个带超时控制的限流器:

package main

import (
    "context"
    "fmt"
    "time"
    "golang.org/x/time/rate"
)

func main() {
    // 每秒 10 个令牌,桶容量 20
    limiter := rate.NewLimiter(10, 20)

    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    for i := 0; i < 30; i++ {
        if err := limiter.Wait(ctx); err != nil {
            fmt.Printf("请求 %d 被限流: %v\n", i, err)
            continue
        }
        fmt.Printf("请求 %d 通过\n", i)
    }
}

输出示例:

请求 0 通过
请求 1 通过
...
请求 19 通过
请求 20 被限流: rate: Wait(n=1) would exceed context deadline
请求 21 被限流: rate: Wait(n=1) would exceed context deadline

前 20 个请求利用桶中的积累令牌瞬间通过,后续请求因令牌补充速度(10个/秒)跟不上请求速度,且上下文超时,被拒绝。

工程要点:在生产环境中,Wait 方法必须配合 context.WithTimeout 使用,防止请求无限阻塞。超时时间应根据业务容忍度设置,通常为 50ms~500ms。