Redis+Lua脚本实现限流

发布于:2025-02-19 ⋅ 阅读:(19) ⋅ 点赞:(0)

我们常见的组件中的限流原理底层有些是通过Redis+Lua脚本实现限流的,所以本文介绍一下如何使用Redis+Lua脚本实现常见的限流算法,例如固定窗口限流,互动窗口限流,漏桶限流,以及我们的Redisson中内置的令牌桶限流

这里以 Spring Cloud Gateway 为例,它是 Spring Cloud 生态系统中的 API 网关

限流原理:

Redis + Lua 脚本限流:Spring Cloud Gateway 可以集成 Spring Cloud Gateway Redis Rate Limiter 来实现限流,其底层就是基于 Redis + Lua 脚本。通过 Lua 脚本在 Redis 中原子性地执行限流逻辑,利用 Redis 的有序集合(ZSET)和字符串等数据结构来存储请求信息和统计数据。例如,使用有序集合存储请求的时间戳,根据时间间隔和预设的速率计算是否允许新的请求。这种方式利用了 Redis 的高性能和 Lua 脚本的原子性,能够在高并发场景下保证限流的准确性和一致性。

其他限流方式:除了 Redis + Lua 脚本,Spring Cloud Gateway 还可以通过自定义过滤器实现不同的限流策略。例如,可以基于内存中的计数器进行简单的单机限流,或者集成其他限流组件来实现限流功能


Redis自增逻辑+Lua脚本实现固定窗口计数法

使用Lua脚本保证原子性

通过执行Lua脚本在Redis中原子地处理计数器的递增和过期时间设置:

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('GET', key)

if current == false then
    redis.call('INCR', key)
    redis.call('EXPIRE', key, window)
    return 1
elseif tonumber(current) < limit then
    return redis.call('INCR', key)
else
    return -1
end

脚本说明:

  • KEYS[1]: 计数器的键名(如rate_limit:api1)。
  • ARGV[1]: 允许的最大请求数(如100)。
  • ARGV[2]: 时间窗口的秒数(如60)。
  • 首次请求时创建计数器并设置过期时间。
  • 后续请求递增计数器,超过限制则返回-1。

Lua脚本逻辑解释

我们有个固定时间,和这个时间内最高的请求次数(我们限制的次数)

如果我们这个计数窗口不存在,我们设置窗口和过期时间

if current == false then
    redis.call('INCR', key)
    redis.call('EXPIRE', key, window)
    return 1

如果我们这个窗口没超时且没到最高请求次数,我们的计数器自增

elseif tonumber(current) < limit then
return redis.call('INCR', key)

如果上面都不满足,说明到了窗口的最高请求次数,我们返回-1表示请求错误

else
return -1

Redisson客户端调用Lua脚本

在Java代码中,通过Redisson客户端执行上述脚本:

RScript script = redisson.getScript();
Long result = script.eval(RScript.Mode.READ_WRITE,
    "lua_script_above", // Lua脚本内容
    RScript.ReturnType.INTEGER,
    Collections.singletonList("rate_limit:api1"),
    100, 60);

if (result != null && result > 100) {
    // 触发限流
} else {
    // 允许执行
}

Zset+Lua脚本实现滑动窗口计数法

一、滑动窗口核心原理

  1. 时间窗口划分:将时间轴划分为多个小窗口(例如:1分钟分为6个10秒的窗口)。
  2. 动态滑动:每次请求时,删除过期窗口,仅保留最近时间范围内的窗口。
  3. 计数统计:统计当前存活窗口内的总请求数,判断是否超过阈值。

二、Redis数据结构设计

键名sliding_window:api1(自定义前缀+业务标识)

数据类型:有序集合(ZSET)

成员(Member):时间戳(精确到毫秒或秒,确保唯一性)

分数(Score):同成员值(用于范围删除)

过期时间:略大于窗口时间,由 Redis 自动清理过期数据

三、Lua脚本实现(原子操作)

-- KEYS[1]: 限流的键名(如 sliding_window:api1)
-- ARGV[1]: 当前时间戳(毫秒,如 tonumber(redis.call('TIME')[1])*1000)
-- ARGV[2]: 窗口大小(毫秒,如 60000 表示60秒)
-- ARGV[3]: 限流阈值(如100)

-- 删除过期的窗口数据(ARGV[1] - ARGV[2] 之前的成员)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])

-- 获取当前窗口内的请求总数
local current = redis.call('ZCARD', KEYS[1])

