登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

Redis 窗口计数限流器的边界与过期策略

来源:17golang原创

时间:2026-10-10 17:19:17 309浏览 收藏

Redis 窗口计数限流器真正难处理的地方,不是把数字加一,而是决定这个数字什么时候过期。若每次请求都重新执行 EXPIRE,看起来像是给计数器续命,实际可能把固定窗口变成滑动窗口;若先执行 INCR,再单独执行 EXPIRE,客户端在两条命令之间断开,还可能留下没有 TTL 的脏键。

官方地址:https://redis.io/docs/latest/

固定窗口限流的稳妥做法是:先把用户标识和窗口编号组成独立键,再让 Redis 原子执行计数增加与“仅首次设置过期时间”。这样后续请求只增加计数,不会不断推迟窗口结束时间。

先把“窗口”说清楚

假设接口限制为每 60 秒最多 100 次请求,至少有三种常见语义。第一种是自然时间窗口,例如 12:00:00 到 12:00:59;第二种是以用户第一次请求为起点的 60 秒窗口;第三种是每次请求都把 TTL 延长 60 秒,形成一个随流量移动的窗口。三者都能用计数器表示,但过期策略不能混用。

策略计数键TTL 行为适合场景
固定时间桶包含窗口编号新键首次写入时设置简单、吞吐高、可接受边界突发
首次请求锚定同一主体一个键首次计数时设置,之后不刷新希望窗口从第一次请求开始计算
滑动续期同一主体一个键每次请求都刷新只关心“最近一段时间是否活跃”
Redis 请求、窗口键、INCR 和过期时间之间关系的静态结构说明图
图1:Redis 窗口计数器的键、计数与 TTL 关系说明图;这是静态说明图,不是运行截图。

计数键要把主体和窗口隔离开

固定窗口的键名可以写成 rate:{user:42}:202610101015,其中最后一段是按 60 秒取整后的窗口编号。对于 Redis Cluster,花括号中的内容还可以让同一用户相关键落在同一个哈希槽;如果脚本只访问一个计数键,这个写法也便于后续扩展。

窗口编号应由应用侧根据统一时间计算,不能把本机格式化后的自然语言日期直接当作稳定协议。示例中用命令变量模拟一个已经算好的窗口编号:

# 用主体标识和窗口编号组成独立计数键,避免相邻窗口共用数字
SET rate:{user:42}:202610101015 0

# 计数器只保存整数;读取结果可以直接用于和阈值比较
INCR rate:{user:42}:202610101015

# 观察当前剩余时间,-1 表示键存在但没有过期时间
TTL rate:{user:42}:202610101015

不要把所有用户都写进一个固定键,也不要只用用户 ID 而不带窗口编号。前者无法分摊计数,后者会让窗口切换依赖“什么时候清零”,并且更容易把清零操作和请求竞争放在一起。

用一次原子执行绑定 INCR 和 EXPIRE

Redis 的 INCR 会在键不存在时从 0 开始增加,且单条命令本身是原子的。问题出在后续过期命令:单独发送两条命令时,应用可能在第一条成功后失去连接。把两步放进 Lua 脚本,Redis 会在脚本运行期间按原子语义处理它们。

-- KEYS[1] 是当前主体的计数键,ARGV[1] 是窗口保留秒数
local current = redis.call('INCR', KEYS[1])

-- 只有第一次创建计数值时设置 TTL,后续请求不改变窗口终点
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end

-- 返回当前计数,调用方据此判断是否超过阈值
return current

执行时把键作为 KEYS[1] 传入,把秒数作为 ARGV[1] 传入:

# 第一个参数 1 表示后面只有一个 Redis 键
# 60 表示首次计数后保留 60 秒,不会在每次请求时重新计时
EVAL "local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]); end; return n" 1 rate:{user:42}:202610101015 60

如果脚本返回 101,说明计数已经超过 100,业务层应拒绝本次请求或返回明确的限流响应。阈值判断和计数更新最好在同一段脚本里完成,避免客户端先读后写造成并发间隙;不过脚本只负责“判定”,不能把真实业务处理也塞进 Redis。

最容易误用的是“每次都刷新 TTL”

