极简架构的限流算法选型:令牌桶、滑动窗口与自适应限流的性能对比
一、同一个接口的三种限流方式,效果天差地别
一个 API 服务使用固定窗口限流(每分钟 100 次)。用户在前 2 秒内打满 100 次请求后,后 58 秒全部被拒绝——"时间窗口的边界效应"。在这个 2 秒的流量尖刺中,服务虽然没被打垮,但数据库的 CPU 飙到了 89%——100 个请求在 2 秒内的瞬时压力大于 100 个请求在 60 秒内的均匀分布。
切换到令牌桶算法后,同样的流量模式不再触发数据库压力告警——桶的容量限制了突发流量,用户请求被平滑地排出,而不是在窗口边界集中涌入。
二、四种限流算法的对比
| 算法 | 突发容忍 | 实现复杂度 | 内存占用 | 精度 | 适用场景 |
|---|---|---|---|---|---|
| 固定窗口 | 高(边界效应) | 低 | 极低 | 低 | 非关键 API |
| 滑动窗口 | 中 | 中 | 中 | 高 | API 限流(推荐) |
| 令牌桶 | 可控(桶容量) | 中 | 低 | 高 | 允许合理突发 |
| 漏桶 | 零 | 中 | 低 | 高 | 严格的速率整形 |
2.1 令牌桶的极简实现
// 令牌桶:允许短期突发,长期限定平均速率 type TokenBucket struct { rate float64 // 令牌生成速率(个/秒) capacity float64 // 桶容量(最大突发) tokens float64 // 当前令牌数 lastUpdate time.Time // 上次更新令牌的时间 mu sync.Mutex } func NewTokenBucket(rate, capacity float64) *TokenBucket { return &TokenBucket{ rate: rate, capacity: capacity, tokens: capacity, lastUpdate: time.Now(), } } func (tb *TokenBucket) Allow() bool { tb.mu.Lock() defer tb.mu.Unlock() // 计算流逝时间内新增的令牌 now := time.Now() elapsed := now.Sub(tb.lastUpdate).Seconds() tb.tokens = math.Min(tb.capacity, tb.tokens + elapsed*tb.rate) tb.lastUpdate = now if tb.tokens >= 1.0 { tb.tokens -= 1.0 return true } return false }2.2 滑动窗口的精确实现
// 滑动窗口日志:存储最近的请求时间戳 type SlidingWindowLog struct { window time.Duration // 窗口大小(如 1 分钟) maxReqs int // 窗口内最大请求数 timestamps []time.Time // 请求时间戳链表 mu sync.Mutex } func (sw *SlidingWindowLog) Allow() bool { sw.mu.Lock() defer sw.mu.Unlock() now := time.Now() cutoff := now.Add(-sw.window) // 清理过期的请求记录 valid := 0 for _, ts := range sw.timestamps { if ts.After(cutoff) { sw.timestamps[valid] = ts valid++ } } sw.timestamps = sw.timestamps[:valid] if len(sw.timestamps) >= sw.maxReqs { return false } sw.timestamps = append(sw.timestamps, now) return true }2.3 自适应限流
// 自适应限流:根据系统负载动态调整速率 type AdaptiveLimiter struct { bucket *TokenBucket cpuThreshold float64 // CPU 阈值,超过即降低速率 minRate float64 maxRate float64 } func (l *AdaptiveLimiter) Allow() bool { // 获取当前 CPU 使用率 cpuUsage := getCPUUsage() // CPU 高时降低速率,CPU 低时恢复 currentRate := l.maxRate if cpuUsage > l.cpuThreshold { ratio := (1.0 - cpuUsage) / (1.0 - l.cpuThreshold) currentRate = l.minRate + (l.maxRate - l.minRate)*ratio if currentRate < l.minRate { currentRate = l.minRate } } l.bucket.rate = currentRate return l.bucket.Allow() }三、性能基准对比
在 8 核 CPU 上对 100 万次Allow()调用进行基准测试:
| 算法 | ns/op | 内存/op | 分配次数/op |
|---|---|---|---|
| 固定窗口 | 12 ns | 0 B | 0 |
| 令牌桶 | 45 ns | 0 B | 0 |
| 滑动窗口日志 | 180 ns | 80 B | 1 |
| 自适应限流 | 55 ns | 0 B | 0 |
固定窗口最快但精度最差,滑动窗口最精确但需要内存分配(存储时间戳)。令牌桶在速度和精度之间取得了最好的平衡。
四、边界与权衡
分布式限流的复杂性:单机限流容易,分布式限流需要同步状态。Redis 的INCR+EXPIRE实现固定窗口很简单,但令牌桶在分布式环境下的同步开销显著。建议单机使用令牌桶,分布式使用 Redis 固定窗口 + 本地令牌桶双层限流。
令牌桶的"突发"副作用:桶容量 100 意味着在令牌攒满后,可以瞬间发出 100 个请求。如果下游服务无法承受这个突发量,需要调低桶容量。
过于激进的限流:限流阈值设置过低会让正常用户被误杀。建议从宽松阈值开始(预测峰值 QPS 的 2 倍),逐步收紧,每个阶段观察业务指标(订单量、用户投诉)。
五、总结
API 限流算法选型:微服务内部调用用令牌桶(平衡突发和平均)、面向用户 API 用滑动窗口(精确公平)、系统保护用自适应限流(按系统负载动态调整)。令牌桶是 90% 场景下的最优默认选择——实现简单、性能高、允许合理的请求突发。
实施建议:先上固定窗口验证限流逻辑是否正确 → 切换为令牌桶(调优突发容量)→ 在流量稳态后加入自适应降速。限流算法不是一劳永逸的——根据流量模式的演变(日均 QPS、峰值/均值比、下游承受能力)定期复审参数。