if current < tonumber(ARGV[3]) then
    -- 未超限:记录当前请求的时间戳
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])
    -- 设置键过期时间(窗口时间 + 1秒,避免残留)
    redis.call('PEXPIRE', KEYS[1], ARGV[2] + 1000)
    return 1  -- 允许请求
else
    return 0  -- 触发限流
end

脚本逻辑说明

  1. 动态清理过期数据:通过 ZREMRANGEBYSCORE 删除窗口外的旧数据。
  2. 计数统计:使用 ZCARD 获取当前窗口内的请求总数。
  3. 阈值判断:未超限时记录当前时间戳到 ZSET,并刷新过期时间

脚本参数说明

KEYS[1]:限流的键名,例如 sliding_window:api1,用于标识需要进行限流的资源或接口。在 Redis 中,该键是一个有序集合(Sorted Set),其中每个成员代表一个请求的时间戳。

ARGV[1]:当前时间戳,单位为毫秒。可以通过 tonumber(redis.call('TIME')[1])*1000 获取当前时间的毫秒数。

ARGV[2]:滑动窗口的大小,单位为毫秒。例如,60000 表示一个 60 秒的滑动窗口。

ARGV[3]:限流阈值,即允许在滑动窗口内通过的最大请求数量。例如,100 表示在一个滑动窗口内最多允许 100 个请求通过

Lua脚本逻辑解释

我们把我们的请求的时间戳存到Zset里面当做请求的唯一标识,然后我们会把小于滑动窗口过期时间的Zset里面的时间戳移出滑动窗口,这样子我们就实现了滑动窗口算法

1. 删除过期的窗口数据

redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
  • ZREMRANGEBYSCORE 是 Redis 的一个命令,用于从有序集合中移除指定分数范围的成员。
  • 这里的分数代表请求的时间戳,通过 ARGV[1] - ARGV[2] 计算出滑动窗口的起始时间戳。
  • 该命令会移除有序集合中时间戳小于 ARGV[1] - ARGV[2] 的所有成员,从而保证有序集合中只包含当前滑动窗口内的请求记录

2. 获取当前窗口内的请求总数

local current = redis.call('ZCARD', KEYS[1])
  • ZCARD 是 Redis 的一个命令,用于获取有序集合中的成员数量。
  • 通过该命令可以得到当前滑动窗口内的请求总数,并将其赋值给变量 current

3. 判断是否超限并进行相应处理

if current < tonumber(ARGV[3]) then
    -- 未超限:记录当前请求的时间戳
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])
    -- 设置键过期时间(窗口时间 + 1秒,避免残留)
    redis.call('PEXPIRE', KEYS[1], ARGV[2] + 1000)
    return 1  -- 允许请求
else
    return 0  -- 触发限流
