限流是接口防护中最基础也最关键的环节。当某个接口突然被大量请求打过来时,如果没有频次限制,数据库连接池可能瞬间耗尽,服务直接瘫痪。Redis因为操作原子性好、读写性能高,天然适合做这件事:把计数逻辑放在Redis里,请求先过一道Redis的闸门,超限的直接拒绝,系统压力立刻得到缓解。下面介绍几种常见的实现思路,以及各自适合的场景。

固定窗口计数:最简单直接的方案
固定窗口计数的思路非常直观:以时间片为单位,比如每分钟一个窗口。请求到来时,用INCR命令给计数器加一,如果是第一次计数(返回值为1),再用EXPIRE设置过期时间,超过阈值就拒绝请求。
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0 -- 超过限制,拒绝
end
return 1 -- 允许通过这个方案实现简单,内存占用极小,一个key只存一个数字。但它有个经典缺陷:临界点突刺问题。假设限制每分钟100次,用户在第59秒到第60秒之间打了100次,又在第61秒开始再打100次,两秒内实际通过了200次请求。窗口边界的两倍流量冲击,对某些敏感接口来说可能是致命的。
另外要注意,INCR和EXPIRE两条命令如果不是原子执行,可能出现第一步成功、第二步失败的情况,导致key永远不过期。所以生产环境一定要用Lua脚本或者事务把两条命令绑在一起,上面代码已经演示了标准做法。
滑动窗口:解决临界问题的进阶方案
滑动窗口不再按固定时间片切割,而是把时间看作一条连续的线。常见实现有两种:一种是滑动窗口日志,用ZSET记录每个请求的时间戳;另一种是滑动窗口计数,把大窗口切成多个小格子分别计数再求和。
基于ZSET的日志方式,每次请求先删除窗口外的旧记录,再统计当前窗口内的请求数,未超限则添加本次时间戳。它的精度最高,窗口边界完全平滑,不存在突刺问题。缺点是内存开销和请求量成正比,如果限制是每秒10万次,一个key就要存10万个成员,高流量场景下代价不小。用Lua脚本实现的完整逻辑如下:
-- KEYS[1]: 限流key, ARGV[1]: 窗口秒数, ARGV[2]: 最大请求数, ARGV[3]: 当前时间戳(毫秒)
local window = tonumber(ARGV[1]) * 1000
local now = tonumber(ARGV[3])
local threshold = tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count >= threshold then
return 0
end
redis.call('ZADD', KEYS[1], now, now .. '-' .. math.random())
redis.call('EXPIRE', KEYS[1], ARGV[1] + 1)
return 1小格子方案则是在精度和内存之间折中:比如把1分钟切成6个10秒的小格,每个小格单独计数,查询时汇总所有小格。精度取决于格子粒度,格子越细越精确但key越多。如果业务对边界流量不敏感,用固定窗口加一点冗余阈值往往就够用了,不必盲目追求滑动窗口。
令牌桶:允许突发流量的平滑限流
前面两种方案本质上是在数请求数,令牌桶则换个角度:以固定速率往桶里放令牌,桶有容量上限,请求只有拿到令牌才能通过。它的特点是允许一定程度的突发流量,桶里攒了50个令牌时,瞬间来的50个请求都能立刻放行,之后才恢复到平均速率。
p>实现思路是记录上次放令牌的时间和当前令牌数,每次请求时根据时间差补充令牌,再判断数量是否足够。Redis官方提供的redis-cell模块直接提供了CL.THROTTLE命令,一行命令就能完成令牌桶限流:
CL.THROTTLE user:123 15 30 60 1 -- 参数含义:key, 桶容量15, 60秒内最多30个令牌, 每次消耗1个 -- 返回值包含是否允许、需要等待的毫秒数等信息
如果不想引入额外模块,也可以自己用Lua脚本模拟,核心公式是:当前令牌数等于min(桶容量, 上次令牌数 + 时间差乘以速率)。对于网关类需要应对突发流量的场景,令牌桶通常是比纯计数更合理的选择,比如允许用户瞬间发出5个并发请求,但长期平均速率受限。
落地时的几个坑与最佳实践
第一,key的维度要设计好。限流对象可以是用户ID、IP、接口路径加用户ID的组合,甚至设备指纹。只按IP限流容易被局域网出口误伤,只按用户ID限流又防不住批量注册的账号,实际业务常常需要多维叠加。
第二,集群模式下Lua脚本的key必须落在同一个槽位。多key操作要使用hash tag,例如写成rate_limit:{用户ID}:api,花括号内的内容决定槽位归属,否则脚本会直接报错。
第三,别忽视失败兜底。Redis本身故障时,限流逻辑应该选择放行还是拒绝?对登录、支付这类安全敏感接口,默认拒绝更稳妥;对普通查询接口,可以短暂放行并触发告警,避免Redis抖动引发全站不可用。
第四,限流阈值建议做成可配置的。通过配置中心下发,遇到大促或异常流量时能快速调整,而不是改代码重新发布。同时配合监控统计被拒绝的请求数,当拒绝率突然飙升时及时告警,因为这可能意味着业务高峰来临,也可能是遭遇了攻击。综合来看,中小流量用固定窗口,需要精确边界用ZSET滑动窗口,要支持突发用令牌桶,按场景选对方案才是关键。