4. Go 实现令牌桶限流器:基于 time.Ticker 和 channel
令牌桶(Token Bucket)是限流领域最经典的算法之一。它通过一个固定容量的桶来存放令牌,以恒定速率向桶中添加令牌,请求到达时需从桶中获取一个令牌才能通过。这种设计既能平滑突发流量,又能保证长期的平均速率。
本节将使用 Go 语言的原生并发原语——time.Ticker 和 chan struct{}——实现一个生产可用的令牌桶限流器。代码不依赖第三方库,结构清晰,适合嵌入到微服务网关或 RPC 中间件中。
4.1 核心设计思路
令牌桶的实现可以拆解为两个并发角色:
- 生产者:一个后台 goroutine 配合
time.Ticker,每隔固定时间间隔向桶中投放一个令牌。 - 消费者:业务请求调用
Allow()或Wait()方法,尝试从桶中取出一个令牌。
桶本身是一个带缓冲的 channel,缓冲大小即为桶的容量。channel 的收发语义天然符合令牌的“存入”与“取出”操作,且 goroutine-safe,无需额外加锁。
| 组件 | 类型 | 职责 |
|---|---|---|
| 桶 | chan struct{} |
存放令牌,缓冲长度 = 桶容量 |
| 生产者 | time.Ticker + goroutine |
按速率向 channel 发送空结构体 |
| 消费者 | Allow() / Wait() |
尝试从 channel 接收令牌 |
| 关闭控制 | context.Context |
优雅停止后台 goroutine |
设计要点:使用
chan struct{} 而非 chan int 或 chan bool,因为空结构体不占用内存,且语义上仅表示“令牌存在”这一信号,无额外数据负载。
4.2 令牌桶结构体定义
// TokenBucket 令牌桶限流器
type TokenBucket struct {
rate time.Duration // 令牌生成间隔
capacity int // 桶容量
tokens chan struct{} // 令牌 channel
ctx context.Context
cancel context.CancelFunc
}
4.3 构造函数与生产者启动
构造函数接收每秒期望的请求数(QPS)和桶容量。内部将 QPS 转换为令牌生成间隔,并启动后台 goroutine 持续向 channel 投放令牌。
// NewTokenBucket 创建一个令牌桶限流器
// qps: 每秒允许的请求数
// capacity: 桶容量,允许的突发大小
func NewTokenBucket(qps int, capacity int) *TokenBucket {
if qps <= 0 || capacity <= 0 {
panic("qps and capacity must be positive")
}
ctx, cancel := context.WithCancel(context.Background())
tb := &TokenBucket{
rate: time.Second / time.Duration(qps),
capacity: capacity,
tokens: make(chan struct{}, capacity),
ctx: ctx,
cancel: cancel,
}
// 预填充桶至满
for i := 0; i < capacity; i++ {
tb.tokens <- struct{}{}
}
// 启动生产者 goroutine
go tb.produce()
return tb
}
// produce 后台令牌生产者
func (tb *TokenBucket) produce() {
ticker := time.NewTicker(tb.rate)
defer ticker.Stop()
for {
select {
case <-tb.ctx.Done():
return
case <-ticker.C:
// 非阻塞发送:桶满时丢弃令牌
select {
case tb.tokens <- struct{}{}:
default:
// 桶已满,丢弃本次令牌
}
}
}
}
关键细节:生产者使用双重 select 结构。外层 select 监听 ticker 和关闭信号;内层 select 向 channel 发送令牌时使用
default 分支处理桶满的情况,避免生产者阻塞。
4.4 消费者方法:Allow 与 Wait
限流器对外暴露两个核心方法:
Allow():非阻塞尝试获取令牌,立即返回是否成功。Wait():阻塞直到获取到令牌,适用于必须等待的场景。
// Allow 非阻塞获取令牌
// 返回 true 表示获取成功,请求被允许
func (tb *TokenBucket) Allow() bool {
select {
case <-tb.tokens:
return true
default:
return false
}
}
// Wait 阻塞等待直到获取令牌
// 可通过 context 取消等待
func (tb *TokenBucket) Wait(ctx context.Context) error {
select {
case <-tb.tokens:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
4.5 关闭与资源清理
限流器必须提供关闭方法,以停止后台 goroutine 并释放 ticker 资源。
// Stop 停止令牌桶,释放后台资源
func (tb *TokenBucket) Stop() {
tb.cancel()
}
4.6 令牌桶工作流程
下图展示了令牌桶从初始化到请求限流的完整生命周期:
4.7 完整使用示例
func main() {
// 创建限流器:QPS=10,桶容量=5(允许突发5个请求)
limiter := NewTokenBucket(10, 5)
defer limiter.Stop()
// 模拟20个并发请求
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if limiter.Allow() {
fmt.Printf("[%d] 请求通过\n", id)
} else {
fmt.Printf("[%d] 请求被限流\n", id)
}
}(i)
}
wg.Wait()
}
4.8 性能与边界分析
| 场景 | 行为 | 说明 |
|---|---|---|
| 桶满时持续生产 | 令牌被丢弃 | 生产者 default 分支防止阻塞 |
| 桶空时大量请求 | Allow 返回 false | 非阻塞,请求快速失败 |
| 突发流量 | 桶中积攒的令牌被快速消耗 | 容量越大,突发容忍度越高 |
| Stop 后继续调用 | Allow 仍可消耗剩余令牌 | 生产者已停止,桶不再补充 |
注意事项:本实现基于 channel 和 goroutine,适用于大多数业务场景。但在极端高并发(百万 QPS 级别)下,channel 的调度开销可能成为瓶颈。此时建议使用原子操作 + 时间戳计算的纯用户态令牌桶实现。
4.9 与标准限流器对比
| 特性 | 本实现 | golang.org/x/time/rate |
|---|---|---|
| 算法 | 令牌桶(channel 实现) | 令牌桶(浮点计算 + 原子操作) |
| 并发安全 | channel 天然安全 | 互斥锁 + 原子操作 |
| 突发支持 | 通过 capacity 控制 | 通过 Burst 方法控制 |
| 等待语义 | Wait(ctx) 支持取消 | Wait(ctx) 支持取消 |
| 性能 | 中等(goroutine 调度) | 高(无 goroutine 开销) |
| 依赖 | 纯标准库 | 需要引入 x/time 包 |
本实现的优势在于零依赖、代码可读性强、适合教学和中小规模项目。对于追求极致性能的生产环境,建议在理解原理后迁移到 x/time/rate 或自研原子操作版本。