Redis的Lua脚本

Redis2.6加入了对Lua脚本的支持。Lua脚本可以被用来扩展Redis的功能,并提供更好的性能。

在《Redis拾遗》中曾经引用了《Redis in Action》中的一套悲观锁的实现,使用Lua脚本实现同样的功能,性能提高1倍以上。在另一个自动补全的例子中,使用Lua脚本比WATH/MULTI/EXEC快了20倍。

基本用法

在Redis中使用EVAL命令来运行Lua脚本。其参数分三个部分,分别为Lua脚本、操作的键的个数与键值、其他参数。例如:

1
> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 foo bar

上边的命令相当于运行set foo bar。在参数中指定键值并不是必须的,但是在集群环境中,Redis通过分析参数中的键来确定脚本需要运行在哪些节点上。

在Lua脚本中调用Redis命令有两种方式,一种是如上边例子中的redis.call,另一种是redis.pcall。两者的区别是,当发生异常时,call会抛出异常终止程序,并返回错误信息。而pcall则会捕获异常并返回一个使用Lua Table表示的错误信息,但脚本会继续运行。在下边的例子中,将set误写为secall抛出异常,而pcall会捕获异常并继续执行。

1
2
> eval "redis.call('se', 'foo', 'bar');return 1" 0
(error) ERR Error running script (call to f_d6ca96827cc8fb5e8cdeacf0ccabcee83fb23513): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script
1
2
> eval "redis.pcall('se', 'foo', 'bar');return 1" 0
(integer) 1

Redis中的Lua环境载入的库有:basetablestringmathstructcjsoncmsgpackbitop

脚本缓存

所有运行过的脚本都会被Redis缓存下来,并计算出其SHA1值作为唯一标识。缓存的脚本没有超时,只能通过运行SCRIPT FLUSH命令清空整个脚本缓存。在Redis服务停止以后脚本缓存自动清空。

被缓存的脚本可以使用EVALSHA命令运行,只需要传入脚本的SHA1值而不是脚本内容,这样就可以节省大量的带宽资源。Redis提供了SCRIPT LOAD命令来载入脚本并生成SHA1值,同时,可以通过SCRIPT EXISTS命令判断SHA1值对应的脚本是否存在。

下边是一个简单的脚本缓存使用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> evalsha e0e1f9fabfc9d4800c877a703b823ac0578ff8db 0
(error) NOSCRIPT No matching script. Please use EVAL.
> script load 'return 1'
"e0e1f9fabfc9d4800c877a703b823ac0578ff8db"
> evalsha e0e1f9fabfc9d4800c877a703b823ac0578ff8db 0
(integer) 1
> script flush
OK
> evalsha e0e1f9fabfc9d4800c877a703b823ac0578ff8db 0
(error) NOSCRIPT No matching script. Please use EVAL.

Redis官方对于脚本缓存的推荐使用方式是,每次都优先使用EVALSHA尝试运行缓存的脚本,如果缓存没有命中,则使用EVAL。一般应用场景下,往往不会直接使用Redis Cli,而是依赖于特定的客服端库。而大部分的库都在内部实现了上述的优化,并对用户透明。下边是Spirng Data Redis官方文档中的相关描述:

The default ScriptExecutor optimizes performance by retrieving the SHA1 of the script and attempting first to run evalsha, falling back to eval if the script is not yet present in the Redis script cache.

下边代码片段摘自Spring官方的测试用例:

1
2
3
4
5
6
7
this.template = new StringRedisTemplate();
....
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("org/springframework/data/redis/core/script/increment.lua"));
script.setResultType(Long.class);
ScriptExecutor<String> scriptExecutor = new DefaultScriptExecutor<String>(template);
Long result = scriptExecutor.execute(script, Collections.singletonList("mykey"));

数据类型转换

在Lua脚本中使用callpcall调用Redis命令时,就需要将Lua的数据类型转成Redis的数据类型,同时Redis调用的返回值又需要转回到Lua的数据类型。下边两张表是他们互相转换的规则:

Redis类型到Lua类型的转换表:

Redis Lua
integer reply number
bulk reply string
multi bulk reply table (may have other Redis data types nested)
status reply table with a single ok field containing the status
error reply table with a single err field containing the error
Nil bulk reply and Nil multi bulk reply false boolean type

Lua类型到Redis类型转换表:

Lua Redis
number integer reply (the number is converted into an integer)
string bulk reply
table (array) multi bulk reply (truncated to the first nil inside the Lua array if any)
table with a single ok field status reply
table with a single err field error reply
boolean false Nil bulk reply
boolean true integer reply with value of 1

另外两条重要的规则:

  • Lua只有一个number表示数字类型,不区分整型与浮点型,在将Lua的number转换成Redis类型时,小数部分会被忽略。所以,如果需要返回浮点型的数值,需要转成Lua的string类型返回。
  • Lua的数组中基本上不会出现nils,所以在将Lua数组转到Redis类型时,当遇到nil,转换即停止。

在下边的例子中可以看到,Lua的table类型被转成了Redis的multi bulk reply,并且浮点数3.3333的小数位被省略了,同时在第一个nil处停止了转换:

1
2
3
4
5
> eval "return {1,2,3.3333,'foo',nil,'bar'}" 0
1) (integer) 1
2) (integer) 2
3) (integer) 3
4) "foo"

Redis提供了帮助生成状态与错误值的方法,分别为redis.status_replyredis.error_reply。脚本return {err="My Error"}return redis.error_reply("My Error")的结果是相同的。

原子性

Redis一次只运行一个命令,Lua脚本的运行与其他的Redis命令相同,都是原子操作。在Lua脚本运行的过程中,不会有其他命令运行,因此数据也不会被其他操作修改和读取。这和前边《Redis拾遗》中提到的,实现事务的MULTI/EXEC操作很像。所以,Lua脚本可以用来实现事物,它也是官方推荐的实现事物的方式,因为在复杂情境下,完全在服务端运行的Lua脚本的性能要优于需要多次网络交互的MULTI/EXEC操作。

在Lua脚本运行期间,Redis不能处理其他请求。所以,确保脚本轻量和快速运行非常重要。如果一个脚本运行的时间过长,就会超时,Redis默认的脚本运行超时是5秒钟,可以使用配置文件中的lua-time-limit进行调整。

如果脚本运行超时了,Redis并不是简单的杀死脚本,并继续提供服务,这样违反其原子性。超时后,Redis会记录超时的日志,并开始接受新的请求,但是对SCRIPT KILLSHUTDOWN NOSAVE之外的命令都只返回BUSY的错误。如果运行的脚本只是读取数据,还没有写入数据,这时就可以用SCRIPT KILL将其杀死,否则只能使用SHUTDOWN NOSAVE关闭服务器并放弃之前一段时间的更改,保证数据的一致性。

调试

Redis 3.2版加入Lua脚本的调试器。Lua调试器运行在服务器上,可以在客户端使用Redis Cli进行远程调试。默认情况下,调试会话不会阻塞服务器的正常运行,并且在同一个服务器上可以打开多个调试会话,数据在调试会话结束后会回滚。同时也提供了同步的调试会话,会阻塞服务器,并且不会回滚数据。调试器支持步近、断点、获取Lua变量值、跟踪Redis命令调用、无限循环与超时运行检测等功能。

使用ldb参数打开调试器:

1
redis-cli --ldb --eval ./script.lua key1 key2 , arg1 arg2

参考