分布式限流算法:令牌桶、漏桶与 Redis 实现
· 819 words · ~ 4 min read
单机限流并不难,难的是把限流状态放到多台应用实例之间共享,而且还要保证判断和更新是原子的。
这篇文章只讲两种最常见的限流算法:
- 令牌桶
token bucket - 漏桶
leaky bucket
重点放在三件事:
- 两种算法的流程
- 它们在工程行为上的区别
- 用 Redis 时适合选择哪些数据类型
多个应用实例共享 Redis 里的限流状态,请求是否放行取决于当前桶状态和一次原子更新的结果。
1. 先看两个算法的区别
令牌桶
令牌桶的核心规则可以直接看流程:
flowchart TD
A["请求到来"] --> B["读取桶状态<br/>tokens / last_refill"]
B --> C["按时间补充令牌"]
C --> D["令牌数是否超过 capacity"]
D -->|是| E["截断到 capacity"]
D -->|否| F["保留当前值"]
E --> G{"tokens >= 1 ?"}
F --> G
G -->|是| H["扣减 1 个令牌"]
H --> I["放行请求"]
G -->|否| J["拒绝请求"]
它的行为特征是:
- 令牌按恒定速率补充
- 桶里可以积累额度,所以允许短时间突发
- 长期平均速率仍然受控
例如 capacity = 100、refill = 10/s 时:
- 最多积累 100 个令牌
- 长期平均速率约 10 次/秒
- 空闲一段时间后,可以一次性放过更多请求
这里还有一个实现细节经常会让人困惑:令牌到底从什么时候开始放。
工程实现里通常不会真的起一个后台线程持续往桶里塞令牌,而是用“懒补充”:
- 桶初始化时记录一个时间基准,比如
last_refill = now - 后续每次请求到来时,用
now - last_refill计算这段时间理论上该补多少令牌 - 然后再更新
tokens和last_refill
初始化语义一般有两种:
- 初始满桶:
tokens = capacity,最常见,系统一开始允许一次 burst - 初始空桶:
tokens = 0,少见,之后再按速率慢慢积累
很多 Redis 实现还会采用“首次访问时初始化”的方式:
- 如果这个桶的 key 还不存在,就在第一次请求时把它视为新桶
- 然后把
last_refill设为当前时间 - 是否满桶还是空桶,取决于你的策略选择
漏桶
漏桶的核心流程是:
flowchart TD
A["请求到来"] --> B{"桶是否已满"}
B -->|否| C["请求进入桶"]
C --> D["按固定速率出桶"]
D --> E["下游处理"]
B -->|是| F["拒绝请求<br/>或进入等待队列"]
它的行为特征是:
- 入口流量可以有抖动
- 桶负责吸收这部分抖动
- 出口速率更平滑
- 下游看到的是更稳定的流量
一句话对比
可以直接记:
- 令牌桶:控制平均速率,允许突发,更像配额系统
- 漏桶:平滑输出速率,不鼓励突发,更像排队整形
场景上通常是:
- API 网关、登录、短信验证码,更适合先考虑令牌桶
- 任务消费、消息投递、下游保护,更适合先考虑漏桶
2. Redis 里该用什么数据类型
Redis 里这几种数据结构在这里最常见的用法是:
String:简单值、计数器、单状态时间戳Hash:一个 key 下保存多个状态字段Sorted Set:成员有序,适合按时间维护请求集合Stream:追加式事件流,适合排队和消费
先给结论:
- 令牌桶通常用
Hash + Lua - 漏桶如果是同步拒绝式,通常优先
String/Hash + Lua - 漏桶如果需要显式维护积压集合,可以用
Sorted Set - 漏桶如果是异步排队式,通常用
Stream
令牌桶:优先用 Hash
最常见的令牌桶状态只有两个:
tokenslast_refill
为什么优先用 Hash,而不是多个独立 String:
- 一个 key 下管理多个字段,状态更集中
- 更适合在 Lua 里一次性原子更新
- 多个
String也能实现,但状态分散,更容易引入一致性问题
漏桶:常见有两种实现
漏桶在工程里常见分成两类:
1. 同步拒绝式
这种实现不真的排队处理请求,而是维护一个“计划出桶时间”或“当前桶内积压”。
更常见的做法是只维护一个标量状态,比如 next_allowed_time:
flowchart TD
A["请求到来"] --> B["读取 next_allowed_time"]
B --> C{"是否已经到可服务时间"}
C -->|是| D["更新 next_allowed_time = now + interval"]
D --> E["放行"]
C -->|否| F["拒绝或返回 wait_ms"]
这种做法的优点是:
- 状态很小
- Lua 原子更新直接
- 更接近经典漏桶状态机
如果你希望显式维护“当前积压请求集合”,也可以用 Sorted Set:
flowchart TD
A["请求到来"] --> B["ZSET 中删除已漏出的旧请求"]
B --> C["统计当前剩余成员数"]
C --> D{"是否小于桶容量"}
D -->|是| E["ZADD 新请求"]
E --> F["放行"]
D -->|否| G["拒绝"]
这里通常会把:
score设计成时间戳member设计成请求 ID- 再按时间清理旧请求并统计当前积压量
这个做法是可行的工程建模,但它更像“按时间维护积压请求集合”,不是唯一的漏桶落地方式。
2. 真的要排队并匀速消费
这种实现更像一个平滑队列:
- 请求先进入一个缓冲区
- 后台 worker 按固定速率取走
这时候更适合:
- 用
Stream - 或者更简单的
List
如果需要:
- 保留事件顺序
- 有多个消费者
- 管理消费进度
那 Stream 比 List 更合适。
所以对漏桶,不要只记“Redis 用什么类型”,而要先问:
- 你是想做“拒绝式限流”?
- 还是想做“排队式整形”?
两者对应的数据结构不一样。
3. 用 Redis 实现令牌桶
Redis 官方已经给过一个标准思路:用 Lua 脚本实现 token bucket rate limiter。核心就是把状态放进 Hash,然后在脚本里原子完成整个计算过程。
状态设计
key 设计示例:
|
|
value 用 Hash:
|
|
也就是一个 key 下的两个字段:
tokenslast_refill
核心流程
flowchart TD
A["请求到来"] --> B["HMGET tokens / last_refill"]
B --> C["计算应补充的令牌数"]
C --> D["按 capacity 截断"]
D --> E{"tokens >= 1 ?"}
E -->|是| F["扣减 1 个令牌"]
F --> G["HMSET 写回新状态"]
G --> H["放行"]
E -->|否| I["HMSET 写回新状态"]
I --> J["拒绝"]
为什么必须原子
flowchart LR
A["请求 1"] --> B["读到 tokens = 1"]
C["请求 2"] --> D["也读到 tokens = 1"]
B --> E["判断可放行"]
D --> F["判断也可放行"]
E --> G["结果:实际放过 2 个请求"]
F --> G
所以分布式限流里,原子性不是优化项,而是正确性前提。
一个简化版 Lua 逻辑
下面这个脚本不是完整生产代码,但结构和 Redis 官方示例一致。
它特意省略了一些工程细节,只保留核心思路:
- 默认每次请求只消费
1个 token - 用离散步进的方式补充 token
- 没处理 key 过期和状态清理
- 没返回
retry_after之类的等待信息 - 所有时间参数统一使用毫秒
ARGV[2]表示“每个补充周期补充多少 token”,不是“每秒补充多少”
|
|
令牌桶适合哪些 Redis 类型
令牌桶在 Redis 里通常是:
Hash:保存tokens和last_refillLua或 Redis Functions:保证读、算、写是原子的String:只适合非常简单的状态设计
4. 用 Redis 实现漏桶
漏桶比令牌桶更容易写成两种完全不同的系统。
方案一:用单状态时间戳做“拒绝式漏桶”
如果目标是同步拒绝,不想真的排队,通常没必要把每个请求都单独存下来。
更直接的做法是维护一个“下次可服务时间”:
状态通常就是一个值:
next_allowed_time_ms
每次请求到来时:
- 读取当前
next_allowed_time - 如果它早于
now,就把它拉到now - 如果当前请求要求“立即判断”,且
next_allowed_time > now,则拒绝 - 否则把
next_allowed_time往后推进一个或多个interval - 返回是否放行,或者需要等待到什么时候
这个模型的特点是:
- 优点:状态小,Lua 原子更新简单,更适合同步限流
- 缺点:不直接保存请求明细,也不适合做真实排队
方案二:用 Sorted Set 做“积压集合模型”
如果你除了“能不能放行”之外,还想显式观察当前积压,或者要按时间管理一批待漏出的请求,可以用 Sorted Set。
状态通常是一个 Sorted Set:
score = 进入时间或计划出桶时间member = 请求唯一 ID
每次请求到来时:
- 根据当前时间和漏出速率,计算理论上已经漏掉多少请求
- 删除已经漏掉的旧成员
- 看当前
ZSET里还剩多少成员 - 如果剩余数量小于桶容量,就把当前请求加进去
- 否则拒绝
这个模型的特点是:
- 优点:容易看出当前积压,方便按
score清理旧元素,也能保留请求级别信息 - 缺点:每个请求都要维护成员,内存成本更高,大流量下清理更重
方案三:用 Stream 做“排队式漏桶”
如果目标不是立刻拒绝,而是先接收、再匀速处理,那就更像一个真正的漏桶队列。
flowchart LR
A["请求到来"] --> B["XADD 到 Stream"]
B --> C["Stream 中形成积压"]
C --> D["Worker 按固定速率 XREADGROUP"]
D --> E["匀速处理下游任务"]
这个方案的语义更接近真正的漏桶队列:
- 天然保留顺序
- 支持多个消费者
- 可以管理消费进度
- 但它也更像“流控 + 队列”
漏桶适合哪些 Redis 类型
可以直接记成:
- 同步拒绝式:优先
String / Hash,保存next_allowed_time,用 Lua 原子推进时间 - 同步拒绝式但需要显式积压:可以用
Sorted Set - 异步排队式:优先
Stream - 只是简单队列:也可以用
List,但能力弱于Stream
5. 该怎么选
如果只想做 Web API 限流,可以按这个思路判断:
flowchart TD
A["开始选型"] --> B{"是否允许一定突发"}
B -->|是| C["令牌桶"]
B -->|否| D{"是否需要排队整形"}
D -->|是| E["漏桶"]
D -->|否| F["也可以继续评估令牌桶或窗口类算法"]
选令牌桶
典型场景:
- API 网关
- 登录接口
- 短信验证码
- 允许一点突发的同步请求
落地方式通常是:
Hash存状态Lua或 Redis Functions 做原子更新
选漏桶
典型场景:
- 下游系统容量固定
- 希望把流量打平
- 请求可以排队或延迟处理
落地方式通常是:
- 同步拒绝式:
String / Hash - 需要显式积压时:
Sorted Set - 异步排队式:
Stream
工程上再补一句
很多系统嘴上说自己用“漏桶”,最后实现出来其实更像别的模型。判断时重点看这几件事:
- 是否允许突发
- 是直接拒绝还是排队
- 状态保存的是剩余额度,还是积压请求
6. 一个实用的选型建议
默认方案
- 对外同步 API 限流:令牌桶
- Redis 类型:
Hash - 并发正确性:
EVAL或 Redis Functions
如果你明确要“严格匀速,不允许突发”的同步限流,漏桶通常优先考虑:
String或Hash保存next_allowed_time- 用 Lua 原子判断和推进时间
- 只有在你确实需要“显式积压集合”时,再考虑
Sorted Set
默认先选令牌桶,通常是因为:
- 状态小
- 逻辑直接
- 允许合理突发
- 实现和运维复杂度较低
什么时候用 Sorted Set
适合 Sorted Set 的情况:
- 想按时间维护请求
- 想显式知道当前积压
- 更关注请求集合的时间序行为
什么时候用 Stream
适合 Stream 的情况:
- 任务整形
- 异步削峰
- 顺序消费
- 多 worker 平滑处理
7. 总结
可以直接记住这几条:
- 令牌桶:控制平均速率,允许突发
- 漏桶:平滑输出速率
- 分布式限流的关键是原子更新
- 令牌桶常见实现:
Hash + Lua - 漏桶常见实现:
String/Hash + Lua,或Sorted Set / Stream - 默认优先从令牌桶开始