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 执行流程
- 客户端发送
EVAL命令,携带脚本源码和参数。 - Redis 服务器在 Lua 沙箱中加载脚本,注册
redis.call()/redis.pcall()函数。 - 脚本执行期间,Redis 进程阻塞,不响应其他命令,保证原子性。
- 脚本返回结果,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() 操作五种数据结构的完整路径:
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。