Redis Lua 脚本详细教程

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 KILLSHUTDOWN NOSAVE

性能优化建议

  • 尽量少用 KEYS 全局扫描 :脚本内调用 KEYSSCAN 会阻塞 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。如果脚本包含随机性命令(如 TIMERANDOMKEY),会导致主从数据不一致。解决方案:

复制代码
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 脚本将计算推向数据侧,提供原子性、高性能的复杂操作能力。熟练掌握 EVALEVALSHA、脚本调试和集群约束,可以大幅优化缓存中间件的使用模式。从简单的原子性 CAS,到复杂的限流、队列管理,Lua 脚本都是 Redis 进阶用户的必备技能。

实际生产使用时,建议将脚本文件独立维护,通过加载工具(如 Redis 自带的 --eval 选项)进行测试,并配合监控系统观察脚本执行耗时与错误率。

相关推荐
MyY_DO2 小时前
缓存穿透-damai
缓存
上海合宙LuatOS3 小时前
LuatOS扩展库API——【exlcd】显示屏控制
物联网·lua·luatos
難釋懷3 小时前
Nginx本地缓存
nginx·spring·缓存
0xDevNull3 小时前
Spring Boot 中使用 Redis Lua 脚本详细教程
spring boot·redis·lua
不爱吃大饼3 小时前
Redis核心点
redis
William Dawson3 小时前
【实战分享】DTU设备高并发数据接入全流程(Redis + RabbitMQ + 数据库)
数据库·redis·rabbitmq
CodeMartain7 小时前
Redis为什么快?
数据库·redis·缓存
南汐以墨14 小时前
一个另类的数据库-Redis
数据库·redis·缓存
一个有温度的技术博主16 小时前
Redis AOF持久化:用“记账”的方式守护数据安全
redis·分布式·缓存