本文最初写于2017年,2026年根据当前Redis文档重新整理。

限流看起来只是计数,更需要注意的是多条命令之间的边界。假设应用先判断键是否存在,再执行自增和设置过期时间,并发请求就可能在这些步骤之间交错;如果客户端在自增后、设置过期时间前异常退出,还可能留下一个不会自动清理的键。

Redis Lua的价值,是把这段逻辑放到服务端一次执行。脚本执行期间具有原子性,其他命令不会插入其中。

一个固定窗口计数器

下面的脚本接收一个限流键、窗口秒数和最大请求数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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

调用约定:

  • KEYS[1]:限流维度,例如用户或接口对应的键;
  • ARGV[1]:窗口时长,单位为秒;
  • ARGV[2]:窗口内允许的最大次数;
  • 返回1表示允许,返回0表示拒绝。

第一次自增后立即设置过期时间,整个过程在同一次脚本执行中完成。它减少了客户端与Redis之间的往返,也避免了多条命令被并发请求穿插执行。

原子不等于没有代价

Redis执行脚本时会阻塞其他服务端活动,因此脚本应保持短小、确定,并避免大范围扫描或耗时计算。把复杂业务全部塞进Lua,只是把应用层的复杂度转移到了一个更敏感的位置。

固定窗口本身也有边界效应:两个相邻窗口交界处可能在很短时间内通过接近两倍的请求。如果业务不能接受,需要改用滑动窗口、令牌桶等策略,而不是继续扩充这段脚本。

脚本的加载方式

频繁调用时,可以先通过SCRIPT LOAD加载脚本,再使用EVALSHA按摘要执行。但脚本缓存并不持久,重启、故障转移或SCRIPT FLUSH之后都可能丢失,客户端仍需处理重新加载。

Redis 7开始提供Functions,适合需要随数据持久化和复制的服务端逻辑。无论选择Eval脚本还是Functions,都更适合用于确实需要数据局部性和原子组合的场景。

这个例子的重点不是“如何写Lua”,而是如何把一个存在并发窗口的客户端流程,收拢成边界清楚的服务端操作。

参考