Appearance
Redis Lua 脚本
1. 简介
Redis 中为什么引入 Lua 脚本?
Redis 是高性能的 key-value 内存数据库,在部分场景下,是对关系数据库的良好补充。
Redis 提供了非常丰富的指令集,官网上提供了 200 多个命令。但是某些特定领域,需要扩充若干指令原子性执行时,仅使用原生命令便无法完成。
Redis 为这样的用户场景提供了 lua 脚本支持,用户可以向服务器发送 lua 脚本来执行自定义动作,获取脚本的响应数据。Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。
Redis 意识到上述问题后,在 2.6 版本推出了 lua 脚本功能,允许开发者使用 Lua 语言编写脚本传到 Redis 中执行。使用脚本的好处如下:
- 减少网络开销:可以将多个请求通过脚本的形式一次发送,减少网络时延;
- 原子操作:Redis 会将整个脚本作为一个整体执行,中间不会被其他请求插入。因此在脚本运行过程中无需担心会出现竞态条件,无需使用事务;
- 复用:客户端发送的脚本会永久存在 Redis 中,这样其他客户端可以复用这一脚本,而不需要使用代码完成相同的逻辑;
什么是 Lua?
Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放。
其设计目的就是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。因为广泛的应用于:游戏开发、独立应用脚本、Web 应用脚本、扩展和数据库插件等。
比如:Lua 脚本用在很多游戏上,主要是 Lua 脚本可以嵌入到其他程序中运行,游戏升级的时候,可以直接升级脚本,而不用重新安装游戏。
Lua 脚本的基本语法可参考:菜鸟教程
2. Redis 中 Lua 的常用命令
命令不多,就下面这几个:
EVALEVALSHASCRIPT LOAD-SCRIPT EXISTSSCRIPT FLUSHSCRIPT KILL
2.1. EVAL 命令
命令格式:EVAL script numkeys key [key …] arg [arg …]
script参数是一段 Lua5.1 脚本程序。脚本不必也不应该定义为一个 Lua 函数;numkeys指定后续参数有几个 key,即:key [key …]中 key 的个数。如没有 key,则为0;key [key …]从EVAL的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),在 Lua 脚本中通过KEYS[1],KEYS[2]获取;arg [arg …]附加参数。在 Lua 脚本中通过ARGV[1]、ARGV[2]获取;
根据《Redis 设计与实现》中第 20 章的内容,Redis 服务端会将脚本中的具体内容封装到以 “f_40 位 sha 值” 命名的函数中,相当于 “f_40 位 sha 值” 是函数名,脚本内容是函数体
Bash
## 例 1:numkeys=1,keys 数组只有 1 个元素 key1,arg 数组无元素
127.0.0.1:6379> EVAL "return KEYS[1]" 1 key1
"key1"
## 例 2:numkeys=0,keys 数组无元素,arg 数组元素中有 1 个元素 value1
127.0.0.1:6379> EVAL "return ARGV[1]" 0 value1
"value1"
## 例 3:numkeys=2,keys 数组有两个元素 key1 和 key2,arg 数组元素中有两个元素 first 和 second
## 其实 {KEYS[1],KEYS[2],ARGV[1],ARGV[2]} 表示的是 Lua 语法中 “使用默认索引” 的 table 表,
## 相当于 java 中的 map 中存放四条数据。Key 分别为:1、2、3、4,而对应的 value 才是:KEYS[1]、KEYS[2]、ARGV[1]、ARGV[2]
## 举此例子仅为说明 eval 命令中参数的如何使用。项目中编写 Lua 脚本最好遵从 key、arg 的规范。
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
## 例 4:使用了 Redis 为 lua 内置的 redis.call 函数
## 脚本内容为:先执行 SET 命令,在执行 EXPIRE 命令
## numkeys=1,keys 数组有一个元素 userAge(代表 Redis 的 key)
## arg 数组元素中有两个元素:10(代表 userAge 对应的 value)和 60(代表 Redis 的存活时间)
127.0.0.1:6379> EVAL "redis.call('SET', KEYS[1], ARGV[1]);redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1;" 1 userAge 10 60
(integer) 1
127.0.0.1:6379> get userAge
"10"
127.0.0.1:6379> ttl userAge
(integer) 441
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
通过上面的例 4,我们可以发现,脚本中使用 redis.call() 去调用 redis 的命令。
在 Lua 脚本中,可以使用两个不同函数来执行 Redis 命令,它们分别是:redis.call() 和 redis.pcall()。
这两个函数的唯一区别在于它们使用不同的方式处理执行命令所产生的错误,差别如下:
错误处理
当
redis.call()在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因:Bash127.0.0.1:6379> lpush foo a (integer) 1 127.0.0.1:6379> eval "return redis.call('get', 'foo')" 0 (error) ERR Error running script (call to f_282297a0228f48cd3fc6a55de6316f31422f5d17): ERR Operation against a key holding the wrong kind of value1
2
3
4
5和
redis.call()不同,redis.pcall()出错时并不引发错误,而是返回一个带 err 域的 Lua 表,用于表示错误:Bash127.0.0.1:6379> EVAL "return redis.pcall('get', 'foo')" 0 (error) ERR Operation against a key holding the wrong kind of value1
2
2.2. SCRIPT LOAD 命令 和 EVALSHA 命令
SCRIPT LOAD命令格式:SCRIPT LOAD scriptEVALSHA命令格式:EVALSHA sha1 numkeys key [key …] arg [arg …]
这两个命令放在一起讲的原因是:EVALSHA 命令中的 sha1 参数,就是 SCRIPT LOAD 命令执行的结果。
SCRIPT LOAD 将脚本 script 添加到 Redis 服务器的脚本缓存中,并不立即执行这个脚本,而是会立即对输入的脚本进行求值。并返回给定脚本的 SHA1 校验和。如果给定的脚本已经在缓存里面了,那么不执行任何操作。
在脚本被加入到缓存之后,在任何客户端通过 EVALSHA 命令,可以使用脚本的 SHA1 校验和来调用这个脚本。脚本可以在缓存中保留无限长的时间,直到执行 SCRIPT FLUSH 为止。
Bash
## SCRIPT LOAD 加载脚本,并得到 sha1 值
127.0.0.1:6379> SCRIPT LOAD "redis.call('SET', KEYS[1], ARGV[1]);redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1;"
"6aeea4b3e96171ef835a78178fceadf1a5dbe345"
## EVALSHA 使用 sha1 值,并拼装和 EVAL 类似的 numkeys 和 key 数组、arg 数组,调用脚本。
127.0.0.1:6379> EVALSHA 6aeea4b3e96171ef835a78178fceadf1a5dbe345 1 userAge 10 60
(integer) 1
127.0.0.1:6379> get userAge
"10"
127.0.0.1:6379> ttl userAge
(integer) 431
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
2.3. SCRIPT EXISTS 命令
命令格式:SCRIPT EXISTS sha1 [sha1 …]
作用:给定一个或多个脚本的 SHA1 校验和,返回一个包含 0 和 1 的列表,表示校验和所指定的脚本是否已经被保存在缓存当中。
Bash
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345
1) (integer) 1
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe346
1) (integer) 0
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345 6aeea4b3e96171ef835a78178fceadf1a5dbe366
1) (integer) 1
2) (integer) 01
2
3
4
5
6
7
2
3
4
5
6
7
2.4. SCRIPT FLUSH 命令
命令格式:SCRIPT FLUSH
作用:清除 Redis 服务端所有 Lua 脚本缓存
Bash
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345
1) (integer) 1
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345
1) (integer) 01
2
3
4
5
6
2
3
4
5
6
2.5. SCRIPT KILL 命令
命令格式:SCRIPT FLUSH
作用:杀死当前正在运行的 Lua 脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。这个命令主要用于终止运行时间过长的脚本,比如一个因为 BUG 而发生无限 loop 的脚本,诸如此类。
假如当前正在运行的脚本已经执行过写操作,那么即使执行 SCRIPT KILL,也无法将它杀死,因为这是违反 Lua 脚本的原子性执行原则的。在这种情况下,唯一可行的办法是使用 SHUTDOWN NOSAVE 命令,通过停止整个 Redis 进程来停止脚本的运行,并防止不完整(half-written)的信息被写入数据库中。
3. Redis 执行 Lua 脚本文件
在第二章中介绍的命令,是在 Redis 客户端中使用命令进行操作。该章节介绍的是直接执行 Lua 的脚本文件。
3.1. 编写 Lua 脚本文件
Lua
local key = KEYS[1]
local val = redis.call("GET", key);
if val == ARGV[1]
then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
3.2. 执行 Lua 脚本文件
执行命令:redis-cli -a 密码 --eval 脚本路径 key [key …] , arg [arg …]
如:redis-cli -a 123456 --eval ./Redis_CompareAndSet.lua userName , zhangsan lisi
此处敲黑板,注意啦!!!
--eval而不是命令模式中的eval,一定要有前端的两个-;- 脚本路径后紧跟
key [key …],相比命令行模式,少了numkeys这个 key 数量值,key [key …]和arg [arg …]之间的,英文逗号前后必须有空格,否则死活都报错;
Bash
## Redis 客户端执行
127.0.0.1:6379> set userName zhangsan
OK
127.0.0.1:6379> get userName
"zhangsan"
## linux 服务器执行
## 第一次执行:compareAndSet 成功,返回 1
## 第二次执行:compareAndSet 失败,返回 0
$ redis-cli -a 123456 --eval Redis_CompareAndSet.lua userName , zhangsan lisi
(integer) 1
$ redis-cli -a 123456 --eval Redis_CompareAndSet.lua userName , zhangsan lisi
(integer) 01
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
4. 实例:使用 Lua 控制 IP 访问频率
需求:实现一个访问频率控制,某个 IP 在短时间内频繁访问页面,需要记录并检测出来,就可以通过 Lua 脚本高效的实现。
小声说明:本实例针对固定窗口的访问频率,而动态的非滑动窗口。即:如果规定一分钟内访问 10 次,记为超限。在本实例中前一分钟的最后一秒访问 9 次,下一分钟的第 1 秒又访问 9 次,不计为超限。
脚本如下:
Lua
local visitNum = redis.call('incr', KEYS[1])
if visitNum == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
if visitNum > tonumber(ARGV[2]) then
return 0
end
return 1;1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
演示如下:
Bash
## LimitIP:127.0.0.1 为 key,10 3 表示:同一 IP 在 10 秒内最多访问三次
## 前三次返回 1,代表未被限制;第四、五次返回 0,代表 127.0.0.1 这个 ip 已被拦截
$ redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
(integer) 1
$ redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
(integer) 1
$ redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
(integer) 1
$ redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
(integer) 0
$ redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
(integer) 01
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
5. 总结
- 通过上面一系列的介绍,对 Lua 脚本、Lua 基础语法有了一定了解,同时也学会在 Redis 中如何去使用 Lua 脚本去实现 Redis 命令无法实现的场景;
- 回头再思考文章开头提到的 Redis 使用 Lua 脚本的几个优点:减少网络开销、原子性、复用;
本文已简单介绍了 Redis 中如何使用 Lua 脚本,以及几个小实例应用。在下一篇中会介绍真实项目中的 “答题红包雨抢夺” 的实例和项目中是如何使用 Lua 解决问题。敬请期待!!!
参考资料
- 菜鸟教程 -> Lua 教程
- Redis 官方命令参考
- 《Redis 设计与实现》-黄健宏著
- 掘金小册 -> Redis 深度历险:核心原理与应用实践