怎么用 Redis 滑动窗口限制单 IP 的采集频率?
用 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。
转载请注明出处,版权归原作者所有。