Redis 限流实现:INCR、滑动窗口、漏桶与令牌桶对比

限流是 Redis 最常见的使用场景之一。无论是保护 API 免受滥用,还是限制后台任务的执行频率,Redis 都提供了构建限流的基础原语。本指南用真实命令讲解四种算法。

问题场景

没有限流,单个用户或机器人可以:

  • 耗尽你的 API 服务器资源。
  • 每秒触发数千次昂贵的数据库查询。
  • 因失控的进程导致云账单暴涨。

算法一:固定窗口计数器(INCR + EXPIRE)

最简单的方案。在固定时间窗口内计数请求。

原理

  1. 使用类似 ratelimit:<user_id>:<window> 的 Key。
  2. 每次请求 INCR 计数器。
  3. 如果计数器超过限制,拒绝请求。
  4. 在第一次请求时用 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 个请求。滑动窗口可以解决这个问题。

算法二:滑动窗口日志

用有序集合记录每个请求的时间戳。实现精确的每秒限流。

原理

  1. 将当前时间戳作为分数和成员添加到 ZSET。
  2. 移除窗口之外的旧条目。
  3. 统计剩余条目数。
  4. 如果超过限制,拒绝。

命令实战

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 都使用这种方式。

对比表

算法精度内存突发处理复杂度
固定窗口极低允许边界突发简单
滑动日志无突发中等
滑动计数器最小突发中等
漏桶平滑突发
令牌桶允许可控突发

常见坑点

  1. 竞态条件:始终使用 MULTI/EXEC 或 Lua 脚本保证原子操作。
  2. 时钟偏移:在分布式系统中,使用 Redis 服务器时间(TIME 命令)而非客户端时间。
  3. 内存泄漏:始终给限流 Key 设置 TTL。不活跃用户的遗留 Key 会不断累积。
  4. 过度设计:从固定窗口(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 方案感受滑动窗口的精度。