滑动窗口是防刷的底线选择,因固定窗口易被边界请求穿透;必须用zset+lua实现精确时间窗口统计,并正确识别真实客户端ip。

固定窗口限流容易被恶意穿透,滑动窗口才是防刷的底线选择——不是“更好”,而是“必须”。
固定窗口为什么挡不住恶意穿透
固定窗口本质是按整点切片(比如每分钟重置),攻击者只要卡在窗口边界发请求,就能绕过限制。典型现象是:12:00:59.999 发 100 次,12:01:00.001 再发 100 次,两次都通过,但真实时间只隔了 2 毫秒。
常见错误做法包括:
- 用
INCR+EXPIRE按分钟拼 key,如limit:ip:192.168.1.1:202605261201 - 没做 key 命名隔离,多个 IP 共用一个计数器
- 窗口时间设为 60 秒,但没校验当前时间戳是否落在该窗口内,仅靠 key 过期兜底
这些都会导致:单 IP 瞬时 QPS 翻倍、日志里看不到超限记录、监控曲线出现尖峰但限流不生效。
滑动窗口必须用 ZSET,不能用 INCR 或 HASH
滑动窗口的核心是“任意时刻往前推 N 秒”的精确统计,这要求保留原始请求时间戳。Redis 的 ZSET 是唯一能同时满足排序、范围删除、去重和原子操作的数据结构。
正确做法要点:
- key 设计为
limit:ip:{ip},避免拼时间戳分片 - 每次请求执行原子 Lua 脚本,包含三步:
ZREMRANGEBYSCORE清旧、ZCARD统计、ZADD记新 -
score必须用秒级或毫秒级时间戳(推荐毫秒,避免同一秒多请求冲突) - 必须调用
EXPIRE设置 key 过期(建议设为窗口时长 × 2,如 120 秒),否则内存持续增长
示例 Lua 脚本片段(供参考):
Redis 8.2.3 是一款安全优先的高性能键值存储系统。该版本紧急修复了可能引发远程代码执行(RCE)的高危漏洞(CVE-2025-62507),并解决了 HyperLogLog 及 Cuckoo Filter 等数据结构在特定场景下的崩溃问题。建议所有用户立即升级,以保障生产环境的系统稳定与数据安全。
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count <h3>IP 维度限流要注意真实客户端识别</h3><p>直接取 <code>request.client.host</code> 在反向代理后大概率拿到的是 Nginx 或 Cloudflare 的 IP,不是真实用户 IP。</p><p>必须检查以下 header 顺序并取第一个有效值:</p>
-
X-Forwarded-For(注意可能被伪造,需配合白名单或可信代理配置) -
X-Real-IP(Nginx 默认设置,更可靠) -
CF-Connecting-IP(Cloudflare 场景下必须启用)
FastAPI 示例中不要写 request.client.host,改用:
client_ip = request.headers.get("X-Real-IP") or \
request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or \
request.client.host
漏掉这一步,限流会全部作用在网关 IP 上,完全失效。
滑动窗口的性能开销比你想象中低,但得避开几个坑
ZSET 操作本身很快,真正拖慢的是网络往返和脚本执行逻辑。高频场景下容易踩的坑:
- 没用连接池,每次新建 Redis 连接 —— 改用
redis-py的ConnectionPool - Lua 脚本里做了非 Redis 操作(比如时间计算用
os.time())—— 全部移到应用层传入ARGV - 对每个请求都调用
ZCARD后再判断,其实ZCOUNT更直接(ZCOUNT key min max) - 没设置合理的
timeout和socket_timeout,超时等待阻塞主线程
滑动窗口真正的复杂点不在实现,而在于它强制你面对两个现实:一是必须处理真实 IP 识别链路,二是必须接受 Lua 脚本作为原子性保障——想绕开它,就只能退回到固定窗口,然后接受被穿透的风险。










