Redis从2.6版本开始内置Lua解释器,允许开发者把一串Redis命令封装成脚本,一次性发送到服务端原子执行。这种机制带来的好处非常直接:一是原子性,脚本执行期间不会被其他客户端的命令打断,天然规避并发竞态;二是减少网络往返,原本需要多次请求的逻辑现在一次就能完成。不过Lua脚本在Redis中运行环境有不少特殊限制,写法和标准Lua并不完全一样,调试也需要专门的手段。本文将从基础语法、典型场景、调试排错三个层面系统讲解Redis Lua脚本的编写方法。

一、EVAL命令与Lua脚本的基本结构
执行Lua脚本的核心命令是EVAL,它的完整格式是EVAL script numkeys key [key ...] arg [arg ...]。其中script是Lua脚本字符串,numkeys表示后面有几个key参数。写Redis Lua脚本时最重要的一条规范是:所有要操作的key都必须通过KEYS数组传入,而不是在脚本里硬编码。这是因为Redis Cluster需要根据key来路由请求,如果key写死在脚本内部,集群就无法正确判断脚本会访问哪些槽位。
下面是一个简单的示例,实现判断某个key是否存在并返回对应信息的功能:
-- EVAL "return KEYS[1] .. '-' .. ARGV[1]" 1 mykey hello
local key = KEYS[1]
local exists = redis.call('EXISTS', key)
if exists == 1 then
return redis.call('GET', key)
else
return 'key not found: ' .. ARGV[1]
end
脚本内部通过redis.call调用Redis命令,命令名必须大写字符串,后面的参数依次展开。如果希望在命令出错时不中断整个脚本,可以改用redis.pcall,它会捕获错误并返回一个带err字段的表,方便脚本自行处理异常逻辑。两者的区别类似于普通调用和异常捕获调用,在写复杂脚本时合理搭配使用能提升脚本健壮性。
还需要注意Lua和Redis类型转换的规则:Lua的数字和布尔值返回给客户端时会转换,true会变成1,false和nil会变成null。而Redis返回的整数状态码在Lua中就是数字,比如EXISTS返回0或1,可以直接参与条件判断。理解这些转换规则是写出正确判断逻辑的前提,很多新手脚本出错就是因为拿nil去和数字做比较。
二、典型场景实战:分布式锁与滑动窗口限流
1. 分布式锁的正确实现
分布式锁是Lua脚本最经典的应用。单纯用SETNX加锁、DEL解锁存在竞态风险,比如客户端A的锁过期后被客户端B获取,A随后执行DEL会误删B的锁。用Lua脚本把"验证锁归属再删除"这个逻辑原子化,就能避免误删问题:
-- 加锁:key为锁名,ARGV[1]为唯一标识,ARGV[2]为过期时间(毫秒)
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
return 1
else
return 0
end
-- 解锁:只有锁的持有者才能删除
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
这里的ARGV[1]通常使用"客户端ID加线程ID"拼接的唯一值,保证只有锁的持有者能解锁。加锁和解锁必须写成两个独立脚本,如果合并成一个脚本内部加锁再解锁,锁就失去了跨请求存在的意义。
2. 滑动窗口限流器
固定窗口限流在窗口边界处可能出现双倍流量,滑动窗口通过记录每次请求的时间戳并用ZSET维护,精度更高。核心思路是:删除窗口外的时间戳,统计窗口内数量,未超限则记录本次请求:
-- KEYS[1]: 限流key ARGV[1]: 窗口大小(秒) ARGV[2]: 最大请求数 ARGV[3]: 当前时间戳(微秒)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 移除窗口之外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000000)
-- 统计窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('PEXPIRE', key, window * 1000)
return 1
end
return 0
注意ZADD的member必须唯一,否则同一时间戳的请求会被去重导致限流计数不准,所以示例中用时间戳拼接随机数作为member。另外时间戳建议由客户端传入而不是在脚本里调用TIME,这样脚本本身是纯函数式的,在主从复制和AOF持久化时更安全。
三、脚本的缓存机制与生产环境注意事项
每次EVAL都要传输完整脚本文本,脚本越长网络开销越大。Redis提供了SCRIPT LOAD命令,它会加载脚本并返回一个SHA1摘要,之后用EVALSHA sha1 numkeys ...执行,只需传输40个字符的摘要。生产环境推荐的做法是:应用启动时先SCRIPT LOAD所有脚本缓存摘要,运行期统一用EVALSHA执行,如果收到NOSCRIPT错误(可能因为服务端重启清空了缓存)再降级为EVAL并重新LOAD。
Redis 3.2之后默认开启效果复制模式,主库只把脚本执行产生的写命令复制到从库和AOF,而不是复制脚本本身。这要求脚本在不同时间执行结果必须一致,也就是说不能依赖时间、随机数这类不确定因素。如果确实需要使用TIME或SRANDMEMBER这类命令,需要在执行前调用redis.replicate_commands()切换为效果复制。到了Redis 7版本,脚本系统升级为Function功能,支持把脚本持久化在服务端并按库管理,新项目可以考虑使用Redis Function替代传统的EVAL方式。
另一个必须警惕的点是脚本的执行时间。Lua脚本是原子阻塞的,脚本一旦死循环,整个Redis都会被卡住无法响应。虽然可以通过busy-reply-threshold(旧版本叫lua-time-limit)设置忙碌阈值,但超时后Redis只是记录日志并允许其他客户端发送SCRIPT KILL终止脚本,如果脚本已经执行了写操作,只能通过SHUTDOWN NOSAVE恢复。因此写脚本时要克制,单脚本不要做重量级计算,尽量拆小。
四、使用LDB调试器定位脚本问题
Redis内置了一个功能完善的Lua调试器LDB,支持断点、单步执行、变量查看。进入调试模式使用redis-cli --eval script.lua key1 key2 , arg1 arg2加上--ldb参数,注意逗号前后必须有空格,逗号前是key列表,逗号后是参数列表:
redis-cli --ldb --eval /path/to/script.lua mykey , hello 100
调试会话中常用命令包括:step单步执行,print打印变量值,break设置断点,list查看当前执行位置附近的源码,restart重新执行脚本。调试模式下脚本对数据的修改默认是回滚的,会话结束后数据自动恢复,这一点对生产环境附近的实例非常友好。如果需要在远程连接上调试,加上--ldb-sync-modes可以进入同步调试模式,但要注意调试期间服务器会阻塞。
除了LDB,日常排错还要熟悉几个高频坑点。第一个是整数除法:Lua 5.1中7/2的结果是3.5,如果希望整除要用math.floor(7/2),而tonumber转换失败返回nil后直接参与算术运算会直接抛错,所以转换后最好判空。第二个是nil拼接字符串:'prefix' .. nil会报错,处理GET返回的nil前必须先判断。第三个是redis.call的类型严格性,比如INCRBY传参必须保证是字符串或数字形式,传boolean进去会报错。把这些坑点记住,配合LDB断点查看中间变量,绝大多数脚本问题都能快速定位。
总结一下,写好Redis Lua脚本的关键在于三点:遵守KEYS传参规范保证集群兼容,控制脚本复杂度避免长时间阻塞,善用EVALSHA缓存和LDB调试提升开发与运行效率。掌握这些要点后,像分布式锁、限流、原子计数组合操作这类需求都可以用脚本优雅地解决。
Redis Lua脚本EVAL命令Lua脚本调试修改时间:2026-09-02 06:40:36