找回密码
 立即注册
首页 业界区 业界 (面试题)Redis实现 IP 维度滑动窗口限流实践 ...

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

倡粤 3 小时前
引言

在开发高并发系统时,限流是一个绕不开的话题。无论是为了保护后端服务不被突发流量打垮,还是为了防爬虫、防恶意攻击,限流都是最常用的手段之一。常见的限流算法有计数器(固定窗口)、滑动窗口、漏桶、令牌桶等。今天我们就来聊一聊如何用 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 配合过期时间来实现限流:
  1. INCR limit:ip:192.168.1.1:202503051230   # 按分钟分片
  2. 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 就是请求发生的时间戳(毫秒或秒级,根据精度要求)
每次请求到来时,我们执行以下逻辑:

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

上述步骤需要保证原子性,否则在高并发下可能出现竞争条件:比如两个请求同时删除过期数据,然后都发现当前计数 < 100,都执行了 ZADD,导致限流失效。因此,我们必须把整个逻辑封装在一个 Lua 脚本里,让 Redis 原子执行。
下面是一个简单的 Lua 脚本示例(使用秒级时间戳):
  1. -- KEYS[1] = key
  2. -- ARGV[1] = 当前时间戳(秒)
  3. -- ARGV[2] = 窗口大小(秒)
  4. -- ARGV[3] = 最大请求数
  5. -- ARGV[4] = requestId(可选,也可以用时间戳+随机数生成)
  6. local key = KEYS[1]
  7. local now = tonumber(ARGV[1])
  8. local window = tonumber(ARGV[2])
  9. local limit = tonumber(ARGV[3])
  10. local requestId = ARGV[4] or tostring(now) .. '_' .. tostring(math.random(1000000))
  11. -- 移除窗口外的数据
  12. redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
  13. -- 统计当前窗口请求数
  14. local current = redis.call('ZCARD', key)
  15. if current >= limit then
  16.     return 0  -- 拒绝
  17. else
  18.     -- 记录本次请求
  19.     redis.call('ZADD', key, now, requestId)
  20.     -- 设置过期时间(窗口大小*2,避免key长期存在)
  21.     redis.call('EXPIRE', key, window * 2)
  22.     return 1  -- 允许
  23. end
复制代码
调用时,用 EVALSHA 或 EVAL 传入参数即可。这个脚本返回 1 表示放行,0 表示限流。
为什么 requestId 不能直接用时间戳?

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

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

  • API 限流:按用户 ID、AppKey 等维度,限制单位时间内调用次数。
  • 登录防爆破:限制同一 IP 或同一账号的失败尝试次数,比如 5 分钟允许 5 次登录失败。
  • 短信验证码发送限制:防止短信轰炸,同一手机号 1 分钟内最多发送 1 条。
  • 爬虫防护:对爬虫 IP 进行动态封禁,如果某 IP 在短时间内请求量过大,则返回验证码或直接拒绝。
  • 商品秒杀/活动防刷:限制同一用户参加秒杀的频率。
  • 评论/点赞防刷:限制同一用户对同一内容的操作次数。
它的优点是精确、无边界突刺,缺点是需要存储每一次请求的记录,如果限流阈值很大(比如每秒 10 万次),ZSET 会变得非常庞大,内存占用和性能都会成为问题。因此,它适用于阈值较低的场景(比如几十到几百次/分钟)。对于超高阈值的限流,可以采用令牌桶或漏斗算法,这些算法内存占用更小,但精度稍低。
注意事项


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

通过 Redis 的 ZSET 实现滑动窗口限流,代码简单,逻辑清晰,能够精确控制任意时间窗口内的请求量,完美规避固定窗口的边界突刺问题。虽然需要存储每一次请求的记录,但在阈值不大的场景下非常实用。结合 Lua 脚本的原子性,我们可以放心地在生产环境中使用。
希望这篇文章能帮你理解滑动窗口限流的原理和实现。如果你有更好的想法或问题,欢迎留言讨论!

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册