分布式限流 / 令牌桶与漏桶 003 — RATE LIMITING

分布式限流 算法
令牌桶与漏桶的
Go 实现 与 对比。

限流是高并发系统保护下游服务、抵御流量洪峰的核心手段,是系统雪崩防护的最后一道防线。本文依次讲解时间窗口、漏桶、令牌桶三种主流限流模型,并基于 Go 语言结合 Redis + Lua 脚本实现高性能分布式限流器内核,可直接用于网关、业务服务线上部署。

算法模型
4TYPES
实现语言
Go
原子性保障
Lua
状态存储
Redis
CHAPTER 01
01/05

三种经典限流模型 原理剖析

从最简单的计数器到令牌桶,限流算法的演进本质是从"粗暴拒绝"到"流量整形"的工程哲学进化。

1.1 / FIXED WINDOW
CRITICAL SPIKE
固定时间窗口(计数器)
将时间划分为固定区间(如 1s),每个窗口维护一个请求计数器,请求到来计数器自增,超过阈值直接拒绝。例如限制 1 秒最多 100 次请求,0~1s 窗口内计数上限 100。
✓ 实现最简单,内存开销极低
✕ 致命缺陷:临界突刺问题
SCENE简易低频风控
1.2 / SLIDING WINDOW
COMPLEX
滑动时间窗口
把大窗口切分为多个细小格子,每次请求向前滑动窗口,剔除过期时间段的请求计数。解决了固定窗口临界流量突刺问题。
✓ 解决固定窗口临界流量突刺问题
✕ 实现复杂度上升;无法平滑流量,依旧允许瞬时尖峰
SCENE简单 API 限流
1.3 / LEAKY BUCKET
NO BURST
漏桶算法(Leaky Bucket)
想象一个底部匀速漏水的水桶。请求作为水流持续进入桶内;桶以固定速率持续漏水(处理请求);桶满之后,新到来的请求直接丢弃。
✓ 强制限制流出速率,流量绝对平滑
✕ 无法应对突发流量
SCENE第三方接口调用
1.4 / TOKEN BUCKET
RECOMMENDED
令牌桶算法(Token Bucket)
系统按照固定速率向桶内投放令牌;每处理一次请求,必须消耗 1 枚令牌;桶存满后不再新增令牌。正常情况匀速处理,桶内积攒令牌时可一次性处理突发流量。
✓ 匀速处理 + 允许短时爆发(流量整形)
✓ 网关、秒杀系统首选
SCENE网关 / 秒杀 / Web 服务
⚠ CRITICAL SPIKE — 固定窗口致命缺陷
WINDOW 1 (0~1s) · MAX 100 WINDOW 2 (1~2s) · MAX 100 0.99s ~ 1.01s 200 2 倍上限流量涌入
窗口临界点(0.99s~1.01s),前窗口末尾 + 新窗口开头可以涌入 2 倍上限流量,极易击穿系统。这是固定窗口算法的致命伤。
HORIZONTAL COMPARISON
算法横向对比
限流算法 能否应对突发 流量特征 典型使用场景
固定窗口 ✕ 存在临界毛刺 波动大 简易低频风控
滑动窗口 ✕ 短时允许峰值 波动可控 简单 API 限流
漏桶算法 ✕ 严格匀速 绝对平滑 第三方接口调用
令牌桶算法 ✓ 支持流量爆发 基础匀速,允许突峰 网关 / 秒杀 / Web 服务
ENGINEERING CONSENSUS 互联网业务高并发场景,优先选择 令牌桶算法。
CHAPTER 02
02/05

漏桶 vs 令牌桶 可视化对比

两种桶算法的本质差异:漏桶强制匀速流出,不允许突发;令牌桶匀速生成令牌,积攒后可一次性消耗,允许短时爆发。

LEAKY BUCKET
漏桶算法 · 匀速流出
请求入桶 → 固定速率漏水 → 桶满丢弃
REQUESTS IN 水流入 OVERFLOW 桶满丢弃 FIXED RATE 匀速漏水
✕ 无法应对突发流量,桶满即丢弃
✓ 强制匀速流出,下游压力绝对稳定
→ 适用:第三方支付/短信接口严格限频
TOKEN BUCKET
令牌桶算法 · 允许突发
匀速生成令牌 → 请求消耗令牌 → 桶满停止
TOKEN GEN 匀速生成 CONSUME 请求消耗 BURST 允许突发 PASS 放行
✓ 正常匀速处理,积攒令牌后允许短时爆发
✓ 流量整形而非简单拒绝,用户体验更好
→ 适用:网关 / 秒杀 / Web 服务限流
CORE DIFFERENCE 漏桶关注流出速率(强制匀速),令牌桶关注令牌存量(允许积攒)。这就是网关、秒杀系统首选令牌桶的根本原因。
CHAPTER 03
03/05

