外观
实测对比:Redis+Lua 滑动窗口限流在网关 1000 QPS 下的原子脚本与坑点
问题背景:网关为什么需要滑动窗口限流
在网关层承接登录、下单、搜索等接口时,限流通常不是一个可选项,而是保护下游服务的第一道闸门。假设某接口承诺单租户 1000 QPS,如果只在应用内存中计数,多实例部署后每个节点都会放行一部分流量,最终进入后端的请求会被放大;如果直接在 Redis 中执行“先读计数、再判断、最后写入”,多个命令之间又会产生竞态,流量突增时可能超发。
滑动窗口限流的目标,是在最近连续的 1000ms 内最多放行 1000 个请求,而不是简单地按自然秒切分。它比固定窗口更平滑,也比复杂的令牌桶更容易解释和审计。Redis 单线程执行命令,配合 Lua 脚本可以把“删除旧请求、统计窗口内请求、写入当前请求、设置过期时间”封装成一次原子执行,非常适合网关限流。
方案对比:固定窗口、令牌桶与滑动窗口
常见限流算法各有取舍。固定窗口实现最简单,但在窗口边界可能出现两倍突发:例如上一个窗口末尾放过 1000 个请求,下一个窗口开始瞬间又放过 1000 个。令牌桶允许一定突发,并能持续生成令牌,但参数语义通常是“平均速率 + 突发容量”,审计窗口内真实请求数不如滑动窗口直观。
Redis ZSET 实现滑动窗口时,每次请求用毫秒时间戳作为 member 或 score,移除窗口外数据后统计当前窗口元素数量。它的优点是精确、原子、可观测;代价是每个请求至少产生一次 ZADD,极限 QPS 下会形成热点 Key。因此,1000 QPS 的单 Key 场景可以直接使用,超大流量则应按租户、路由或时间片做分片。
关键数据结构:ZSET 如何表达滑动窗口
建议的 Key 设计为 rate_limit:{route}:{tenant},例如 rate_limit:/order:tenant-1001。ZSET 的 score 存毫秒时间戳,member 必须保证唯一。只使用时间戳作为 member 会覆盖同一毫秒内的多个请求,因此生产脚本通常用 请求唯一 ID-时间戳 作为 member,或者由网关注入 requestId。
下面的命令可以直接运行,用 5 个元素演示窗口数据。其中前两个元素位于窗口外,后三个位于窗口内:
bash
# 启动 Redis,清空测试库
redis-cli FLUSHDB
# 构造一批请求时间:900、950 已过期;1050、1100、1200 在最近 500ms 内
redis-cli ZADD rate_limit:/demo:tenant-a 900 req-900 950 req-950 1050 req-1050 1100 req-1100 1200 req-1200
# 当前时间按 1200 处理,窗口起点为 700,即只保留 score >= 700 的请求
redis-cli ZREMRANGEBYSCORE rate_limit:/demo:tenant-a -inf 699
# 查看窗口内剩余请求
redis-cli ZCARD rate_limit:/demo:tenant-a
# 按分数升序查看明细,确认滑动窗口保留的数据
redis-cli ZRANGE rate_limit:/demo:tenant-a 0 -1 WITHSCORES
# 防止冷 Key 永久占用内存,设置略大于窗口的过期时间
redis-cli EXPIRE rate_limit:/demo:tenant-a 2这里有一个容易误解的点:滑动窗口不是每隔固定时间“整体滚动”,而是每个请求到来时都以“当前时间减去窗口长度”为左边界,实时删除更早的请求。因此无论请求何时到达,判断的都是最近一段连续时间。
Lua 原子脚本:网关 1000 QPS 限流实现
将脚本保存为 sliding_window.lua。脚本接收一个 Key、当前毫秒时间、窗口毫秒数、最大请求数、请求唯一 ID 和过期秒数,返回是否允许、当前计数、剩余额度与重试等待时间。
lua
-- KEYS[1]:限流 Key,例如 rate_limit:/order:tenant-1001
-- ARGV[1]:当前毫秒时间戳
-- ARGV[2]:窗口大小,1000 表示最近 1 秒
-- ARGV[3]:窗口内最大请求数,1000 表示 1000 QPS
-- ARGV[4]:请求唯一 ID,避免同毫秒请求互相覆盖
-- ARGV[5]:Key 过期时间,建议略大于窗口大小
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local request_id = ARGV[4]
local ttl = tonumber(ARGV[5])
local window_start = now - window
-- 1. 删除窗口外的历史请求
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
-- 2. 统计当前窗口请求数
local current = redis.call('ZCARD', key)
-- 3. 已达到阈值则拒绝,不写入本次请求
if current >= limit then
local earliest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = 0
if earliest[2] ~= nil then
retry_after = tonumber(earliest[2]) + window - now
if retry_after < 0 then
retry_after = 0
end
end
return {0, current, 0, retry_after}
end
-- 4. member 加入时间戳和 requestId,score 为当前毫秒时间
local member = now .. '-' .. request_id
redis.call('ZADD', key, now, member)
-- 5. 每次请求刷新过期时间,避免低频 Key 残留
redis.call('PEXPIRE', key, ttl)
local accepted = current + 1
local remaining = limit - accepted
return {1, accepted, remaining, 0}可以通过 redis-cli 直接执行,便于在接入网关前验证逻辑:
bash
# 读取脚本内容;-1 表示脚本后面紧跟的参数都是脚本内容,之后才是 key 数量
SCRIPT=$(cat sliding_window.lua)
# 第 1 个请求:允许,当前计数为 1,剩余 999
redis-cli --eval sliding_window.lua rate_limit:/order:tenant-1001 , \
1700000000000 1000 1000 req-001 1500
# 紧接着的第 2 个请求:允许,当前计数为 2,剩余 998
redis-cli --eval sliding_window.lua rate_limit:/order:tenant-1001 , \
1700000000001 1000 1000 req-002 1500
# 查看窗口内结构
redis-cli ZRANGE rate_limit:/order:tenant-1001 0 -1 WITHSCORES
# 模拟 1000 QPS:连续发送 1005 次,前 1000 个应放行,后续应返回 0
for i in $(seq 1 1005); do
redis-cli --eval sliding_window.lua rate_limit:/order:tenant-1001 , \
1700000001000 1000 1000 req-$i 1500
done在 Java Spring Cloud Gateway、OpenResty 或 Envoy ext_authz 中接入时,不建议每次请求都发送完整 SCRIPT LOAD 内容。更稳妥的方式是启动时加载脚本得到 SHA,再优先使用 EVALSHA;收到 NOSCRIPT 后重新加载。这样能把网络报文从“整段脚本加参数”降为“SHA 加参数”。
性能与正确性:1000 QPS 下要看什么
1000 QPS 意味着平均每毫秒 1 个请求,但真实流量通常不是均匀分布,压测时应观察 P99,而不是只看平均值。Redis 执行这个脚本的复杂度主要来自 ZREMRANGEBYSCORE、ZCARD 和 ZADD。正常情况下它们都能较快完成,但如果 Key 长期异常堆积,删除旧元素可能带来额外开销。
压测建议分三步:第一,单实例 Redis 验证脚本返回数量是否严格为 1000;第二,多网关节点并发压测同一个租户 Key,确认 Redis 原子性使全局总量不超发;第三,加入网络抖动和 Redis 超时,验证网关的失败策略。限流组件失败时不能无限放行核心交易接口,通常应选择 fail-close 或使用本地兜底配额。
避坑总结:网关限流的高频故障与修正
第一,不要把时间戳直接当 ZSET member。 常见现象是压测 QPS 很高,但 ZCARD 始终小于真实请求数,同一毫秒内大量请求被 ZADD 覆盖,限流失效。修正方法是 member 使用 毫秒时间戳-requestId,score 仍保存时间戳;如果 requestId 可能重复,应再拼接网关节点 ID 或随机串。
第二,不要混用秒级和毫秒级时间。 应用侧传入 System.currentTimeMillis() 是毫秒,Redis TIME 命令返回秒和微秒。若把秒级时间写入毫秒窗口,会出现窗口起点异常,要么所有请求立刻过期,要么全部被误拒。修正方法是在脚本入口统一 tonumber,并约定全部使用毫秒;需要 Redis 时钟时,用 TIME 后换算成毫秒,且不要依赖主从各节点本地时钟完全一致。
第三,不要拆成多条非原子命令。 典型错误是先 EXPIRE、再 ZCARD、应用判断后 ZADD。并发请求可能同时读到小于阈值的计数并一起写入,造成超发。修正方法是把清理、判断、写入和过期全部放入同一个 Lua 脚本,由 Redis 单线程原子执行。
第四,谨慎设置 Redis 超时和失败策略。 现象是 Redis 偶发延迟时,网关工作线程被阻塞,接口整体 RT 上升。修正方法是给限流调用设置短超时,例如 5~20ms,并按业务分级:支付、库存等核心路径默认拒绝或使用保守本地配额;普通资讯接口可短时间 fail-open,但必须打点告警。
第五,避免全局单 Key 承载所有流量。 如果所有租户共用一个 Key,1000 QPS 的目标会变成全局限额,也会造成热点。修正方法是按路由、租户、实例或哈希槽拆分 Key,例如 rate_limit:/order:tenant-1001:02,再把总额度按分片分配;需要严格总量时,分片数不宜过多,并在网关侧保持稳定路由。
第六,主从切换不能被当作强一致限流。 Redis 主从复制是异步的,故障切换瞬间刚写入的计数可能尚未复制到新主节点,极端情况下会出现少量超发。若业务追求的是保护系统而不是精确计费,这种取舍通常可以接受;若必须强一致,应使用共识系统或在下游再做一层幂等与配额保护。
综上,Redis+Lua 滑动窗口限流非常适合网关 1000 QPS 这类“精确、低延迟、易审计”的场景。关键不是把算法写出来,而是保证 member 唯一、时间单位统一、脚本原子执行、Key 合理拆分,并明确 Redis 故障与主从切换时的业务取舍。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。