Redis 从 2.6.0 版本开始内置了 Lua 解释器,允许用户在服务器端执行 Lua 脚本。通过 Lua 脚本,可以将多个 Redis 命令封装在一起,以原子方式执行,极大提升复杂操作的效率与可靠性。
为什么使用 Lua 脚本?
- 原子性:Redis 在执行 Lua 脚本期间不会执行其他命令,整个脚本要么完全执行,要么完全不执行(类似于事务,但更灵活)。
- 减少网络延迟:将多个命令打包成一个脚本,只需一次网络请求,避免多次 RTT。
- 复用与模块化:脚本可以常驻服务端,被客户端反复调用。
- 更强的逻辑能力:支持条件判断、循环、变量等编程特性,弥补原生命令的不足。
- 部分替代事务:传统 MULTI/EXEC 事务无法根据中间结果做决策,而 Lua 脚本可以。
环境准备
确保 Redis 版本 >= 2.6.0,最好使用 5.0 以上版本以获得更佳体验。
通过 redis-cli 或任意编程语言客户端(如 Python、Java、Go)执行脚本。
基础语法与快速上手
在 Redis 中执行 Lua 脚本:EVAL
EVAL script numkeys key [key ...] arg [arg ...]
script:Lua 脚本字符串。numkeys:后面 key 参数的数量。key:脚本中要操作的 Redis 键名,可通过KEYS数组访问。arg:附加参数,通过ARGV数组访问。
示例:返回 Hello 与传入的 name
EVAL "return 'Hello ' .. ARGV[1]" 0 world
输出 :"Hello world"
访问 Redis 键
在 Lua 脚本中通过 redis.call() 或 redis.pcall() 调用 Redis 命令。区别在于 pcall 会捕获异常并返回错误表,不中断脚本。
-- 原子递增并返回新值
EVAL "local val = redis.call('INCR', KEYS[1]); return val" 1 counter
参数传递
-- 设置多个字段,返回 OK
EVAL "for i=1, #KEYS do redis.call('SET', KEYS[i], ARGV[i]) end return 'OK'" 2 name age Alice 25
核心命令详解
1. EVAL -- 直接执行脚本
每次都会传输完整脚本内容,适合临时或小脚本。
2. EVALSHA -- 通过 SHA1 哈希执行
如果脚本已经加载到 Redis 服务端,可以通过 EVALSHA 执行,节省带宽。
SCRIPT LOAD "return 'Hello Redis'"
# 返回 "c686f316aaf1eb01d7a4c1ae86f7d2b9c0f5f4f3"
EVALSHA c686f316aaf1eb01d7a4c1ae86f7d2b9c0f5f4f3 0
3. 脚本管理命令
SCRIPT LOAD script-- 加载脚本,返回 SHA。SCRIPT EXISTS sha1 [sha1 ...]-- 检查脚本是否存在于缓存中。SCRIPT FLUSH-- 清空所有已加载的脚本。SCRIPT KILL-- 杀死运行超时的脚本(仅限未执行写操作时)。
深入 Lua 脚本编程
数据类型映射
Redis 与 Lua 之间的类型转换规则:
| Redis 返回值 | Lua 类型 |
|---|---|
| integer (或 OK) | number / table (含 ok 字段) |
| string | string |
| nil | nil |
| array | Lua table(从1开始索引) |
示例:处理 Redis 返回的数组
local res = redis.call('HMGET', 'user:1', 'name', 'age')
-- res 是一个 table,res[1] 是 name,res[2] 是 age
return {res[1], tonumber(res[2])}
条件与循环
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = redis.call('GET', key)
if not current then
redis.call('SET', key, 0)
current = 0
else
current = tonumber(current)
end
if current >= limit then
return {err = "over limit"}
end
redis.call('INCR', key)
return {ok = "success"}
错误处理
使用 redis.pcall 捕获错误,避免脚本因异常而终止。
local result = redis.pcall('LPOP', KEYS[1])
if result.type == 'err' then
return {error = "pop failed: " .. result.err}
end
return result
脚本中的全局变量
默认情况下,Lua 脚本不允许定义全局变量,避免污染。可使用 local 声明局部变量。
如需开启全局(不推荐),需修改 Redis 配置 lua-enable-global。
原子性说明
Redis 保证 Lua 脚本执行期间不会被其他命令打断。但注意:
- 脚本中的写操作一旦执行,即使脚本后续出错,已执行的写操作不会回滚(Redis 事务也不支持回滚)。因此需谨慎设计。
- 若脚本运行超时(默认 5 秒),Redis 会标记该脚本为"忙碌",不再接受其他命令。此时只能执行
SCRIPT KILL或SHUTDOWN NOSAVE。
性能优化建议
- 尽量少用
KEYS全局扫描 :脚本内调用KEYS或SCAN会阻塞 Redis,改用SSCAN/HSCAN或维护索引集合。 - 控制脚本执行时间 :避免在脚本内循环执行大量命令(如 10 万次
INCR),应拆分为多次调用或使用游标。 - 利用
EVALSHA:将长脚本预先加载,生产环境务必使用EVALSHA。 - 避免在脚本中生成大表返回:返回值会序列化后再发送给客户端,占用内存和网络。
调试技巧
使用 redis.log
redis.log(redis.LOG_NOTICE, "value is ", value)
日志级别:LOG_DEBUG, LOG_VERBOSE, LOG_NOTICE, LOG_WARNING。
查看日志需调整 Redis 日志级别。
使用 redis.replicate_commands
默认情况下,脚本中的写命令会以事务模式复制到从库/AOF。如果脚本包含随机性命令(如 TIME、RANDOMKEY),会导致主从数据不一致。解决方案:
redis.replicate_commands() -- 在脚本开头调用,启用效果复制模式
local time = redis.call('TIME')[1]
redis.call('SET', KEYS[1], time)
启用后,Redis 会记录脚本中写命令的具体参数并复制,而不是复制整个脚本。
常见应用场景
1. 原子性限流(滑动窗口或固定窗口)
-- 固定窗口限流:key 为 user:123:rate_limit, ARGV[1]=max_requests, ARGV[2]=window_seconds
local key = KEYS[1]
local max = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = redis.call('TIME')[1]
local bucket = redis.call('GET', key)
if bucket and tonumber(bucket) >= max then
return 0
end
if not bucket then
redis.call('SETEX', key, window, 1)
else
redis.call('INCR', key)
end
return 1
2. 分布式锁释放(原子性检查并删除)
-- 释放锁,只有 value 匹配时才删除
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
3. 批量操作且需要中间结果
例如批量获取多个 hash 的某个字段,并进行过滤:
local result = {}
for i, key in ipairs(KEYS) do
local val = redis.call('HGET', key, 'status')
if val == 'active' then
result[#result+1] = key
end
end
return result
4. 条件更新计数器
local key = KEYS[1]
local delta = tonumber(ARGV[1])
local min = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
local current = tonumber(redis.call('GET', key) or 0)
local new = current + delta
if new < min then new = min end
if new > max then new = max end
redis.call('SET', key, new)
return new
注意事项与最佳实践
| 注意点 | 建议 |
|---|---|
| 脚本超时 | 设置 lua-time-limit 并避免长时间循环,使用 SCRIPT KILL 处理 |
| 随机性命令 | 如需随机,调用 redis.replicate_commands() 以保证主从一致 |
| 内存消耗 | 脚本返回值不宜过大,避免返回整个 hash 或 list |
| 键的规划 | 脚本中所有要访问的键必须 通过 KEYS 传入,不要动态拼接键名,否则影响集群模式 |
| 集群兼容性 | 在 Redis Cluster 中,一个脚本只能访问同一 slot 中的键,可通过 hash tag 强制放同一 slot |
| 版本升级 | 旧版 Redis 中 Lua 字节码可能因升级失效,需重载脚本 |
| 调试生产环境 | 先使用 EVAL 测试,稳定后改用 SCRIPT LOAD + EVALSHA |
| 避免全局变量 | 使用 local 声明所有变量,避免意外共享 |
完整示例:令牌桶限流器
-- 令牌桶算法
-- KEYS[1] = bucket_key
-- ARGV[1] = rate (tokens per second)
-- ARGV[2] = capacity (max tokens)
-- ARGV[3] = requested (tokens needed)
-- ARGV[4] = now (timestamp in seconds)
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now
local delta = math.max(0, now - last_time)
local filled = math.min(capacity, tokens + delta * rate)
local allowed = filled >= requested
local new_tokens = filled
if allowed then
new_tokens = filled - requested
end
redis.call('HMSET', key, 'tokens', new_tokens, 'last_time', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1) -- 过期时间略大于填满时间
return { allowed, new_tokens }
调用方式:
EVAL "$(cat token_bucket.lua)" 1 rate_limit:user123 5 10 1 1620000000
总结
Redis Lua 脚本将计算推向数据侧,提供原子性、高性能的复杂操作能力。熟练掌握 EVAL、EVALSHA、脚本调试和集群约束,可以大幅优化缓存中间件的使用模式。从简单的原子性 CAS,到复杂的限流、队列管理,Lua 脚本都是 Redis 进阶用户的必备技能。
实际生产使用时,建议将脚本文件独立维护,通过加载工具(如 Redis 自带的 --eval 选项)进行测试,并配合监控系统观察脚本执行耗时与错误率。