为什么限流是后端开发的必修课
在高并发系统中,限流是保护服务稳定性的第一道防线。无论是突发流量洪峰、恶意请求轰炸,还是下游服务变慢导致的雪崩,限流都能在危机时刻守住底线。
限流算法不止一种,选错了方案,要么防护不够,要么误杀正常请求。本文将拆解三种经典限流算法的原理,给出完整的Go实现,并讨论各自的适用场景。
令牌桶算法(Token Bucket)
令牌桶是目前应用最广泛的限流算法。核心思路很简单:以固定速率往桶里放令牌,请求来了先拿令牌,拿到了才能通过,拿不到就拒绝或排队。
桶有一个容量上限,满了就不再放。这意味着令牌桶允许一定程度的突发流量——桶里攒了令牌,短时间大量请求能一次性消费掉。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
package ratelimit
import (
"sync"
"time"
)
type TokenBucket struct {
mu sync.Mutex
rate float64 // 每秒生成令牌数
burst float64 // 桶容量
tokens float64 // 当前令牌数
lastRefill time.Time // 上次补充时间
}
func NewTokenBucket(rate, burst float64) *TokenBucket {
return &TokenBucket{
rate: rate,
burst: burst,
tokens: burst, // 初始满桶
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
// 按时间比例补充令牌
tb.tokens += elapsed * tb.rate
if tb.tokens > tb.burst {
tb.tokens = tb.burst
}
tb.lastRefill = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
|
适用场景:API网关、微服务入口限流。Go标准库 golang.org/x/time/rate 就是令牌桶实现,生产环境直接用即可,不必手写。
漏桶算法(Leaky Bucket)
漏桶算法把请求比作水,桶以固定速率漏水(处理请求)。水来得太快超出桶容量就溢出(丢弃)。与令牌桶不同,漏桶强制输出速率恒定,不允许突发。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
type LeakyBucket struct {
mu sync.Mutex
rate float64 // 漏出速率(每秒处理请求数)
capacity float64 // 桶容量
water float64 // 当前水量
lastLeak time.Time // 上次漏水时间
}
func NewLeakyBucket(rate, capacity float64) *LeakyBucket {
return &LeakyBucket{
rate: rate,
capacity: capacity,
water: 0,
lastLeak: time.Now(),
}
}
func (lb *LeakyBucket) Allow() bool {
lb.mu.Lock()
defer lb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(lb.lastLeak).Seconds()
// 漏水
lb.water -= elapsed * lb.rate
if lb.water < 0 {
lb.water = 0
}
lb.lastLeak = now
if lb.water < lb.capacity {
lb.water++
return true
}
// 水满溢出
return false
}
|
适用场景:需要严格均匀输出的场景,如消息队列消费速率控制、对外部API的调用频率控制。如果你不希望任何突发请求打垮下游,漏桶是更好的选择。
滑动窗口算法(Sliding Window)
滑动窗口将时间划分为小窗口,在每个窗口内计数。窗口随时间滑动,过期计数自动移除。相比固定窗口,滑动窗口避免了边界突刺问题(固定窗口在切换瞬间可能允许双倍流量)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
type SlidingWindow struct {
mu sync.Mutex
rate int // 窗口内允许请求数
window time.Duration // 窗口大小
requests []time.Time // 请求时间戳
}
func NewSlidingWindow(rate int, window time.Duration) *SlidingWindow {
return &SlidingWindow{
rate: rate,
window: window,
requests: make([]time.Time, 0, rate),
}
}
func (sw *SlidingWindow) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
border := now.Add(-sw.window)
// 移除过期请求
valid := sw.requests[:0]
for _, t := range sw.requests {
if t.After(border) {
valid = append(valid, t)
}
}
sw.requests = valid
if len(sw.requests) < sw.rate {
sw.requests = append(sw.requests, now)
return true
}
return false
}
|
适用场景:需要精确控制时间段内请求数的场景,如"每分钟100次"的API调用限制。滑动窗口在精确度上最优,但内存开销随窗口内请求数线性增长。
三种算法对比
| 特性 |
令牌桶 |
漏桶 |
滑动窗口 |
| 突发流量 |
允许(受桶容量限制) |
不允许 |
受限 |
| 输出平滑度 |
不平滑 |
完全平滑 |
不平滑 |
| 精确度 |
中等 |
高 |
最高 |
| 内存开销 |
O(1) |
O(1) |
O(n) |
| 实现复杂度 |
低 |
低 |
中等 |
选型建议:大多数场景用令牌桶就够了,它是事实上的标准。需要恒定输出速率选漏桶。需要精确控制窗口请求数选滑动窗口。
分布式限流:Redis + Lua
单机限流只能保护单个实例。多实例部署下,需要分布式限流。Redis + Lua脚本是最常见的方案,保证原子性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
// Redis令牌桶Lua脚本
const tokenBucketScript = `
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or burst
local lastRefill = tonumber(data[2]) or now
local elapsed = math.max(0, now - lastRefill)
tokens = math.min(burst, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 0
end
`
// 使用示例
func AllowRedis(rdb *redis.Client, key string, rate, burst float64) (bool, error) {
result, err := rdb.Eval(context.Background(), tokenBucketScript,
[]string{key},
rate, burst, float64(time.Now().UnixNano())/1e9,
).Int()
if err != nil {
return false, err
}
return result == 1, nil
}
|
Lua脚本在Redis中原子执行,多个服务实例共享同一个计数器,实现全局限流。注意设置合理的过期时间,避免冷数据长期占用内存。
实践建议
- 限流维度要选对:按用户ID限流、按IP限流、按接口限流,还是全局限流,取决于你的防护目标
- 拒绝策略要合理:直接返回429是最简单的,但更好的做法是排队等待或降级返回缓存数据
- 监控不可少:限流触发频率是系统健康度的重要指标,接入告警,在限流频繁触发时主动扩容
- 不要过度依赖限流:限流是兜底措施,根本解法是提升系统容量和优化架构
总结
限流算法不是选"最好的",而是选"最合适的"。令牌桶兼顾弹性与防护,是大多数场景的首选;漏桶适合需要严格速率控制的下游保护;滑动窗口则在精确度要求高的场景中占优。理解每种算法的特性,结合你的业务需求做出选型,才是工程师该做的事。