Lua限流脚本缓存保护 - 第2节

2.1 Redis 核心数据结构速览

在编写限流脚本之前,必须熟练掌握 Redis 的五种基本数据结构。限流场景中,String 用于计数器,Sorted Set 用于滑动窗口,List 用于令牌桶队列,Hash 用于存储配置参数,Set 用于去重标记。

数据结构 底层实现 限流典型用途 常用命令
String SDS(动态字符串) 固定窗口计数器、令牌数 INCR、EXPIRE、GETSET
Sorted Set 跳表 + 哈希表 滑动窗口时间戳集合 ZADD、ZREMRANGEBYSCORE、ZCARD
List 双向链表 / quicklist 令牌桶队列、请求缓冲 LPUSH、RPOP、LLEN
Hash 压缩列表 / 哈希表 存储限流规则、用户配置 HSET、HGET、HINCRBY
Set 整数集合 / 哈希表 黑名单、已处理 ID 去重 SADD、SISMEMBER、SCARD
选型建议: 滑动窗口限流优先使用 Sorted Set + ZREMRANGEBYSCORE 清理过期时间戳;固定窗口限流使用 String + INCR + EXPIRE 即可,性能最高。

2.2 Lua 脚本在 Redis 中的执行原理

Redis 从 2.6 版本开始内嵌 Lua 5.1 解释器。Lua 脚本在 Redis 中具有原子性、事务性和可复用性三大特性,是构建高性能限流组件的基石。

2.2.1 执行流程

  1. 客户端发送 EVAL 命令,携带脚本源码和参数。
  2. Redis 服务器在 Lua 沙箱中加载脚本,注册 redis.call() / redis.pcall() 函数。
  3. 脚本执行期间,Redis 进程阻塞,不响应其他命令,保证原子性。
  4. 脚本返回结果,Redis 将结果返回给客户端。
关键特性: 脚本执行期间不会插入其他命令,因此无需 MULTI/EXEC 事务包装;但脚本应尽量短小,避免长时间阻塞。

2.2.2 沙箱与安全限制

  • 禁止使用 loadfile、dofile、require 等文件操作函数。
  • 禁止使用 os.execute、io.* 等系统调用。
  • 全局变量 KEYS 和 ARGV 由 Redis 注入,分别对应键名参数和附加参数。
  • 脚本默认超时时间为 5 秒(可通过 lua-time-limit 配置),超时后 Redis 会强制终止脚本。
避坑: 不要在脚本中使用 math.random 生成随机数,Redis 沙箱未初始化种子,每次返回相同序列。应使用 redis.random() 或传入外部随机参数。

2.3 EVAL 命令详解

EVAL 是 Redis 执行 Lua 脚本的核心命令,其语法如下:

EVAL script numkeys key [key ...] arg [arg ...]
参数 说明 示例
script Lua 脚本源码字符串 "return redis.call('GET', KEYS[1])"
numkeys 键名参数的数量 1
key 键名列表,通过 KEYS[1] 访问 counter:user:123
arg 附加参数列表,通过 ARGV[1] 访问 100

2.3.1 基本示例

-- 原子递增并设置过期时间
local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local count = redis.call('INCR', key)
if count == 1 then
    redis.call('EXPIRE', key, ttl)
end
return count

调用方式:

EVAL "local key = KEYS[1]; local ttl = tonumber(ARGV[1]); local count = redis.call('INCR', key); if count == 1 then redis.call('EXPIRE', key, ttl) end; return count" 1 counter:user:123 60

2.3.2 EVALSHA 与脚本缓存

Redis 会对 EVAL 提交的脚本计算 SHA1 摘要并缓存。后续可使用 EVALSHA 直接通过摘要执行,避免重复传输脚本源码:

EVALSHA <sha1> numkeys key [key ...] arg [arg ...]

脚本缓存是永久性的,直到 Redis 重启或执行 SCRIPT FLUSH。生产环境建议使用 SCRIPT LOAD 预加载脚本,再通过 EVALSHA 调用。

最佳实践: 客户端库(如 Jedis、Lettuce)通常自动管理 EVALSHA 回退逻辑:优先使用 EVALSHA,若返回 NOSCRIPT 错误,自动降级为 EVAL 并重新缓存。

2.4 Lua 脚本与 Redis 数据结构的交互

以下 SVG 展示了 Lua 脚本通过 redis.call() 操作五种数据结构的完整路径:

Lua 脚本与 Redis 数据结构交互图 Lua 脚本 redis.call() String Sorted Set List Hash Set 返回值:String 返回整数/字符串;Sorted Set 返回数组;List 返回数组;Hash 返回字符串或 nil;Set 返回 0/1

2.4.1 返回值类型映射

Redis 返回类型 Lua 类型 示例
整数回复 number redis.call('INCR', 'x') → 1
字符串回复 string redis.call('GET', 'x') → "value"
多行字符串回复 table (数组) redis.call('LRANGE', 'list', 0, -1) → {"a","b"}
状态回复 table {ok="OK"} redis.call('SET', 'x', '1') → {ok="OK"}
错误回复 table {err="..."} redis.call('GET', 1) → {err="WRONGTYPE ..."}
空结果 boolean false redis.call('GET', 'nonexist') → false
注意: Lua 中 false 和空数组 {} 在返回给 Redis 客户端时会被转换为空回复。如果需要区分“不存在”和“空列表”,建议在脚本中显式返回 nil 或特定标记。

2.5 脚本原子性与事务对比

下表对比了 Lua 脚本与 Redis 原生事务(MULTI/EXEC)的核心差异:

特性 Lua 脚本 MULTI/EXEC 事务
原子性 整个脚本原子执行 队列中所有命令原子执行
条件逻辑 支持 if/else、循环、函数 不支持,所有命令预入队
中间结果读取 可读取前一步操作的结果 不可读,所有命令在 EXEC 后返回
错误处理 pcall 可捕获错误继续执行 任一命令失败,事务整体回滚
性能 一次网络往返,脚本缓存后更优 一次网络往返,但命令需排队
适用场景 复杂限流算法、条件判断 简单批量写入、无依赖操作
结论: 限流脚本必须使用 Lua 而非事务,因为限流逻辑依赖前一步的计数结果(如判断是否超过阈值),事务无法满足此需求。

2.6 实战:编写第一个限流脚本骨架

以下脚本实现了固定窗口限流的核心逻辑,后续章节将在此基础上扩展:

-- fixed_window_ratelimit.lua
-- KEYS[1]: 限流计数器 key
-- ARGV[1]: 窗口大小(秒)
-- ARGV[2]: 最大请求数
-- ARGV[3]: 当前请求时间戳(可选,用于测试)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local max_requests = tonumber(ARGV[2])
local now = tonumber(ARGV[3] or redis.call('TIME')[1])

-- 获取当前计数
local current = redis.call('GET', key)
if not current then
    -- 首次请求,初始化计数器
    redis.call('SET', key, 1, 'EX', window)
    return 1
end

current = tonumber(current)
if current >= max_requests then
    -- 超过限流阈值,返回 0 表示拒绝
    return 0
end

-- 未超限,递增计数器
redis.call('INCR', key)
return current + 1
测试方法: 使用 redis-cli --eval fixed_window_ratelimit.lua counter:api, 10 5 模拟 10 秒窗口内最多 5 次请求。连续执行 6 次,第 6 次应返回 0。