Contents

限流算法实战:三种经典算法的Go实现与选型指南

为什么限流是后端开发的必修课

在高并发系统中,限流是保护服务稳定性的第一道防线。无论是突发流量洪峰、恶意请求轰炸,还是下游服务变慢导致的雪崩,限流都能在危机时刻守住底线。

限流算法不止一种,选错了方案,要么防护不够,要么误杀正常请求。本文将拆解三种经典限流算法的原理,给出完整的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中原子执行,多个服务实例共享同一个计数器,实现全局限流。注意设置合理的过期时间,避免冷数据长期占用内存。

实践建议

  1. 限流维度要选对:按用户ID限流、按IP限流、按接口限流,还是全局限流,取决于你的防护目标
  2. 拒绝策略要合理:直接返回429是最简单的,但更好的做法是排队等待或降级返回缓存数据
  3. 监控不可少:限流触发频率是系统健康度的重要指标,接入告警,在限流频繁触发时主动扩容
  4. 不要过度依赖限流:限流是兜底措施,根本解法是提升系统容量和优化架构

总结

限流算法不是选"最好的",而是选"最合适的"。令牌桶兼顾弹性与防护,是大多数场景的首选;漏桶适合需要严格速率控制的下游保护;滑动窗口则在精确度要求高的场景中占优。理解每种算法的特性,结合你的业务需求做出选型,才是工程师该做的事。