Redis 分布式限流的四大算法与终极形态


案发场景:

你们的开放平台对外提供了一个极其昂贵的 AI 算力 API。

为了防止被恶意白嫖,你规定:每个 API Key 每分钟最多只能调用 100 次

青铜防线(固定窗口的灾难):

你的初级研发写了一段逻辑:用 Redis 的 INCR 对 API Key 进行计数,并给这个 Key 设置 60 秒的过期时间。
黑客的绞杀: 黑客极其聪明,他在第 59 秒的时候,瞬间发起了 100 次请求(此时计数器从 0 到 100,放行)。

紧接着到了第 60 秒(下一分钟开始了),Redis 的 Key 过期清零。黑客在第 01 秒,又瞬间发起了 100 次请求!
结果: 在短短的 2 秒钟(59秒~01秒)内,你的系统实际上承受了 200 次 并发调用!这就是著名的**"临界点突刺"**灾难。原本设计的盾牌,被黑客利用时间差瞬间击穿。

为了补上这个致命漏洞,架构师们在 Redis 里上演了一场极其精彩的算法演进。


1. 白银防线:滑动窗口 (Sliding Window) 的精准绞杀

既然固定窗口有时间边界漏洞,那我们干脆把时间边界**"动起来"
我们只关心
"以当前时刻为起点的过去 60 秒内"**,总请求量有没有超标。

ZSet 的降维打击:

在 Redis 中,什么数据结构能同时记录"数据"和"时间"?ZSet(有序集合)

  • Score: 存入当前请求的毫秒级时间戳。
  • Member: 存入当前请求的 UUID(防止被去重)。
滑动执行四步曲(必须用 Lua 脚本保证原子性):

假设用户发起了一次请求(当前时间是 T):

  1. 清理过期垃圾: 执行 ZREMRANGEBYSCORE key 0 (T - 60秒)。把 60 秒之前的请求记录全部无情删掉。
  2. 统计当前数量: 执行 ZCARD key,获取当前 ZSet 里还剩下几个请求。
  3. 判断放行: 如果数量 < 100,说明没超标。
  4. 记录本次请求: 将本次请求的 (T, UUID) 放入 ZSet。放行!

致命缺陷:内存的黑洞

滑动窗口做到了 100% 的精准限流。

但是!如果你的限流规则不是"每分钟 100 次",而是"每小时 100 万次"。

那就意味着,你需要在一个 ZSet 里密集地存储 100 万个元素的 UUID 和时间戳!单单是为了给一个用户限流,Redis 就要消耗好几兆的内存。如果是百万活跃用户,Redis 会当场 OOM 暴毙。


2. 黄金防线:漏桶算法 (Leaky Bucket) 的平滑约束

为了解决 ZSet 的内存黑洞,老一辈的架构师搬出了网络工程领域经典的"漏桶算法"。

物理模型:

想象一个底部有个小洞的桶。

  • 用户的请求就像往桶里疯狂倒水(速度不可控,可能有突发洪峰)。
  • 桶底的小洞以绝对恒定的速度漏水(比如每秒处理 10 个请求)。
  • 如果倒水的速度太快,水溢出桶口,请求就被直接丢弃。

优点: 极其平滑,绝对不会把底层的数据库瞬间打死。
缺点: 它太死板了!互联网业务是有"突发流量 (Burst)"的。假设桶是空的,突然来了 50 个请求,系统明明有能力瞬间处理完,但漏桶非要以"每秒 10 个"的龟速慢慢漏,导致剩下 40 个请求全部排队超时,用户体验极差。


3. 王者防线:令牌桶算法 (Token Bucket) 的空间折叠魔法

这是目前包括 Google Guava、Spring Cloud Gateway 在内的所有顶级限流器的底层标配。

它既能限制长期平均速度,又能允许一定程度的突发流量 ,而且------内存占用趋近于 0!

物理模型:

  1. 令牌发放者: 以恒定的速度(比如每秒 10 个)向桶里发令牌。
  2. 桶的容量: 桶最多只能装 100 个令牌(允许的最大突发流量)。
  3. 请求放行: 每个请求过来,必须从桶里拿走 1 个令牌。拿到就放行,拿不到(桶空了)就拒绝。
Redis 极客落地:用数学消灭定时任务

如果在 Redis 里真的搞一个定时任务,每秒往几百万个用户的桶里 INCR 发令牌,Redis 的 CPU 会瞬间熔断。

数学折叠魔法:

我们根本不需要真的发令牌!我们只需要在 Redis 的 Hash 结构里记录两个值:

  • last_refill_time:上次补充令牌的时间戳。
  • current_tokens:当时的令牌余量。

当一个新的请求在时间 T 到来时,我们通过一段极其优美的数学公式,现场计算 出当前应该有多少令牌:
当前令牌 = current_tokens + (T - last_refill_time) * 发放速率

算出当前令牌后,减去 1,再把新的 T 和余量写回 Redis 即可。
全程只需要记录 2 个字段,不论流量多大,内存永远定死在几十个字节!这是人类数学对物理存储的又一次终极降维打击。


4. 代码落地:Spring Boot + Lua 令牌桶实战

下面是一套可以直接抄进生产环境的分布式令牌桶 Lua 脚本及调用代码。