end
未超限情况(current < tonumber(ARGV[3])

ZADD 命令用于向有序集合中添加一个或多个成员。这里将当前请求的时间戳 ARGV[1] 作为成员和分数添加到有序集合 KEYS[1] 中,以记录该请求。

PEXPIRE 命令用于为键设置过期时间,单位为毫秒。设置过期时间为 ARGV[2] + 1000 毫秒,即在滑动窗口时间的基础上增加 1 秒,避免有序集合中残留过期数据。

最后返回 1 表示允许当前请求通过


为什么要设置过期时间

在基于滑动窗口算法的限流场景下,使用 Redis 的有序集合来存储请求的时间戳。随着时间的推移,一些请求记录会因为超出滑动窗口的范围而变得 “过期”

虽然在每次请求时会通过 ZREMRANGEBYSCORE 命令删除这些过期的成员,但如果没有设置键的过期时间,即使有序集合中的所有成员都过期被删除了,这个有序集合键本身依然会存在于 Redis 中,造成不必要的内存占用


为什么要在滑动窗口时间基础上增加 1 秒

设置过期时间为 ARGV[2] + 1000 毫秒(即滑动窗口时间加上 1 秒),主要是为了避免出现残留过期数据的情况,下面通过具体例子来说明:

假设滑动窗口大小 ARGV[2] 是 60000 毫秒(也就是 60 秒),当一个请求在第 0 秒到来时,会将该请求的时间戳(0 毫秒)存入有序集合,同时会给这个有序集合设置一个过期时间为 61000 毫秒(60000 + 1000)

在第 60 秒时,会执行 ZREMRANGEBYSCORE 命令删除 0 到(60000 - 60000)即 0 毫秒之前的成员(这里就是删除 0 毫秒这个成员)。如果没有额外增加 1 秒的过期时间,此时有序集合里已经没有成员了,但在第 60 秒刚过,可能会有新的请求到来,而这个时候由于过期时间刚好到了,有序集合可能已经被 Redis 删除,那么新请求到来时就无法正确统计当前窗口内的请求数量

而增加 1 秒的过期时间后,即使在第 60 秒删除了过期成员,在接下来的 1 秒内,有序集合依然存在,新的请求可以正常地添加到有序集合中并进行限流判断,等过了 61 秒,有序集合会自动被 Redis 删除,这样就既保证了数据的正确统计,又避免了残留过期数据占用内存

综上所述,设置过期时间为 ARGV[2] + 1000 毫秒是一种为了确保滑动窗口限流算法正确运行并避免内存浪费的有效手段


超限情况(current >= tonumber(ARGV[3])

直接返回 0 表示当前请求触发了限流,不允许通过

四、Java代码调用

通过 Redisson 客户端执行 Lua 脚本:

// 获取当前时间戳(毫秒)
long now = System.currentTimeMillis();
// 窗口大小(60秒)
long windowMillis = 60_000;
// 限流阈值
int threshold = 100;

// 执行Lua脚本
Long result = redisson.getScript().eval(
    RScript.Mode.READ_WRITE,
    "上述Lua脚本内容",
    RScript.ReturnType.INTEGER,
    Collections.singletonList("sliding_window:api1"), // KEYS[1]
    now,          // ARGV[1]
    windowMillis, // ARGV[2]
    threshold     // ARGV[3]
);

if (result != null && result == 1) {
    // 允许执行
} else {
    // 触发限流
}

Zset+Lua脚本实现漏桶算法

一、漏桶算法核心原理

漏桶算法的核心是 以恒定速率处理请求,无论请求的突发性如何,确保流量被平滑处理:

  1. 请求入桶:请求到达时放入桶(队列)中。
  2. 恒定速率漏水:以固定速率(如每秒处理 10 个请求)从桶中取出请求处理。
  3. 桶满拒绝:若桶容量已满,新请求被拒绝。

二、实现方案设计

数据结构选择

键名leaky_bucket:api1(业务标识)

数据类型:有序集合(ZSET)

  • 成员(Member):请求的唯一标识(如 UUID 或时间戳+随机数)
  • 分数(Score):请求的时间戳(毫秒精度)

额外键leaky_bucket:api1:last_processed(记录上一次处理请求的时间戳)

核心逻辑

  1. 恒定速率漏水:每次请求时,计算当前时间与上一次处理时间的间隔,按速率计算出应处理的请求数量。
  2. 清理已处理请求:删除已处理的旧请求,释放桶空间。
  3. 判断桶容量:若当前桶内请求数未超容量,允许新请求加入

三、Lua脚本实现(原子操作)

-- KEYS[1]: 漏桶的键名(ZSET,如 leaky_bucket:api1)
-- KEYS[2]: 上一次处理时间的键名(如 leaky_bucket:api1:last_processed)
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 漏桶速率(每秒处理数,如10)
-- ARGV[3]: 漏桶容量(最大积压请求数,如100)

-- 获取上一次处理时间
local lastProcessed = redis.call('GET', KEYS[2])
if not lastProcessed then
    lastProcessed = ARGV[1]
    redis.call('SET', KEYS[2], ARGV[1])
end

-- 计算时间间隔(毫秒)
local timeElapsed = ARGV[1] - tonumber(lastProcessed)
-- 计算应处理的请求数(按速率折算)
local processCapacity = math.floor(timeElapsed * tonumber(ARGV[2]) / 1000)

if processCapacity > 0 then
    -- 删除已处理的请求(按时间范围清理)
    redis.call('ZREMRANGEBYRANK', KEYS[1], 0, processCapacity - 1)
    -- 更新上一次处理时间
    redis.call('SET', KEYS[2], ARGV[1])
end

-- 获取当前桶内请求数
local currentCount = redis.call('ZCARD', KEYS[1])

if currentCount < tonumber(ARGV[3]) then
    -- 桶未满:添加当前请求(以时间戳为分数)
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. ':' .. math.random())
    return 1  -- 允许请求
else
    return 0  -- 触发限流
end

脚本逻辑说明

  1. 动态漏水:根据时间差和速率计算应处理的请求数,删除已处理的旧请求。
  2. 容量判断:检查当前桶内请求数是否超过容量限制。
  3. 时间戳更新:每次处理请求后更新上一次处理时间

脚本参数说明

KEYS[1]:漏桶的键名,是一个 Redis 的有序集合(ZSET)用于存储请求的时间戳。例如 leaky_bucket:api1

KEYS[2]:上一次处理时间的键名,是一个 Redis 的字符串,用于记录上一次处理请求的时间戳。例如 leaky_bucket:api1:last_processed

ARGV[1]:当前时间戳,单位为毫秒

ARGV[2]:漏桶速率,即每秒允许处理的请求数。例如 10 表示每秒允许处理 10 个请求

ARGV[3]:漏桶容量,即漏桶最大允许积压的请求数。例如 100 表示漏桶最多可以积压 100 个请求


Lua脚本逻辑解释

1. 获取上一次处理时间

local lastProcessed = redis.call('GET', KEYS[2])
if not lastProcessed then
    lastProcessed = ARGV[1]
    redis.call('SET', KEYS[2], ARGV[1])
end

使用 redis.call('GET', KEYS[2]) 从 Redis 中获取上一次处理请求的时间戳。

如果 lastProcessed 为空(即第一次处理请求),则将当前时间戳 ARGV[1] 赋值给 lastProcessed,并将其存储到 Redis 中

2. 计算时间间隔和应处理的请求数

local timeElapsed = ARGV[1] - tonumber(lastProcessed)
local processCapacity = math.floor(timeElapsed * tonumber(ARGV[2]) / 1000)

timeElapsed 表示从上一次处理请求到当前时间的时间间隔,单位为毫秒。

processCapacity 表示在这段时间内按照漏桶速率应该处理的请求数

通过将时间间隔(毫秒)乘以漏桶速率(每秒处理数)再除以 1000 得到,并向下取整

3. 处理已过期的请求

if processCapacity > 0 then
    redis.call('ZREMRANGEBYRANK', KEYS[1], 0, processCapacity - 1)
    redis.call('SET', KEYS[2], ARGV[1])
end

如果 processCapacity 大于 0,说明在这段时间内有请求可以被处理。

使用 redis.call('ZREMRANGEBYRANK', KEYS[1], 0, processCapacity - 1) 从有序集合 KEYS[1] 中删除排名从 0 到 processCapacity - 1 的元素,即删除最早进入漏桶的 processCapacity 个请求。

更新上一次处理时间为当前时间戳 ARGV[1]

4. 获取当前桶内请求数

local currentCount = redis.call('ZCARD', KEYS[1])

使用 redis.call('ZCARD', KEYS[1]) 获取有序集合 KEYS[1] 中元素的数量,即当前漏桶内积压的请求数

5. 判断是否允许当前请求

if currentCount < tonumber(ARGV[3]) then
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. ':' .. math.random())
    return 1  -- 允许请求
