Redis 中的 Lua 脚本到底是什么?小白也能看懂

最近在学习 Redis、分布式锁、优惠券秒杀这些内容时,经常会看到 Lua 脚本。

比如 Redisson 分布式锁底层会用 Lua,优惠券扣库存也经常会用 Lua。刚开始看到的时候会觉得很懵:Lua 是 Redis 特有的吗?为什么 Redis 里面还能写脚本?它的语法又是什么?

这篇文章就用小白能理解的方式,把 Redis Lua 脚本讲清楚。


一、Lua 脚本是 Redis 特有的吗?

不是。

Lua 本身是一门独立的脚本语言,和 Java、Python、JavaScript 一样,只是它比较轻量,常常被嵌入到其他软件里面使用。

比如:

Redis 可以执行 Lua
Nginx 可以使用 Lua
游戏开发里经常使用 Lua
一些嵌入式系统也会使用 Lua

所以准确来说:

Lua 不是 Redis 特有的,Redis 只是支持执行 Lua 脚本。

在 Java 后端开发里,我们最常见到 Lua 的场景,通常就是 Redis。


二、Redis 为什么要用 Lua?

Redis 本身每一条命令都是原子的。

比如:

INCR stock

这条命令执行时不会被其他命令打断。

但是如果一个业务逻辑需要多条 Redis 命令组合起来,就可能会出现并发问题。

比如优惠券秒杀场景:

1. 判断库存是否大于 0
2. 判断用户是否已经领取过
3. 库存减 1
4. 记录用户已经领取

如果这些操作放在 Java 代码里分多次执行:

String stock = redis.get("stock");

if (stock > 0) {
    redis.decr("stock");
    redis.sadd("coupon:users", userId);
}

中间可能会被其他线程插队。

比如库存只剩 1 个时,两个请求同时进来:

线程 A 看到 stock = 1
线程 B 也看到 stock = 1

线程 A 扣库存成功
线程 B 也扣库存成功

最后库存可能变成 -1

这就是典型的并发问题。

而 Lua 脚本可以把多条 Redis 命令打包成一个整体,让 Redis 一次性执行。

核心点是:

Redis 执行 Lua 脚本时,脚本整体是原子的。

也就是说,Lua 脚本执行过程中,不会被其他 Redis 命令插队。

所以 Redis 使用 Lua 的核心目的就是:

把多个 Redis 命令组合成一个原子操作。

三、Redis Lua 脚本长什么样?

先看一个 Redisson 分布式锁里类似的 Lua 逻辑:

