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

用 Redis Functions 封装滑动窗口限流:部署、版本与回滚

来源:17golang原创

时间:2026-10-07 11:08:51 468浏览 收藏

落地方案可以先压缩成一句话:用一个 Sorted Set 保存窗口内的请求,把“删掉过期成员、统计当前数量、决定是否放行、写入新请求”封装进 Redis Function;部署时让 sliding_allow_v1 与 sliding_allow_v2 使用不同名称并行存在,应用只切换函数名,回滚时再切回 v1。

这个设计同时解决两个问题。数据面上,四个 Redis 命令在一次原子函数执行中完成,不会被其他客户端插入;发布面上,稳定版本不会被候选版本直接覆盖,回滚不需要现场重写 Lua。

背景:EVAL 能运行,但不适合承担版本管理

Redis 官方文档指出,Redis Functions 从 Redis 7.0 开始提供。传统 EVALSHA 依赖脚本缓存,而缓存可能在 SCRIPT FLUSH、服务重启或副本故障转移后丢失,应用需要在运行时处理脚本重载。函数库则是数据库的一等软件制品,会随数据持久化并复制到副本,应用只依赖已注册的函数 API。

这并不意味着 EVAL 不能做限流,而是职责不同:临时脚本适合轻量、由应用携带的逻辑;Functions 更适合需要统一部署、命名、观察和回滚的服务端能力。官方还明确说明,函数执行具有原子性,并在执行期间阻塞服务器,所以函数必须短小,不能把慢查询或无界循环塞进去。

先固定调用契约,再写 Lua

本文只访问一个限流键,键名通过 KEYS[1] 传入。其余参数放在 ARGV:当前毫秒时间、窗口毫秒数、窗口配额、请求唯一标识。返回值是两个整数:第一个表示是否放行,第二个表示本次判断后的剩余额度。

位置含义示例
KEYS[1]限流维度键rl:{user:42}:login
ARGV[1]当前时间,毫秒1791342000000
ARGV[2]窗口长度,毫秒60000
ARGV[3]窗口内最大请求数5
ARGV[4]请求唯一 memberreq-8f27

FCALL 的第二个参数是 key 数量。官方要求函数访问的所有键都显式作为 key 参数传入,这对单机和集群环境都很重要。本文只访问一个键,因此调用时写 FCALL sliding_allow_v1 1 ...。

把四次操作收进一个原子边界

滑动窗口调用参数、Redis Function、有序集合状态与返回契约的静态依赖结构
图1:滑动窗口限流的静态依赖结构;调用参数、函数边界、有序集合状态和返回契约之间用无方向连线表示关系,不代表执行步骤。

Sorted Set 的 score 使用毫秒时间戳,member 使用请求唯一标识。每次调用先删除 score 小于等于窗口左边界的成员,再用 ZCARD 读取窗口内数量。达到配额就拒绝;未达到配额才写入当前请求,并刷新键的过期时间。

#!lua name=rate_limit_v1

-- 封装单个限流键的滑动窗口判断
local function sliding_allow(keys, args)
    local key = keys[1]
    local now_ms = tonumber(args[1])
    local window_ms = tonumber(args[2])
    local limit = tonumber(args[3])
    local member = args[4]

    -- 拒绝缺失参数和非正数配置,避免产生不可控键
    if not key or not now_ms or not window_ms or not limit or not member then
        return redis.error_reply('ERR invalid arguments')
    end
    if window_ms = limit then
        return {0, 0}
    end

    -- member 必须由调用方保证唯一,否则 ZADD 会更新旧成员
    redis.call('ZADD', key, now_ms, member)
    redis.call('PEXPIRE', key, window_ms)

    return {1, limit - count - 1}
end

-- 注册带版本号的函数名,给并行部署和回滚留下空间
redis.register_function('sliding_allow_v1', sliding_allow)

这里没有先 ZADD 再判断,因为被拒绝的请求不应该进入集合。PEXPIRE 让长期无人访问的限流键自动清理;活跃键则由每次成功请求刷新 TTL。函数执行是原子的,但原子不等于零成本:清理大量过期成员仍会占用服务器时间,因此窗口、调用频率和单键基数都要有上限。

首次部署:加载库、查看注册结果、再调用

把上面的源码保存为 rate_limit_v1.lua。库名来自首行元数据 #!lua name=rate_limit_v1,函数名来自 redis.register_function。两者不要混淆:FUNCTION DELETE 接受库名,FCALL 接受函数名。

# 从标准输入加载完整函数库,成功时返回库名 rate_limit_v1
redis-cli -x FUNCTION LOAD 

返回 1, 4 表示本次放行,窗口还剩 4 个名额;返回 0, 0 表示拒绝。生产客户端应把这两个整数映射成明确的数据结构,不要依赖日志文本做判断。

版本不要覆盖:让 v1 和 v2 暂时并存

Redis Functions稳定版本、候选版本与运维控制面的静态模块关系
图2:Redis Functions 并行版本的静态模块关系;应用配置可以绑定 v1 或 v2,FUNCTION LIST 与 FUNCTION DELETE 属于运维控制面,不表示发布时间线。