else
    return 0  -- 触发限流
end

如果当前漏桶内的请求数 currentCount 小于漏桶容量 ARGV[3],说明漏桶未满,允许当前请求。

使用 redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. ':' .. math.random()) 将当前请求添加到有序集合 KEYS[1] 中,分数为当前时间戳,成员为当前时间戳加上一个随机数,以确保成员的唯一性

返回 1 表示允许请求

如果当前漏桶内的请求数 currentCount 大于等于漏桶容量 ARGV[3],说明漏桶已满,触发限流。

返回 0 表示拒绝请求


四、Java代码调用

通过 Redisson 客户端执行 Lua 脚本

long now = System.currentTimeMillis();
int ratePerSecond = 10;  // 漏桶速率:每秒处理10个请求
int capacity = 100;      // 漏桶容量

Long result = redisson.getScript().eval(
    RScript.Mode.READ_WRITE,
    "上述Lua脚本内容",
    RScript.ReturnType.INTEGER,
    Arrays.asList("leaky_bucket:api1", "leaky_bucket:api1:last_processed"), // KEYS
    now,          // ARGV[1]: 当前时间戳
    ratePerSecond,// ARGV[2]: 速率
    capacity      // ARGV[3]: 容量
);

if (result != null && result == 1) {
    // 允许执行
} else {
    // 触发限流
}

五、关键优化点

时间精度:使用毫秒级时间戳提高处理精度

容量控制:通过 ZREMRANGEBYRANK 清理已处理请求,避免内存无限增长

随机成员名:为每个请求生成唯一成员名(如时间戳+随机数),避免冲突

异常处理

时钟回拨:处理 Redis 服务器与客户端时间不一致的问题

并发控制:依赖 Lua 脚本的原子性保证,无需额外锁机制


网站公告

今日签到

点亮在社区的每一天
去签到