返回首页

Redis进阶4.2: Lua 脚本入门

适用版本:Redis 7.2.x

学 Redis 到事务这一章时,很多人会遇到一个很现实的问题:

  • MULTI / EXEC 可以把多条命令一起提交
  • WATCH 可以做乐观锁
  • 但如果业务逻辑本身就是“先读、再判断、再修改”,客户端代码还是会变得比较绕

这时,Lua 脚本就登场了。

Redis 允许我们把一小段 Lua 逻辑发送到服务端执行。这样一来,原本需要客户端发多次命令才能完成的逻辑,就可以压缩成一次原子执行。对很多“判断 + 更新”场景来说,这比事务更直接,也更不容易写错。

这篇文章会从“为什么需要 Lua”“怎么写最简单的脚本”“KEYSARGV 怎么用”“什么情况下适合用脚本”几个角度,把 Redis Lua 脚本入门这件事讲清楚。


一、为什么 Redis 需要 Lua 脚本

先看一个常见需求:扣库存。

业务要求通常是:

  1. 读取库存
  2. 判断库存是否大于等于本次购买数量
  3. 如果足够,则扣减
  4. 如果不足,则返回失败

如果只用普通命令,你可能会写成:

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 个 key
  • KEYS[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:key
  • ARGV[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:预加载脚本,返回 SHA1
  • EVALSHA:通过 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

如果单条命令就能解决,例如 INCRHINCRBYSET NX EX,就没必要额外上 Lua。


十六、小结

这篇文章可以浓缩成下面几个核心结论:

  1. Lua 脚本的核心价值,是把多步逻辑压缩成一次服务端原子执行
  2. EVAL 是最基础的执行方式,KEYSARGV 是标准传参方式
  3. Lua 非常适合“先判断再修改”的场景,例如扣库存、释放锁、限流
  4. 脚本应保持短小,避免长时间阻塞 Redis
  5. 高频脚本适合通过 SCRIPT LOAD + EVALSHA 复用

如果说事务 + WATCH 解决的是“客户端如何安全地并发更新”,那么 Lua 更像是在回答另一个问题:

能不能把这段关键逻辑直接放到 Redis 里,一次性做完?

对很多 Redis 工程实践来说,这正是从“会用命令”走向“会设计原子操作”的关键一步。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Redis进阶4.1: 事务与 WATCH 机制

下一篇

Redis进阶4.3:Pipeline 批量操作