FUNCTION LOAD REPLACE 可以整体替换同名库,但它更适合明确接受原地升级的场景。限流属于入口保护能力,我更倾向于让候选版本使用新库名 rate_limit_v2 和新函数名 sliding_allow_v2。Redis 的函数名是全局唯一的,因此只改库名、不改函数名,仍会产生冲突。

v2 的注册位置可以保持和 v1 相同的回调结构,只修改库名与函数名;算法变化则写在 v2 文件内部。加载后同时查看两套库,确认它们并存。

# 加载候选库,文件首行应声明 name=rate_limit_v2
redis-cli -x FUNCTION LOAD 

应用配置中保存函数名,例如 RATE_LIMIT_FUNCTION=sliding_allow_v2,比把 Lua 源码或 SHA1 放进每个应用实例更容易审计。灰度期间 v1 与 v2 访问同一个限流键时,必须保持数据结构和 member 语义兼容;如果 v2 改变存储格式,就应使用新的 key 命名空间,避免两种实现互相破坏数据。

回滚:先切回调用,再删除候选库

并行版本的价值在这里最明显。发现 v2 的拒绝率、延迟或错误码异常时,把应用配置切回 sliding_allow_v1,不需要重新加载旧源码。确认所有实例都不再调用 v2 后,再删除候选库。

# 应用先恢复调用 sliding_allow_v1,再确认两套库仍存在
redis-cli FUNCTION LIST LIBRARYNAME 'rate_limit_v*'

# 确认无调用方依赖后,按库名删除候选版本及其函数
redis-cli FUNCTION DELETE rate_limit_v2

顺序不能反过来。先删除 v2、再等待应用配置生效,会让仍在使用旧配置的实例收到“函数不存在”错误。对于多实例应用,配置下发状态和实际调用指标应成为删除前置条件。

库备份不是版本切换,但能补上灾难恢复

FUNCTION DUMP 会导出全部函数库的二进制载荷,FUNCTION RESTORE 可以用该载荷恢复,并支持 FLUSH、APPEND、REPLACE 策略。它适合备份迁移,不适合代替日常 v1/v2 灰度,因为载荷覆盖的是函数库集合,而不是单个函数。

from pathlib import Path
import redis

# 连接目标 Redis,生产环境应从受控配置读取连接信息
client = redis.Redis(host="127.0.0.1", port=6379, decode_responses=False)

# 保存完整函数库二进制载荷,不能按文本方式改写
payload = client.execute_command("FUNCTION", "DUMP")
Path("redis-functions.dump").write_bytes(payload)

# 灾难恢复时读取原始字节,并按 REPLACE 策略恢复同名库
saved = Path("redis-functions.dump").read_bytes()
client.execute_command("FUNCTION", "RESTORE", saved, "REPLACE")

恢复命令会改变目标实例的函数库集合,应该在明确的运维窗口中执行。日常回滚仍优先使用并行版本加应用配置切换,因为影响范围更小,也更容易观察。

上线前必须说清的六个边界

  • Redis 版本:Functions 从 Redis 7.0 开始可用;更低版本不能直接使用本文命令。
  • 唯一 member:同一个 member 再次 ZADD 会更新 score,而不是增加一条记录。请求 ID 必须在限流窗口内唯一。
  • 时间来源:示例由调用方传入毫秒时间。多台网关要同步时钟,并拒绝明显超前或回退的时间值。
  • Cluster 键:函数访问的键必须通过 key 参数显式传入。本文只有一个键;扩展到多键时还要保证相关键位于可执行的同一哈希槽。
  • 运行时成本:ZREMRANGEBYSCORE 的成本与被删除成员数有关。不要用一个全局键承载所有用户,也不要设置无界窗口。
  • TTL 语义:示例只在成功写入时刷新 TTL。若业务要求拒绝请求也延长观察期,需要明确修改策略,并评估热键长期驻留的影响。

采用建议

如果限流逻辑只是一次性实验,EVAL 足够直接;如果它已经是多个应用共享的入口能力,Redis Functions 更适合承担部署单元。实现层面坚持单键、短函数、有界集合;发布层面坚持库名和函数名都带版本,候选版本与稳定版本并存;回滚层面坚持先切调用、后删库。

最重要的不是把 Lua 写得更复杂,而是把数据契约和版本契约同时固定下来:KEYS 决定函数允许访问什么,ARGV 决定调用参数,函数名决定应用调用哪个版本,库名决定运维删除哪个部署单元。这四层边界清楚后,滑动窗口限流才真正具备可维护性。

延伸问题

为什么不用固定窗口 INCR?

固定窗口计数更省内存,但窗口边界附近可能出现突发翻倍。滑动窗口保留每次请求时间,判断更平滑,代价是 Sorted Set 存储和清理成本更高。

能不能直接用 FUNCTION LOAD REPLACE 发布 v2?

可以,但同名库会被整体替换,旧实现不再作为独立部署单元保留。对需要快速回滚的入口能力,并行库名和函数名通常更稳妥。

限流函数能返回重试时间吗?

可以在拒绝时读取窗口内最早成员的 score,并计算其离开窗口的时间。但这会增加一次读取和返回契约复杂度;只有客户端确实需要精确 Retry-After 时再加入。

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