EXPIRE 可以覆盖已有 TTL,而 INCR 不会清除键原有的过期时间。因此,下面两种写法的含义完全不同:

# 固定窗口:只在第一次计数时设置过期时间
local n = redis.call('INCR', KEYS[1])
if n == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return n

# 滑动窗口:每一次请求都将窗口向后推迟
local n = redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
return n

第二段并不是“更安全的固定窗口”。只要请求持续到达,键的 TTL 就持续被刷新,计数器可能长期不消失;它适合“最近 60 秒有多少活动”或“连续空闲 60 秒才重置”这类语义。若业务要的是固定 60 秒配额,就必须把刷新动作限定在首次写入。

Redis 首次设置 TTL 与每次刷新 TTL 的固定窗口和滑动窗口对比说明图
图2:两种 TTL 策略的静态对比图,展示固定窗口与滑动窗口的不同语义;这是说明图,不是运行截图。

窗口边界会带来突发流量

固定窗口的计数器很简单,但它不是精确的滑动时间窗口。一个客户端可以在 12:00:59 的最后一瞬间消耗 100 次,又在 12:01:00 的新窗口立刻消耗 100 次。两次都没有超过各自窗口的阈值,却可能在很短时间内形成接近 200 次的突发。

这不是 TTL 写错,而是固定窗口的定义本身。可以按业务风险做选择:

  • 后台任务、低风险读接口:接受边界突发,使用固定窗口计数器。
  • 登录、验证码、写入接口:降低单窗口阈值,并在网关或业务层增加更长周期的第二道限制。
  • 需要平滑速率的接口:考虑滑动窗口、令牌桶或漏桶,不要只靠把固定窗口 TTL 改成每次刷新。

如果采用固定时间桶,可以把 TTL 设为略大于一个窗口,让旧键在切换后仍有短暂的清理余量;但 TTL 过长会增加无效键驻留时间,TTL 过短又可能让观测和故障排查变得困难。关键是让“计数有效期”和“数据清理期”有明确区分。

上线前检查这几个边界

第一,检查返回值类型和异常路径:键里如果被写入非整数文本,INCR 会返回错误,调用方应把它作为数据异常处理,而不是当作“未限流”。第二,检查脚本的运行时间,不要在脚本里遍历大量键或执行与单个主体无关的逻辑。第三,检查集群键:脚本访问的所有键都应落在同一个哈希槽,单键计数最容易满足这个约束。

第四,检查客户端超时和重试。限流脚本已经执行成功但响应丢失时,盲目重试可能把计数再加一次;调用方需要结合请求幂等性决定是否重试。第五,检查监控指标,至少区分“未超限”“达到阈值”“超过阈值”“Redis 执行错误”和“业务处理失败”。

# 用 TTL 和当前计数做低成本排查,确认窗口是否仍在按预期结束
GET rate:{user:42}:202610101015
TTL rate:{user:42}:202610101015

# 清理实验键;生产环境不要用不受控的模式匹配批量删除
DEL rate:{user:42}:202610101015

常见问题

INCR 后每次都 EXPIRE,为什么计数器不容易过期?

因为每次请求都把过期时间重新推迟了。持续流量会让键持续存活,这实现的是滑动续期语义,不是首次请求锚定的固定窗口。

只用 EXPIRE NX 能不能替代 Lua?

EXPIRE key seconds NX 能表达“没有 TTL 时才设置”,但如果 INCR 和 EXPIRE 仍是两次独立请求,客户端可能在中间失败。需要把“计数成功后设置 TTL”作为一个不可分割的动作时,应使用事务或脚本;脚本更适合同时返回计数结果。

固定窗口边界突发能不能靠延长 TTL 消除?

不能。延长 TTL 只改变键的清理时间,不会改变窗口编号和计数规则。要平滑边界,应更换算法或增加多时间尺度限制。

Redis 窗口计数限流的核心决策可以压缩成一句话:先定义窗口,再定义键,最后让计数和首次过期原子绑定。只要不把“每次刷新 TTL”误当成固定窗口,也不忽略相邻窗口叠加的边界突发,计数器就能保持简单,并且让后续的监控、扩展和算法升级都有清晰落点。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>