单机本地限流 Go 实现

以下代码仅适用于单机单实例。集群多节点部署时,各个实例独立限流,无法实现全局流量管控,必须使用分布式限流。

3.1 / LEAKY BUCKET
漏桶简易实现
leaky_bucket.go — single instance
01
02
03
04
05
06
07
08
09
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
package main import ( "time" ) type LeakyBucket struct { capacity int // 桶最大容量 rate float64 // 漏水速率 每秒处理请求数 water float64 // 当前水量 lastTime time.Time } func NewLeakyBucket(capacity int, rate float64) *LeakyBucket { return &LeakyBucket{ capacity: capacity, rate: rate, lastTime: time.Now(), } } func (b *LeakyBucket) Allow() bool { now := time.Now() // 计算距离上次经过的时间,释放水量 duration := now.Sub(b.lastTime).Seconds() b.water -= duration * b.rate if b.water < 0 { b.water = 0 } b.lastTime = now if b.water < float64(b.capacity) { b.water++ return true } return false }
3.2 / TOKEN BUCKET
令牌桶简易实现
token_bucket.go — single instance
01
02
03
04
05
06
07
08
09
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
package main import ( "time" ) type TokenBucket struct { capacity int // 最大令牌数量 rate float64 // 每秒生成令牌数量 tokenNum float64 // 当前令牌 lastTime time.Time } func NewTokenBucket(capacity int, rate float64) *TokenBucket { return &TokenBucket{ capacity: capacity, rate: rate, tokenNum: float64(capacity), lastTime: time.Now(), } } func (t *TokenBucket) Allow() bool { now := time.Now() dur := now.Sub(t.lastTime).Seconds() // 生成新增令牌 t.tokenNum += dur * t.rate if t.tokenNum > float64(t.capacity) { t.tokenNum = float64(t.capacity) } t.lastTime = now if t.tokenNum >= 1 { t.tokenNum-- return true } return false }
⚠ ATTENTION 以上代码仅适用于单机单实例,集群多节点部署时,各个实例独立限流,无法实现全局流量管控,必须使用分布式限流。
CHAPTER 04
04/05

分布式限流 Redis + Lua 方案

分布式限流核心难点:多实例状态统一。使用 Redis 存储令牌桶状态,Lua 脚本保证整个令牌检查、消耗逻辑原子执行,避免并发竞态条件。

4.1 / LUA SCRIPT
Lua 脚本(redis_token_bucket.lua)

不推荐使用多个 Redis 命令分开执行,会产生并发漏洞。Lua 脚本在 Redis 内部单线程执行,整条命令具备原子性。

redis_token_bucket.lua — atomic execution
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- KEYS[1] 限流key -- ARGV[1] 桶最大容量 capacity -- ARGV[2] 每秒生成令牌速率 rate -- ARGV[3] 当前时间戳 now local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local data = redis.call('HMGET', key, 'tokens', 'last_time') local tokens = tonumber(data[1]) or capacity local lastTime = tonumber(data[2]) or now -- 计算时间差,补充令牌 local delta = now - lastTime local newTokens = tokens + delta * rate / 1000 if newTokens > capacity then newTokens = capacity end -- 判断是否足够令牌 if newTokens >= 1 then newTokens = newTokens - 1 redis.call('HMSET', key, 'tokens', newTokens, 'last_time', now) -- 设置过期时间,长期无访问自动清理key redis.call('EXPIRE', key, 600) return 1 -- 允许放行 else redis.call('HMSET', key, 'tokens', newTokens, 'last_time', now) return 0 -- 限流拒绝 end
4.2 / GO INTEGRATION
Go 语言调用 Redis Lua 分布式限流器
dist_token_bucket.go — production ready
01
02
03
04
05
06
07
08
09
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
44
45
46
47
48
49
50
51
52
53
54
55
56
package main import ( "context" "fmt" "github.com/go-redis/redis/v8" "time" ) var luaScript = redis.NewScript(` local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local data = redis.call('HMGET', key, 'tokens', 'last_time') local tokens = tonumber(data[1]) or capacity local lastTime = tonumber(data[2]) or now local delta = now - lastTime local newTokens = tokens + delta * rate / 1000 if newTokens > capacity then newTokens = capacity end if newTokens >= 1 then newTokens = newTokens - 1 redis.call('HMSET', key, 'tokens', newTokens, 'last_time', now) redis.call('EXPIRE', key, 600) return 1 else redis.call('HMSET', key, 'tokens', newTokens, 'last_time', now) return 0 end `) type DistTokenBucket struct { cli *redis.Client capacity int rate float64 } func NewDistTokenBucket(redisCli *redis.Client, capacity int, rate float64) *DistTokenBucket { return &DistTokenBucket{ cli: redisCli, capacity: capacity, rate: rate, } } func (tb *DistTokenBucket) Allow(ctx context.Context, limitKey string) (bool, error) { now := time.Now().UnixMilli() res, err := luaScript.Run(ctx, tb.cli, []string{limitKey}, tb.capacity, tb.rate, now).Int64() if err != nil { return false, err } return res == 1, nil } func main() { rdb := redis.NewClient(&redis.Options{ Addr: "127.0.0.1:6379", }) // 最大100令牌,每秒产生10个令牌 limiter := NewDistTokenBucket(rdb, 100, 10) allow, err := limiter.Allow(context.Background(), "api:limit:user_test") fmt.Println(allow, err) }
CHAPTER 05
05/05

线上落地 避坑指南

五类高频踩坑场景,每一类都对应过真实的线上故障。规避成本远低于复盘成本。

01
Redis 单点故障风险
RISK 分布式限流强依赖 Redis,如果 Redis 宕机,限流逻辑失效。 SOLUTION 建议配置降级策略:Redis 不可用时切换成本地单机限流,保障基础防护。
02
时钟漂移问题
RISK 多服务器时钟不一致会造成统计偏差,令牌补充计算错误。 SOLUTION 脚本内统一使用Redis 外部传入时间戳,不使用 Redis 服务端时间。
03
限流粒度选择
RISK 粒度选错,要么防护不足,要么误伤正常用户。 SOLUTION 支持多种粒度:接口全局限流、用户 ID 限流、IP 限流。对外开放接口推荐IP 粒度,业务内部接口采用用户 ID 粒度。
04
限流后的响应策略
RISK 直接断开连接会让前端无法区分"限流"与"服务异常"。 SOLUTION 拒绝请求时不要直接断开连接,标准返回 429 Too Many Requests,前端展示友好提示。
05
漏桶与令牌桶选型误区
RISK 选型混乱,导致突发流量击穿下游或严格限流误伤业务。 SOLUTION 秒杀、业务接口尽量使用令牌桶;调用第三方支付、短信接口严格使用漏桶,防止突发大量调用被服务商封禁。
SUMMARY
!/!

总结 限流不是拒绝,是整形

限流、熔断、降级共同构成高可用防护体系,限流作为第一道前置屏障,配合前文的秒杀系统架构,能够完整构筑抵御流量洪峰的防护链路。

01
单机小规模场景,直接使用内存令牌桶,无需引入 Redis。
02
集群分布式业务,采用 Redis + Lua 实现全局令牌桶限流,保证原子性。
03
追求绝对匀速、禁止突发流量场景,选用漏桶(第三方接口调用)。
04
所有限流组件务必配套监控:记录限流触发次数,及时发现恶意刷流量、业务流量突增异常。
结语 / EPILOGUE

限流不是简单拒绝流量,
而是 流量整形。

限流的本质不是把流量挡在门外,而是让流量以可控的速率进入系统——这就是"整形"二字的含义。令牌桶允许突发,是在用户体验与系统保护之间找到平衡点;漏桶强制匀速,是在下游承受能力与上游调用频率之间筑起防线。

限流、熔断、降级共同构成高可用防护体系。限流作为第一道前置屏障,配合前文的秒杀系统架构,能够完整构筑抵御流量洪峰的防护链路。掌握令牌桶与漏桶的工程实现,是设计高可用分布式系统的基础能力。