Redis 限流实现:INCR、滑动窗口、漏桶与令牌桶对比
限流是 Redis 最常见的使用场景之一。无论是保护 API 免受滥用,还是限制后台任务的执行频率,Redis 都提供了构建限流的基础原语。本指南用真实命令讲解四种算法。
问题场景
没有限流,单个用户或机器人可以:
- 耗尽你的 API 服务器资源。
- 每秒触发数千次昂贵的数据库查询。
- 因失控的进程导致云账单暴涨。
算法一:固定窗口计数器(INCR + EXPIRE)
最简单的方案。在固定时间窗口内计数请求。
原理
- 使用类似
ratelimit:<user_id>:<window>的 Key。 - 每次请求
INCR计数器。 - 如果计数器超过限制,拒绝请求。
- 在第一次请求时用
EXPIRE定义窗口。
命令实战
SET ratelimit:user:1001:window "0" EX 60
INCR ratelimit:user:1001:window
INCR ratelimit:user:1001:window
INCR ratelimit:user:1001:window
GET ratelimit:user:1001:window
TTL ratelimit:user:1001:window
原子版本(MULTI)
避免 INCR 和 EXPIRE 之间的竞态条件:
MULTI
INCR ratelimit:user:1001:min
EXPIRE ratelimit:user:1001:min 60
EXEC
坑点:边界突发
用户可以在窗口 1 的第 59 秒发送 100 个请求,在窗口 2 的第 0 秒再发 100 个——实际上 2 秒内发了 200 个请求。滑动窗口可以解决这个问题。
算法二:滑动窗口日志
用有序集合记录每个请求的时间戳。实现精确的每秒限流。
原理
- 将当前时间戳作为分数和成员添加到 ZSET。
- 移除窗口之外的旧条目。
- 统计剩余条目数。
- 如果超过限制,拒绝。
命令实战
ZADD ratelimit:user:1001:sliding 1739600001 "req:1739600001"
ZADD ratelimit:user:1001:sliding 1739600002 "req:1739600002"
ZADD ratelimit:user:1001:sliding 1739600030 "req:1739600030"
ZREMRANGEBYSCORE ratelimit:user:1001:sliding 0 1739599940
ZCARD ratelimit:user:1001:sliding
坑点
内存使用量随请求量增长。每个请求都在 ZSET 中存储一个成员。对于高流量端点,这会消耗大量内存。给 ZSET Key 设置 TTL 作为安全网:
EXPIRE ratelimit:user:1001:sliding 120
算法三:滑动窗口计数器(混合方案)
固定窗口和滑动窗口日志之间的内存高效折中方案。使用两个固定窗口进行插值。
原理
假设 60 秒窗口,限制 100 个请求:
- 当前窗口计数:40(20 秒前开始)
- 上一窗口计数:80
加权计数 = 上一窗口 × ((窗口大小 - 已过时间) / 窗口大小) + 当前窗口
= 80 × (40/60) + 40 = 93.3
因为 93.3 < 100,请求被允许。
命令实战
SET ratelimit:user:1001:prev "80" EX 120
SET ratelimit:user:1001:curr "0" EX 60
INCR ratelimit:user:1001:curr
INCR ratelimit:user:1001:curr
GET ratelimit:user:1001:prev
GET ratelimit:user:1001:curr
插值计算在应用代码中完成。Redis 只存储计数器。
算法四:漏桶
请求进入一个"桶",以固定速率被处理。如果桶满了,新请求被拒绝。
原理
将桶建模为一个随时间递减的计数器:
- 每个请求递增计数器。
- 后台进程(或惰性求值)以固定速率递减。
- 如果计数器 > 桶容量,拒绝。
命令实战(简化版)
实际中漏桶用 Lua 脚本实现以保证原子性。这里是概念模型:
SET bucket:user:1001:level "0"
SET bucket:user:1001:last_check "1739600000"
INCR bucket:user:1001:level
GET bucket:user:1001:level
泄漏计算在应用代码或 Lua 脚本中:
leaked = (now - last_check) * leak_rate
new_level = max(0, current_level - leaked) + 1
if new_level > capacity: reject
适用场景
最适合将突发流量平滑为稳定流。常用于 API 网关。
算法五:令牌桶
令牌以固定速率添加到桶中。每个请求消耗一个令牌。如果没有可用令牌,请求被拒绝。
原理
- 桶初始满载(如 10 个令牌)。
- 每个请求移除一个令牌。
- 令牌以固定速率补充(如每秒 1 个)。
- 允许短时间突发,最多到桶容量。
命令实战
SET bucket:user:1001:tokens "10"
SET bucket:user:1001:last_refill "1739600000"
DECR bucket:user:1001:tokens
GET bucket:user:1001:tokens
补充逻辑(在应用或 Lua 中):
elapsed = now - last_refill
new_tokens = min(capacity, tokens + elapsed * refill_rate)
if new_tokens < 1: reject
else: new_tokens -= 1
适用场景
最适合允许突发但强制平均速率的场景。AWS、Stripe 和大多数云 API 都使用这种方式。
对比表
| 算法 | 精度 | 内存 | 突发处理 | 复杂度 |
|---|---|---|---|---|
| 固定窗口 | 低 | 极低 | 允许边界突发 | 简单 |
| 滑动日志 | 高 | 高 | 无突发 | 中等 |
| 滑动计数器 | 中 | 低 | 最小突发 | 中等 |
| 漏桶 | 高 | 低 | 平滑突发 | 高 |
| 令牌桶 | 高 | 低 | 允许可控突发 | 高 |
常见坑点
- 竞态条件:始终使用
MULTI/EXEC或 Lua 脚本保证原子操作。 - 时钟偏移:在分布式系统中,使用 Redis 服务器时间(
TIME命令)而非客户端时间。 - 内存泄漏:始终给限流 Key 设置 TTL。不活跃用户的遗留 Key 会不断累积。
- 过度设计:从固定窗口(INCR + EXPIRE)开始。只有当你真正遇到边界突发问题时,才升级到滑动窗口或令牌桶。
在线编辑器试一试
前往 Redis 在线编辑器 实验:
SET ratelimit:user:1001:window "0" EX 60
INCR ratelimit:user:1001:window
INCR ratelimit:user:1001:window
INCR ratelimit:user:1001:window
GET ratelimit:user:1001:window
TTL ratelimit:user:1001:window
ZADD ratelimit:sliding 100 "req:1" 200 "req:2" 300 "req:3"
ZCARD ratelimit:sliding
ZRANGEBYSCORE ratelimit:sliding 150 +inf
试着把计数器递增到超过限制,看看逻辑是怎么工作的。然后试试 ZSET 方案感受滑动窗口的精度。