(面试题)Redis实现 IP 维度滑动窗口限流实践

引言

在开发高并发系统时,限流是一个绕不开的话题。无论是为了保护后端服务不被突发流量打垮,还是为了防爬虫、防恶意攻击,限流都是最常用的手段之一。常见的限流算法有计数器(固定窗口)、滑动窗口、漏桶、令牌桶等。今天我们就来聊一聊如何用 Redis 的有序集合(ZSET)实现一个滑动窗口限流,并以 IP 维度限制 60 秒内最多 100 次请求为例,给出完整的设计思路和代码。

需求描述

假设我们有一个公开的 API,需要根据调用方的 IP 地址进行限流:

  • 任意时刻向前推 60 秒(滑动窗口)
  • 同一个 IP 最多允许 100 次请求

这里强调"任意时刻",意味着我们不能用固定时间窗口(比如每分钟重置一次),因为固定窗口在边界处可能允许瞬间两倍的流量。比如:

  • 12:30:59 请求了 100 次
  • 12:31:00 又请求了 100 次

那么在 12:30:30 ~ 12:31:30 这 60 秒内,实际发生了 200 次请求,显然违背了我们的限制。所以必须用滑动窗口来精确控制。

为什么不用 INCR 做固定窗口?

很多初学者会想到用 Redis 的 INCR 配合过期时间来实现限流:

复制代码
INCR limit:ip:192.168.1.1:202503051230   # 按分钟分片
EXPIRE limit:ip:192.168.1.1:202503051230 60

这种做法本质是固定窗口 :每分钟一个计数器,窗口切换时计数器重置。问题就在于窗口切换的那一瞬间,前后两个窗口的请求可能叠加,导致实际 QPS 翻倍。虽然你可以把窗口粒度调小(比如按秒分片),但依然存在边界突刺,而且 key 的数量会爆炸。所以,要实现严格的滑动窗口,必须记录每一次请求的时间戳

滑动窗口设计:基于 Redis ZSET

核心思路

利用有序集合(ZSET)的特性:

  • 每个 IP 对应一个 ZSET,key 设计为 limit:ip:{ip}
  • ZSET 的 member 可以用唯一请求 ID(比如 UUID 或 请求时间戳+随机数),但为了简单,通常直接用 当前时间戳 作为 member 也可以(如果同一毫秒内有多个请求,member 会重复,但概率极低,也可以用 时间戳_递增计数 保证唯一)
  • ZSET 的 score 就是请求发生的时间戳(毫秒或秒级,根据精度要求)

每次请求到来时,我们执行以下逻辑:

  1. 删除窗口外的数据:ZREMRANGEBYSCORE key 0 (now - 60),移除 60 秒之前的记录。
  2. 统计当前窗口内的请求数:ZCARD key。
  3. 如果数量 ≥ 100,则拒绝请求。
  4. 否则,记录本次请求:ZADD key now requestId。
  5. 设置 key 的过期时间(比如 120 秒),避免长期占用内存。

原子性保证

上述步骤需要保证原子性,否则在高并发下可能出现竞争条件:比如两个请求同时删除过期数据,然后都发现当前计数 < 100,都执行了 ZADD,导致限流失效。因此,我们必须把整个逻辑封装在一个 Lua 脚本里,让 Redis 原子执行。

下面是一个简单的 Lua 脚本示例(使用秒级时间戳):

lua 复制代码
-- KEYS[1] = key
-- ARGV[1] = 当前时间戳(秒)
-- ARGV[2] = 窗口大小(秒)
-- ARGV[3] = 最大请求数
-- ARGV[4] = requestId(可选,也可以用时间戳+随机数生成)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local requestId = ARGV[4] or tostring(now) .. '_' .. tostring(math.random(1000000))

-- 移除窗口外的数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 统计当前窗口请求数
local current = redis.call('ZCARD', key)

if current >= limit then
    return 0  -- 拒绝
else
    -- 记录本次请求
    redis.call('ZADD', key, now, requestId)
    -- 设置过期时间(窗口大小*2,避免key长期存在)
    redis.call('EXPIRE', key, window * 2)
    return 1  -- 允许
end

调用时,用 EVALSHAEVAL 传入参数即可。这个脚本返回 1 表示放行,0 表示限流。

为什么 requestId 不能直接用时间戳?