Lua 脚本 (rate_limiter.lua):

lua 复制代码
-- KEYS[1]: 限流的 Key (如 rate:limit:api_key)
-- ARGV[1]: 桶的容量 (允许的最大突发流量,如 100)
-- ARGV[2]: 令牌发放速率 (每秒发几个,如 10)
-- ARGV[3]: 当前请求的时间戳 (秒)

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 获取当前 Redis 中记录的值
local bucket = redis.call('HMGET', key, 'last_time', 'current_tokens')
local last_time = tonumber(bucket[1]) or now
local current_tokens = tonumber(bucket[2]) or capacity

-- 计算距离上次请求过去了多少秒
local delta_time = math.max(0, now - last_time)

-- 现场推算当前令牌数:过去的令牌 + 过去这段时间应该生成的令牌
local new_tokens = math.min(capacity, current_tokens + (delta_time * rate))

local allowed = 0
if new_tokens >= 1 then
    -- 令牌足够,扣减 1 个,允许通行!
    new_tokens = new_tokens - 1
    allowed = 1
end

-- 将最新的状态写回 Redis,并设置一个过期时间兜底 (防止死数据堆积)
redis.call('HMSET', key, 'last_time', now, 'current_tokens', new_tokens)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 10)

return allowed

Java 调用层:

java 复制代码
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Collections;

@Service
public class RateLimitService {

    private final StringRedisTemplate redisTemplate;
    private final DefaultRedisScript<Long> limitScript;

    public RateLimitService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        // 提前将 lua 脚本加载为 Bean,避免每次读取文件
        this.limitScript = new DefaultRedisScript<>();
        this.limitScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("rate_limiter.lua")));
        this.limitScript.setResultType(Long.class);
    }

    /**
     * 核心校验方法
     */
    public boolean tryAcquire(String apiKey) {
        String key = "rate:limit:" + apiKey;
        int capacity = 100;   // 桶容量:最多允许 100 并发
        int rate = 10;        // 生成速率:每秒恢复 10 个令牌
        long now = Instant.now().getEpochSecond(); // 由 Java 客户端传入时间,绝对安全

        // 执行 Lua 脚本,保证读取-计算-写入的绝对原子性
        Long result = redisTemplate.execute(limitScript, Collections.singletonList(key),
                String.valueOf(capacity),
                String.valueOf(rate),
                String.valueOf(now));

        // 返回 1 表示放行,0 表示限流丢弃
        return result != null && result == 1L;
    }
}

5. 架构师的避坑底线:时间的诅咒

细心的读者会发现,上面的 Lua 脚本中,当前时间 now由 Java 客户端通过 ARGV[3] 传进去的 ,而不是在 Lua 脚本里通过 Redis 函数 redis.call('TIME') 获取的。

为什么要脱裤子放屁?

这涉及到 Redis 脚本极其底层的安全机制:脚本纯函数性 (Pure Functions)

在 Redis 3.2 到 4.0 的年代,Redis 要求所有的 Lua 脚本必须是"纯函数"。如果你在脚本里调用了 TIME(随机时间)或者 RANDOMKEY,Redis 会认为你的脚本是不可被确定性复制的(如果主库和从库执行同一个带有随机时间的脚本,会导致主从数据不一致)。

此时如果你在脚本里写数据,Redis 会直接抛出写错误!

虽然后续版本 Redis 引入了 redis.replicate_commands()(复制效果而非复制命令)解决了这个问题,但将时间生成权交给客户端,依然是目前业界最稳妥、向下兼容性最好的标配做法。


总结

INCR 的固定窗口,到 ZSet 的滑动窗口,再到数学逻辑极度紧凑的令牌桶。

分布式限流的演进,本质上是在**"请求精准度""内存空间消耗"**之间寻找极限平衡的过程。

在这个充满恶意攻击和突发流量的互联网黑暗森林中,没有限流的系统就像一台没有刹车的跑车。

而掌握了基于 Redis Lua 的令牌桶魔法,你就在网关的最前线,立起了一面永远不会被轻易击穿的叹息之墙。

相关推荐
NoSi EFUL1 小时前
学生成绩管理系统(MySQL)
android·数据库·mysql
Yeats_Liao1 小时前
Trae 配置 MySQL MCP 指南
数据库·mysql
Polar__Star1 小时前
SQL如何高效导出大规模的分组汇总数据_利用分页与索引
jvm·数据库·python
富士康质检员张全蛋1 小时前
Kafka架构 主题中的分区
分布式·kafka
2201_761040591 小时前
HTML怎么显示复杂图表摘要_HTML数据结论文字描述区【详解】
jvm·数据库·python
m0_746752301 小时前
HTML怎么标注回收估价规则_HTML估价逻辑说明折叠区【指南】
jvm·数据库·python
Greyson11 小时前
SQL如何解决GROUP BY导致查询变慢_利用覆盖索引进行优化
jvm·数据库·python
AllData公司负责人2 小时前
AllData数据中台通过开源项目RustFS建设现代数据湖存储,接入工业, 医疗, 物联网数据,包括文件/图像/音频/视频数据!
数据库·数据仓库·物联网·开源·数据存储·数据接入·rustfs
m0_613856292 小时前
html标签如何插入图片_html中img标签的正确使用方式【方法】
jvm·数据库·python