if redis.call('exists', KEYS[1]) == 0 then
    redis.call('hset', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

return redis.call('pttl', KEYS[1])

刚开始不用完全看懂,先抓住几个关键点:

if ... then ... end      条件判断
redis.call(...)          调用 Redis 命令
KEYS[1]                  外部传进来的第 1 个 key
ARGV[1]                  外部传进来的第 1 个普通参数
return                   返回结果

Redis Lua 脚本里面最常用的东西,其实就这些。


四、redis.call 是什么?

redis.call() 是 Redis 提供给 Lua 脚本的函数。

可以理解为:

redis.call('Redis命令', '参数1', '参数2', ...)

它的作用就是:在 Lua 脚本里面调用 Redis 命令。

比如在 Redis 命令行里执行:

GET name

在 Lua 里就是:

redis.call('get', 'name')

Redis 命令行:

SET name frank

Lua 里:

redis.call('set', 'name', 'frank')

Redis 命令行:

HSET user:1 name frank

Lua 里:

redis.call('hset', 'user:1', 'name', 'frank')

所以可以简单记:

redis.call() = 在 Lua 脚本里执行 Redis 命令

五、KEYS 和 ARGV 是什么?

Redis 执行 Lua 脚本时,可以从外面传参数进去。

这些参数分成两类:

KEYS:专门用来传 Redis 的 key
ARGV:用来传普通参数

比如执行:

EVAL "return redis.call('get', KEYS[1])" 1 name

这里的含义是:

脚本内容:return redis.call('get', KEYS[1])
key 的数量:1
KEYS[1] = name

所以脚本里的:

redis.call('get', KEYS[1])

就等价于:

GET name

如果有两个 key 和一个普通参数:

EVAL "return {KEYS[1], KEYS[2], ARGV[1]}" 2 key1 key2 value1

对应关系就是:

KEYS[1] = key1
KEYS[2] = key2
ARGV[1] = value1

注意,Lua 的数组下标通常从 1 开始。

所以不是:

KEYS[0]
ARGV[0]

而是:

KEYS[1]
ARGV[1]

这一点和 Java 不一样。


六、Lua 基础语法

在 Redis 使用 Lua 时,不需要系统学习完整 Lua 语言。先掌握几个常用语法就够了。

1. 定义变量

Lua 定义变量使用 local

local name = "frank"
local age = 24

类似 Java 中的局部变量:

String name = "frank";
int age = 24;

Lua 不需要显式声明类型。


2. 字符串

Lua 字符串可以用单引号,也可以用双引号:

local name = "frank"
local city = 'nanjing'

3. 数字

local count = 10
local price = 99.9

4. 条件判断

Lua 的 if 写法是:

if 条件 then
    -- 执行逻辑
end

比如:

local stock = 10

if stock > 0 then
    return 1
end

带 else 的写法:

local stock = 0

if stock > 0 then
    return 1
else
    return 0
end

对应 Java:

if (stock > 0) {
    return 1;
} else {
    return 0;
}

5. 相等和不等判断

Lua 判断相等使用:

==

比如:

if stock == 0 then
    return 0
end

Lua 判断不等使用:

~=

比如:

if stock ~= 0 then
    return 1
end

注意,不是 Java 里的:

!=

6. 注释

单行注释:

-- 这是单行注释

多行注释:

--[[
这是多行注释
]]

七、Redis Lua 中要注意类型转换

Redis 中很多数据取出来默认是字符串。

比如 Redis 中有:

SET stock 10

Lua 中这样取:

local stock = redis.call('get', KEYS[1])

此时 stock 很可能是字符串 "10"

如果要做数字比较,最好使用 tonumber() 转成数字:

local stock = tonumber(redis.call('get', KEYS[1]))

if stock > 0 then
    return 1
else
    return 0
end

tonumber() 类似 Java 里的:

Integer.parseInt(stock)

所以 Redis Lua 里经常会看到:

tonumber(redis.call('get', KEYS[1]))

意思就是:

从 Redis 里取出值,然后转成数字。

八、最简单的 Lua 脚本例子

假设 Redis 中有:

SET stock 10

Lua 脚本:

local stock = redis.call('get', KEYS[1])
return stock

如果传入:

KEYS[1] = stock

那么这个脚本做的事情就是:

获取 stock 的值并返回。

逐行解释:

local stock = redis.call('get', KEYS[1])

表示从 Redis 中获取 KEYS[1] 对应的值,并保存到变量 stock 中。

return stock

表示返回这个值。


九、实战例子:判断库存是否充足

假设 Redis 中有:

SET coupon:stock 10

Lua 脚本:

local stock = tonumber(redis.call('get', KEYS[1]))

if stock <= 0 then
    return 0
end

return 1

含义是:

1. 从 Redis 获取库存
2. 转成数字
3. 如果库存小于等于 0,返回 0
4. 否则返回 1

返回值可以约定为:

0:库存不足
1:库存充足

十、实战例子:扣减库存

local stock = tonumber(redis.call('get', KEYS[1]))

if stock <= 0 then
    return 0
end

redis.call('decr', KEYS[1])

return 1

这段脚本做了三件事:

1. 获取库存
2. 判断库存是否大于 0
3. 如果库存充足,就执行库存减 1

因为这段 Lua 脚本是在 Redis 中一次性执行的,所以它整体是原子的。

不会出现两个请求同时看到库存为 1,然后都扣减成功的问题。


十一、实战例子:防止用户重复领取优惠券

优惠券秒杀场景中,通常不只是扣库存,还要防止同一个用户重复领取。

假设有两个 key:

KEYS[1] = 优惠券库存 key
KEYS[2] = 用户领取记录 key

比如:

KEYS[1] = coupon:1001:stock
KEYS[2] = coupon:1001:users

其中:

coupon:1001:stock 表示优惠券库存
coupon:1001:users 表示已经领取过该优惠券的用户集合

然后传入一个普通参数:

ARGV[1] = userId

完整 Lua 脚本如下:

local stock = tonumber(redis.call('get', KEYS[1]))

if stock <= 0 then
    return 0
end

local isMember = redis.call('sismember', KEYS[2], ARGV[1])

if isMember == 1 then
    return 2
end

redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])

return 1

返回值可以约定为:

0:库存不足
1:领取成功
2:用户已经领取过

十二、逐行解释优惠券 Lua 脚本

local stock = tonumber(redis.call('get', KEYS[1]))

从 Redis 中获取优惠券库存,并转成数字。


if stock <= 0 then
    return 0
end

如果库存小于等于 0,说明库存不足,直接返回 0


local isMember = redis.call('sismember', KEYS[2], ARGV[1])

