⭐ 推荐:社区规则条款 V1.0

怎么用 Redis 滑动窗口限制单 IP 的采集频率?

liulian
liulian 初级会员萌新站长
发布于 2026-10-17 18:49 ·6 浏览 ·0 回复
内容摘要

用 Redis ZSET 存请求时间戳,配合 Lua 脚本原子完成清理、计数和写入,实现单 IP 滑动窗口限流,避免固定窗口用 INCR 在边界放过双倍请求,并用服务端 TIME 防止时钟漂移。

用 Redis 做单 IP 滑动窗口限流,核心是 ZSET(有序集合):把每次请求的时间戳存成 ZSET 的 score,用 ZREMRANGEBYSCORE 清掉窗口外的旧记录,再用 ZCARD 数出窗口内还剩多少条,没超阈值就 ZADD 写入本次请求。整个过程压在一个 Lua 脚本里执行,保证“清理、判断、写入”三步原子完成,不会出现并发超发。

为什么滑动窗口用 ZSET 而不是 INCR?

结论:固定窗口用 INCR 就够,但滑动窗口必须用 ZSET,因为只有 ZSET 能按时间戳范围删除元素并精确统计任意时间区间的请求数。

INCR + EXPIRE 的问题是窗口边界:60 秒窗口、100 次阈值,如果用户在 59 秒和 61 秒各打满 100 次,那 1 秒内实际放过了 200 次,峰值是阈值的 2 倍。滑动窗口没有这个断层,任意连续 60 秒内都不会超过 100 次。

ZSET 的 member 存请求标识、score 存毫秒时间戳,ZREMRANGEBYSCORE key 0 (now-60000) 一条命令就能把过期记录裁掉,剩下的 ZCARD 就是窗口内的真实请求数。

核心 Lua 脚本怎么写?

结论:必须用 Lua(EVAL/EVALSHA)包住三步操作,用 pipeline 或多次 MULTI 都不行——pipeline 只省网络往返、不保证原子性,其他客户端的请求可以在你 ZCARD 和 ZADD 之间插进来。

脚本内容:

-- KEYS[1]=限流key  ARGV[1]=窗口毫秒  ARGV[2]=阈值  ARGV[3]=请求唯一id
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local window = tonumber(ARGV[1])
local limit  = tonumber(ARGV[2])

redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
  local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
  local retry = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
  return {0, retry}          -- 0=拒绝, retry=建议重试秒数
end
redis.call('ZADD', KEYS[1], now, now .. '-' .. ARGV[3])
redis.call('PEXPIRE', KEYS[1], window + 1000)
return {1, 0}

调用示例:EVALSHA <sha> 1 rate:ip:1.2.3.4 60000 100 8f3a1c,key 命名建议 rate:ip:{ip},阈值和窗口都走 ARGV 传参,脚本本身可以缓存在客户端。

两个细节:时间用 redis.call('TIME') 取服务端时间,避免多台业务机时钟漂移导致窗口错乱;如果你用的 Redis 版本或云服务禁用了脚本内调用 TIME,就把客户端毫秒时间戳通过 ARGV 传进来,但前提是所有机器开着 NTP 同步。

PEXPIRE 必须设,且值要比窗口略大(窗口 + 1000ms)。漏掉这一步,长期不活跃 IP 的 key 会永久驻留,内存只涨不降。

同一毫秒的并发请求会不会互相覆盖?

结论:会,而且这是这套方案最容易踩的坑——ZSET 的 member 相同、score 也相同的时候,后写入的会直接覆盖先写入的,计数凭空少 1。

所以 member 一定不能只用时间戳。上面脚本用的是 毫秒时间戳-唯一id,ARGV[3] 可以传 UUID、雪花 ID、请求 ID,甚至用 INCR 生成的自增号。只要保证同一毫秒内的多次请求 member 不重复,ZCARD 就是准的。

判断阈值时用 >= 而不是 >:count >= limit 时拒绝,意味着第 101 次请求被拒,窗口内最多放行 100 次。

内存和性能怎么控?

结论:单 IP 阈值控制在 128 以内,可以白嫖 ZSET 的 listpack 紧凑编码,内存省一个数量级。

Redis 7 的 zset 默认 zset-max-listpack-entries 128(7 之前叫 zset-max-ziplist-entries 128),元素数不超过 128 时用紧凑结构存储,单个元素开销约 30–50 字节,100 条的 ZSET 大约 4–6KB。一旦超过 128 转成 skiplist,单元素开销涨到 80 字节以上,内存会有明显跳变。

按这个估算:10 万个 IP 同时打满 100 次窗口,内存约 500MB–1GB。如果 IP 量级更大,两条路——降低单 IP 阈值,或者做两级限流:先用一个廉价的固定窗口 INCR 做粗筛,只有接近阈值(比如达到阈值的 80%)的 IP 才落到 ZSET 里做精确滑动判定。

性能上,单次 Lua 脚本是 O(log N),N 是窗口内元素数(≤128 时基本是 O(1)),单实例 10 万 QPS 级别的限流判定没什么压力。脚本务必用 EVALSHA,别每次都传全文。

滑动窗口、固定窗口、令牌桶怎么选?

结论:要严格不许突刺选滑动窗口(ZSET),要允许短时突发且省内存选令牌桶,只做粗略防护选固定窗口。

  • 固定窗口:INCR + EXPIRE,内存一个 key 存一个整数,最省,但有 2 倍突刺问题。
  • 滑动窗口:ZSET 方案,最精确,代价是每个 IP 都要存 N 个元素。
  • 令牌桶 / 漏桶:Redis 官方模块 redis-cell 实现了 GCRA 算法,一条 CL.THROTTLE 命令搞定,只存一个 key、内存恒定,允许可控突发。代价要装模块,且不是所有云 Redis 都支持。

爬虫采集防护这类场景,单 IP 60 秒 100 次(约 1.7 QPS)是常见的起始阈值,先按这个跑一周看日志,再把阈值收敛到能拦住脚本、又不误伤正常用户的点。

总结一句话:ZREMRANGEBYSCORE 清窗、ZCARD 计数、ZADD 落点,三步塞进一段 Lua 用 EVALSHA 调,member 带唯一后缀防覆盖,阈值压在 128 以内保 listpack 编码,别忘了 PEXPIRE。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2247.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
GJ论坛站长
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~