适用版本:Redis 7.2.x
学 Redis 到事务这一章时,很多人会遇到一个很现实的问题:
MULTI/EXEC可以把多条命令一起提交WATCH可以做乐观锁- 但如果业务逻辑本身就是“先读、再判断、再修改”,客户端代码还是会变得比较绕
这时,Lua 脚本就登场了。
Redis 允许我们把一小段 Lua 逻辑发送到服务端执行。这样一来,原本需要客户端发多次命令才能完成的逻辑,就可以压缩成一次原子执行。对很多“判断 + 更新”场景来说,这比事务更直接,也更不容易写错。
这篇文章会从“为什么需要 Lua”“怎么写最简单的脚本”“KEYS 和 ARGV 怎么用”“什么情况下适合用脚本”几个角度,把 Redis Lua 脚本入门这件事讲清楚。
一、为什么 Redis 需要 Lua 脚本
先看一个常见需求:扣库存。
业务要求通常是:
- 读取库存
- 判断库存是否大于等于本次购买数量
- 如果足够,则扣减
- 如果不足,则返回失败
如果只用普通命令,你可能会写成:
GET stock:item:1001
DECRBY stock:item:1001 2
问题在于,这两步之间可能被其他客户端插入操作,导致并发竞争。
如果用事务 + WATCH,可以解决,但客户端要负责:
- 先监视 key
- 读取值
- 判断
- 开启事务
- 提交
- 冲突时重试
逻辑不算复杂,但代码容易变长。
而 Lua 脚本可以把它改写成“服务端一次执行”:
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local need = tonumber(ARGV[1])
if stock < need then
return 0
end
return redis.call('DECRBY', KEYS[1], need)
这样就把“读取 + 判断 + 修改”放到了 Redis 内部一次完成。
所以 Lua 的核心价值可以概括为一句话:
把多步业务逻辑收敛成一次服务端原子执行。
二、Redis 中的 Lua 脚本有什么特点
1. 脚本执行具有原子性
Redis 在执行 Lua 脚本时,会把整个脚本当成一个整体来跑。执行过程中,不会插入其他客户端命令。
这意味着:
- 脚本里的多条 Redis 操作是连续执行的
- 很适合实现“先判断再修改”的原子逻辑
2. 脚本运行在 Redis 服务端
因此它可以减少客户端和服务端之间的往返次数(RTT)。
例如原来要发 3 次命令:
GET- 判断
DECRBY
现在只需要发 1 次 EVAL。
3. 脚本本身应尽量短小
虽然 Lua 很强大,但 Redis 是单线程处理命令的。脚本运行太久,会阻塞其他请求。
所以一个重要原则是:
Lua 脚本适合做短小、明确、可快速完成的逻辑,不适合写成长流程业务代码。
三、第一个 Lua 脚本:从 EVAL 开始
Redis 执行 Lua 脚本最常见的命令是 EVAL。
基本格式:
EVAL script numkeys [key1 key2 ...] [arg1 arg2 ...]
参数含义:
script:Lua 脚本内容numkeys:有多少个 key 参数- 后面先放 key,再放普通参数
先看最简单的例子:
EVAL "return 'hello redis'" 0
返回结果:
"hello redis"
这说明:
- Redis 已经成功执行 Lua 代码
- 当前脚本不依赖任何 key 和参数,所以
numkeys是 0
四、在 Lua 脚本里访问 Redis:redis.call
Lua 脚本真正有用的地方,是它可以在脚本中调用 Redis 命令。
最常用的方法是:
redis.call(...)
例如:
EVAL "return redis.call('PING')" 0
返回:
"PONG"
再看一个读 key 的例子:
SET user:1:name alice
EVAL "return redis.call('GET', 'user:1:name')" 0
返回:
"alice"
这说明脚本里可以像平常一样调用 Redis 命令,只不过是通过 redis.call() 这个接口完成。
五、KEYS 和 ARGV:脚本参数的标准写法
在真实项目里,不应该把 key 名和值直接硬编码在脚本里,而应该从外部传入。
Redis 规定:
KEYS数组:存放 key 参数ARGV数组:存放普通参数
这是最标准、也最推荐的写法。
1. 读取 key 参数
SET counter:page 10
EVAL "return redis.call('GET', KEYS[1])" 1 counter:page
这里:
1表示有 1 个 keyKEYS[1]对应counter:page
返回结果:
"10"
2. 读取普通参数
EVAL "return ARGV[1]" 0 hello
返回:
"hello"
3. 同时使用 KEYS 和 ARGV
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 demo:key demo-value
这里:
KEYS[1] = demo:keyARGV[1] = demo-value
这就是写 Redis Lua 脚本时最基础的参数组织方式。
六、为什么官方强调把 key 放在 KEYS 中
这个要求不只是风格问题,还和 Redis 集群路由、命令分析有关。
如果脚本涉及的 key 没有通过 KEYS 显式传入,而是自己在脚本里拼接,可能带来几个问题:
- 客户端难以分析脚本访问了哪些 key
- 集群环境下不利于路由和校验
- 可维护性差,排查困难
所以实践中要尽量遵循:
- 所有参与 Redis 操作的 key,都通过
KEYS传入 - 业务参数通过
ARGV传入
七、一个真正有用的例子:安全扣库存脚本
下面写一个最常见的库存扣减脚本。
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if stock < amount then
return -1
end
return redis.call('DECRBY', KEYS[1], amount)
执行方式:
SET stock:item:1001 8
EVAL "local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if stock < amount then
return -1
end
return redis.call('DECRBY', KEYS[1], amount)" 1 stock:item:1001 3
可能返回:
(integer) 5
如果库存不足:
EVAL "local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if stock < amount then
return -1
end
return redis.call('DECRBY', KEYS[1], amount)" 1 stock:item:1001 10
返回:
(integer) -1
这个例子很重要,因为它体现了 Lua 的典型价值:
- 在脚本内部读取当前值
- 在脚本内部判断条件
- 在脚本内部完成修改
- 中间不暴露给其他客户端插队
八、返回值类型要有预期
Lua 脚本执行结束后,Redis 会把返回结果转换成客户端可理解的类型。
常见情况:
- 返回字符串
- 返回整数
- 返回数组
- 返回
nil
例如:
EVAL "return {1, 2, 3}" 0
返回:
1) (integer) 1
2) (integer) 2
3) (integer) 3
再比如:
EVAL "return nil" 0
返回空结果。
因此写脚本时,建议尽量约定明确的返回规范,比如:
1表示成功0表示条件不满足-1表示库存不足- 返回字符串表示错误原因
这样客户端处理起来更清晰。
九、redis.call 与 redis.pcall 的区别
除了 redis.call(),Lua 脚本里还有一个常见接口:
redis.pcall(...)
两者区别在于错误处理方式不同。
1. redis.call
如果命令执行出错,脚本会直接报错并终止。
示例:
return redis.call('LPOP', KEYS[1])
如果 KEYS[1] 对应的是字符串而不是列表,脚本就会报错。
2. redis.pcall
如果命令出错,会把错误作为返回值交给 Lua 继续处理,而不是直接中断整个脚本。
示例:
local resp = redis.pcall('LPOP', KEYS[1])
return resp
在入门阶段,大多数脚本使用 redis.call 就够了;但如果你希望对异常做更细粒度处理,redis.pcall 会更灵活。
十、EVALSHA:为什么脚本还要先加载再执行
如果一个脚本会被频繁调用,每次都把整段脚本正文传过去,其实比较浪费带宽和解析成本。
因此 Redis 提供了:
SCRIPT LOAD:预加载脚本,返回 SHA1EVALSHA:通过 SHA1 执行已加载脚本
示例:
SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
返回类似:
"a2eba4a6e7d6643bc4b14aa4f9e0eedd55dbae3d"
然后执行:
EVALSHA a2eba4a6e7d6643bc4b14aa4f9e0eedd55dbae3d 1 page:view:count
这会让 page:view:count 自增。
什么时候适合用 EVALSHA
- 脚本逻辑固定
- 调用频率高
- 希望减少重复传输脚本文本
很多 Redis 客户端库内部也会自动做“先尝试 EVALSHA,找不到再 EVAL”的封装。
十一、一个更完整的业务示例:安全释放分布式锁
这是 Lua 在 Redis 中最经典的实践之一。
问题背景:
- 客户端 A 通过
SET lock:order token123 NX EX 30加锁 - 业务执行过程中锁过期了
- 客户端 B 拿到了同一把新锁
- 此时 A 如果直接
DEL lock:order,就可能误删 B 的锁
正确做法是:只有锁的值仍然等于自己的 token,才允许删除。
Lua 脚本:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
执行方式:
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end" 1 lock:order token123
这个例子说明:
- Lua 不只是“能写脚本”
- 它真正适合实现那些“检查条件和修改动作必须绑定为一次原子执行”的场景
十二、Lua 脚本与事务怎么选
这是学习中经常碰到的问题。
适合事务 + WATCH 的情况
- 客户端本来就要参与复杂判断
- 允许冲突后重试
- 逻辑不太适合压缩到脚本里
适合 Lua 的情况
- 逻辑可以收敛为短小脚本
- 需要把“读、判断、写”做成一次原子操作
- 希望减少网络往返
- 热点 key 冲突较多,不想频繁重试
可以这样粗略记:
- 有明显的先读再写且逻辑不长:优先想想 Lua
- 需要客户端参与大量流程控制:再考虑事务 + WATCH
十三、脚本中的几个常见坑
1. 忘记做类型转换
Redis 命令返回的值很多时候是字符串,比较大小前要先 tonumber()。
错误写法:
local stock = redis.call('GET', KEYS[1])
if stock < ARGV[1] then
return 0
end
更稳妥的写法:
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local need = tonumber(ARGV[1])
2. 脚本过长、逻辑过重
Redis 执行脚本时会阻塞事件循环。脚本里如果有大量循环、复杂计算、访问过多 key,就会拖慢整个实例。
3. 把业务分支都堆进 Lua
Lua 是用来保证关键原子步骤的,不是拿来替代完整业务服务层的。订单计算、权限校验、复杂编排这类逻辑不适合塞进脚本里。
4. 不规范使用 KEYS / ARGV
不要把 key 名直接拼接在脚本字符串里;这样会让脚本难维护,也不利于集群场景处理。
十四、客户端示例:Python 调用 Lua 脚本
下面给一个简化版 Python 示例,帮助你理解在代码中如何落地。
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
lua = """
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if stock < amount then
return -1
end
return redis.call('DECRBY', KEYS[1], amount)
"""
script = r.register_script(lua)
result = script(keys=["stock:item:1001"], args=[2])
print(result)
这个写法的好处是:
- 脚本可以复用
- 客户端库通常会帮你处理
EVAL/EVALSHA - 调用参数比手工拼命令更清晰
十五、实践建议
1. 先把 Lua 用在“关键原子步骤”上
例如:
- 库存扣减
- 锁安全释放
- 限流计数 + 过期时间设置
- 幂等标记检查 + 写入
这些都是 Lua 非常擅长的场景。
2. 返回值设计成稳定协议
不要让客户端去猜“空值是不是失败”“字符串是不是成功”。建议一开始就约定好:
- 成功返回什么
- 失败返回什么
- 哪些值代表不同失败原因
3. 脚本尽量短、快、可复用
一段 Lua 如果读起来像一个“小函数”,通常就是好脚本;如果读起来像一个“迷你业务系统”,就该警惕了。
4. 高频脚本考虑缓存 SHA
重复执行的脚本建议使用 SCRIPT LOAD / EVALSHA,或者让客户端库帮你做脚本注册。
5. 写脚本前先想清楚:是否真的需要 Lua
如果单条命令就能解决,例如 INCR、HINCRBY、SET NX EX,就没必要额外上 Lua。
十六、小结
这篇文章可以浓缩成下面几个核心结论:
- Lua 脚本的核心价值,是把多步逻辑压缩成一次服务端原子执行
EVAL是最基础的执行方式,KEYS与ARGV是标准传参方式- Lua 非常适合“先判断再修改”的场景,例如扣库存、释放锁、限流
- 脚本应保持短小,避免长时间阻塞 Redis
- 高频脚本适合通过
SCRIPT LOAD+EVALSHA复用
如果说事务 + WATCH 解决的是“客户端如何安全地并发更新”,那么 Lua 更像是在回答另一个问题:
能不能把这段关键逻辑直接放到 Redis 里,一次性做完?
对很多 Redis 工程实践来说,这正是从“会用命令”走向“会设计原子操作”的关键一步。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!