判断当前用户是否已经领取过该优惠券。

它等价于 Redis 命令:

SISMEMBER coupon:1001:users userId

if isMember == 1 then
    return 2
end

如果用户已经领取过,就返回 2


redis.call('decr', KEYS[1])

优惠券库存减 1。


redis.call('sadd', KEYS[2], ARGV[1])

把当前用户 ID 添加到领取记录集合中。


return 1

返回领取成功。


十三、Redis 里怎么执行 Lua 脚本?

可以使用 EVAL 命令。

基本格式:

EVAL "Lua脚本" key数量 key1 key2 arg1 arg2

比如:

EVAL "return redis.call('get', KEYS[1])" 1 name

表示:

执行脚本:return redis.call('get', KEYS[1])
key 数量:1
KEYS[1] = name

如果有两个 key,一个普通参数:

EVAL "return {KEYS[1], KEYS[2], ARGV[1]}" 2 key1 key2 value1

对应关系是:

KEYS[1] = key1
KEYS[2] = key2
ARGV[1] = value1

十四、Java 中如何执行 Redis Lua?

在 Spring Boot 中,一般使用 StringRedisTemplate 执行 Lua 脚本。

示例:

DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();

redisScript.setScriptText("""
        local stock = tonumber(redis.call('get', KEYS[1]))

        if stock <= 0 then
            return 0
        end

        redis.call('decr', KEYS[1])

        return 1
        """);

redisScript.setResultType(Long.class);

Long result = stringRedisTemplate.execute(
        redisScript,
        List.of("coupon:1001:stock")
);

这里:

List.of("coupon:1001:stock")

对应 Lua 中的:

KEYS[1]

如果还要传用户 ID:

Long result = stringRedisTemplate.execute(
        redisScript,
        List.of("coupon:1001:stock", "coupon:1001:users"),
        userId
);

那么对应关系就是:

KEYS[1] = coupon:1001:stock
KEYS[2] = coupon:1001:users
ARGV[1] = userId

十五、Redis Lua 最常用语法总结

初学 Redis Lua,不需要一开始就学完整 Lua 语言。

先记住下面这些就够了:

-- 定义变量
local value = "hello"

-- 调用 Redis 命令
local stock = redis.call('get', KEYS[1])

-- 转成数字
local num = tonumber(stock)

-- 条件判断
if num > 0 then
    return 1
else
    return 0
end

常用关键点:

local:定义局部变量
if then end:条件判断
return:返回结果
tonumber:转成数字
redis.call:调用 Redis 命令
KEYS[n]:外部传进来的 Redis key
ARGV[n]:外部传进来的普通参数

十六、Redis Lua 常见使用场景

Redis Lua 在后端开发中非常常见,尤其是高并发场景。

常见用途包括:

1. 分布式锁
2. 秒杀扣库存
3. 优惠券领取
4. 防重复提交
5. 接口限流
6. 原子判断 + 原子修改

比如 Redisson 的分布式锁底层就大量使用 Lua 脚本。

优惠券系统中,判断库存、扣减库存、记录用户领取信息,也很适合使用 Lua。


十七、面试怎么回答 Redis Lua?

可以这样回答:

Lua 不是 Redis 特有的,它是一门轻量级脚本语言。Redis 支持执行 Lua 脚本,主要是为了把多个 Redis 命令组合成一个原子操作。比如优惠券秒杀场景中,需要判断库存、判断用户是否重复领取、扣减库存、记录用户领取信息。如果这些操作在 Java 里分多次执行,可能会出现并发问题;放到 Lua 脚本里交给 Redis 执行,可以保证脚本整体的原子性。

再补充一句:

Redis Lua 常用于分布式锁、秒杀扣库存、接口限流、防重复提交等场景。

这样回答就比较完整。


十八、总结

Lua 不是 Redis 特有的,它是一门独立的轻量级脚本语言。

Redis 支持 Lua 的主要原因,是为了让开发者可以把多条 Redis 命令组合成一个原子操作。

对于 Java 后端开发来说,学习 Redis Lua 不需要一开始就系统掌握完整 Lua 语言。先掌握下面几个点就够了:

1. redis.call() 用来调用 Redis 命令
2. KEYS[n] 表示外部传入的 Redis key
3. ARGV[n] 表示外部传入的普通参数
4. local 用来定义变量
5. if then end 用来做条件判断
6. tonumber() 用来把字符串转成数字
7. Lua 脚本在 Redis 中整体原子执行

可以把 Redis Lua 理解成:

把多条 Redis 命令打包成一个小程序,然后一次性交给 Redis 执行。

这就是 Redis Lua 最核心的价值。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