如果多个请求在同一秒内到达,使用相同的时间戳作为 member 会导致 ZADD 时只保留最后一个,丢失之前的计数。所以必须保证 member 唯一。可以用 时间戳_递增序号 或者 UUID。如果并发量不大,也可以直接用时间戳,但为了严谨,还是加上随机后缀为好。

应用场景扩展

这个基于 ZSET 的滑动窗口限流,除了 IP 维度,还可以用在很多地方:

  1. API 限流:按用户 ID、AppKey 等维度,限制单位时间内调用次数。
  2. 登录防爆破:限制同一 IP 或同一账号的失败尝试次数,比如 5 分钟允许 5 次登录失败。
  3. 短信验证码发送限制:防止短信轰炸,同一手机号 1 分钟内最多发送 1 条。
  4. 爬虫防护:对爬虫 IP 进行动态封禁,如果某 IP 在短时间内请求量过大,则返回验证码或直接拒绝。
  5. 商品秒杀/活动防刷:限制同一用户参加秒杀的频率。
  6. 评论/点赞防刷:限制同一用户对同一内容的操作次数。

它的优点是精确、无边界突刺,缺点是需要存储每一次请求的记录,如果限流阈值很大(比如每秒 10 万次),ZSET 会变得非常庞大,内存占用和性能都会成为问题。因此,它适用于阈值较低的场景(比如几十到几百次/分钟)。对于超高阈值的限流,可以采用令牌桶或漏斗算法,这些算法内存占用更小,但精度稍低。

注意事项

  1. 时间同步:所有请求的时间戳最好由 Redis 服务器生成,或者使用同一个时间源(如 NTP),避免因客户端时间不一致导致窗口错乱。上面的 Lua 脚本中,时间戳由调用方传入,如果调用方时间不准,会影响限流效果。建议在脚本内使用 redis.call('TIME') 获取 Redis 服务器时间。
  2. 内存清理:虽然我们设置了过期时间,但 ZSET 的 member 会一直保留到过期。如果窗口大小是 60 秒,过期时间设为 120 秒,那么每个 key 最多保留 120 秒的数据。即便如此,如果 IP 量很大,Redis 内存消耗依然可观。可以结合业务特点,对非活跃 IP 及时清理。
  3. 性能考虑:ZREMRANGEBYSCORE 和 ZADD 都是 O(log N) 操作,N 是窗口内的请求数。如果阈值是 100,N 就是 100,性能完全没问题。但如果阈值是 10000,ZSET 操作会变慢,此时可以考虑改用哈希结构或别的算法。
  4. 分布式环境:如果应用是多实例部署,所有实例共享同一个 Redis,上面的设计天然支持分布式限流。但要注意 Redis 的单点故障,可以配合 Redis 集群或哨兵提高可用性。
  5. 降级方案:当 Redis 不可用时,限流组件应该能快速失败(比如拒绝请求)或降级为本地限流,避免服务雪崩。

总结

通过 Redis 的 ZSET 实现滑动窗口限流,代码简单,逻辑清晰,能够精确控制任意时间窗口内的请求量,完美规避固定窗口的边界突刺问题。虽然需要存储每一次请求的记录,但在阈值不大的场景下非常实用。结合 Lua 脚本的原子性,我们可以放心地在生产环境中使用。

希望这篇文章能帮你理解滑动窗口限流的原理和实现。如果你有更好的想法或问题,欢迎留言讨论!

相关推荐
Lee川2 小时前
探索JavaScript的秘密令牌:独一无二的`Symbol`数据类型
javascript·面试
Be_Better2 小时前
学会与虚拟机对话---ASM
java
AAA梅狸猫2 小时前
消息入队 enqueueMessage
面试
开源之眼4 小时前
《github star 加星 Taimili.com 艾米莉 》为什么Java里面,Service 层不直接返回 Result 对象?
java·后端·github
zone77394 小时前
003:RAG 入门-LangChain 读取图片数据
后端·python·面试
zone77394 小时前
002:RAG 入门-LangChain 读取文本
后端·算法·面试
青青家的小灰灰4 小时前
从入门到精通:Vue3 ref vs reactive 最佳实践与底层原理
前端·vue.js·面试
Maori3165 小时前
放弃 SDKMAN!在 Garuda Linux + Fish 环境下的优雅 Java 管理指南
java
over6975 小时前
从 URL 输入到页面展示:一次完整的 Web 导航之旅
前端·面试·架构