Redis学习手册13—Lua脚本

Redis对Lua脚本的支持是从Redis 2.6.0版开始引入的,它可以让用户在Redis服务器内置的Lua解释器中执行指定的Lua脚本。被执行的Lua脚本可以直接调用 Redis命令,并使用Lua语言及其内置的函数库处理命令结果。

Lua脚本给Redis带来的变化

引入Lua脚本特性,为Redis带来了如下直观的变化:

  • 可以使用Lua脚本来很方便的扩展Redis服务器的功能。
  • Redis服务器以原子方式执行Lua脚本,在执行完整个Lua脚本及其包含的Redis命令之前,Redis服务器不会执行其客户端发送的命令或脚本,因此被执行的Lua脚本天生就具有原子性。
  • 虽然使用流水线加上事务同样可以达到一次执行多条Redis命令的目的,但Redis提供的Lua脚本缓存特性能够更为有效的减少带宽占用。
  • Redis在Lua环境中内置了一些非常有用的包,通过这些包,用户可以直接在服务器端对数据进行处理,然后把它们存储到数据库中,这可以有效的减少不必要的网络传输。

EVAL:执行脚本

使用EVAL命令可以执行给定的Lua脚本:

EVAL script numkeys key [key ... ] arg [arg ... ]

其中:

  • script参数用于传递脚本本身,因为 Redis 目前内置的是Lua 5.1版本的解释器,所以用户在脚本中也只能使用Lua 5.1版本的语法。
  • numkeys参数用于指定脚本需要处理的键数量,而之后的任意多个 key 参数则用于指定被处理的键。通过key传递的键可以在脚本中通过KEYS数组进行访问。根据 Lua的惯例,KYES数组的索引将从1开始。
  • 任意多个arg参数用于指定传递给脚本的附加参数,这些参数可以在脚本中使用ARGV数组进行访问,同样的索引从 1 开始。
127.0.0.1:6379> EVAL "return 'hello world'" 0
"hello world"

使用脚本执行Redis命令

Lua脚本的强大之处在于它可以让用户直接在脚本中执行Redis命令,这一点可以通过在脚本中调用 redis.call() 函数或者 redis.pcall() 函数来完成:

redis.call(command, ...)
redis.pcall(command, ...)

这两个函数接受的第一个参数都是被执行的Redis命令的名字,而后面跟着的则是任意多个命令参数。在Lua脚本中执行Redis命令所使用的格式与在redis-cli客户端中执行Redis命令的格式完全一样。

127.0.0.1:6379> eval "return redis.call('SET',KEYS[1],ARGV[1])" 1 "message" "hello world"
OK
127.0.0.1:6379> eval "return redis.call('ZADD',KEYS[1],ARGV[1],ARGV[2])" 1 "fruit-price" "8.5" "apple"
(integer) 1
127.0.0.1:6379> zrange "fruit-price" 0 -1 WITHSCORES
1) "apple"
2) "8.5"

redis.call() 函数和 redis.pcall() 函数都可以用于执行Redis命令,它们之间唯一的区别是处理错误的方式:

  • redis.call() 函数在执行命令出错时会引发一个Lua错误,迫使 EVAL 命令向调用者返回一个错误;
  • redis.pcall() 函数则会将错误包裹起来,并返回一个表示错误的Lua表格;

值转换

EVAL命令出现之前,Redis服务器中只有一个环境,那就是Redis命令执行器所在的环境。但是,随着 EVAL命令以及 Lua 解释器的出现,使得Redis服务器中同时存在了两种不同的环境。因为这两种环境使用的是不同的输入和输出,所以在这两种环境之间传递值将引发相应的转换操作:

  • 当Lua脚本通过 redis.call() 函数或者 redis.pcall() 函数执行Redis命令时,传入的Lua值将被转换成 Redis协议值;
  • redis.call() 函数或者 redis.pcall() 函数执行完Redis命令时,命令返回的Redis协议值将被转换成 Lua 值;
  • 当Lua脚本执行完毕并向 EVAL 命令的调用者返回结果时,Lua值将被转换成Redis协议值。

下表列出了Redis协议值转换成Lua值的规则:
Redis学习手册13—Lua脚本_第1张图片
下表是Lua值转换成Redis协议值的规则:

Redis学习手册13—Lua脚本_第2张图片
如上面表格中的规则所言,因为带有小数部分的Lua数字将被转换为Redis整数回复:

127.0.0.1:6379> eval "return 3.14" 0
(integer) 3

所以如果你想要向Redis返回一个小数,那么可以先使用Lua内置的 tostring() 函数将它转换成字符串,然后再返回:

127.0.0.1:6379> eval "return tostring(3.14)" 0
"3.14"

全局变量保护

为了防止预定义的Lua环境被污染,Redis只允许用户在Lua脚本中创建局部变量,而不允许创建全局变量,尝试在脚本中创建全局变量将引发错误:

127.0.0.1:6379> eval "local database='redis'; return database" 0
"redis"
127.0.0.1:6379> eval "number=10" 0
(error) ERR Error running script (call to f_a2754fa2d614ad76ecfd143acc06993bedf1f691): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'number'

在脚本中切换数据库

与普通的Redis客户端一样,Lua脚本也允许用户执行 SELECT 命令来切换数据库,但需要注意的是,不同版本的Redis在脚本中执行 SELECT 命令的效果不同:

  • 在Redis 2.8.12之前,用户在脚本中切换数据库之后,客户端使用的数据库也会进行相应的切换。
  • 在Redis 2.8.12以及以后的版本中,脚本执行 SELECT 命令只会对脚本自身产生影响,客户端的当前数据库不会发生改变。

脚本的原子性

Redis的Lua脚本与Redis的事务一样,都是以原子方式执行的:在Redis服务器开始执行 EVAL 命令之后,直到 EVAL 命令执行完毕并向调用者返回结果之前,Redis服务器只会执行 EVAL 命令给定的脚本及其包含的Redis命令调用,至于其他的客户端发送的命令请求则会被阻塞,直到 EVAL 命令执行完毕为止。

基于上述原因,用户在使用Lua脚本的时候,必须尽可能的保证脚本能够高效,快速的执行,从而避免因为独占服务器导致阻塞其他客户端。

命令行方式执行脚本

除了可以在redis-cli客户端中使用 EVAL 命令执行脚本之外,还可以通过 redis-cli客户端的 eval 选项,以命令行的方式执行脚本文件。

如下test.lua脚本文件:

redis.call("SET", KEYS[1], ARGV[1])
return redis.call("GET", KEYS[1])

在命令行中执行如下命令:

redis-cli --eval test.lua  'msg' , 'hello'
"hello"

注意:使用命令行执行脚本文件时,键名与参数之间的逗号前后必须要有空格间隔,如果缺少空格将引发错误

复杂度:EVAL命令的复杂度由被执行的脚本决定
版本要求:EVAL命令从Redis 2.6.0版本开始可用。

SCRIPT LOAD 和 EVALSHA

在定义脚本后,程序通常会重复的执行脚本。如果客户端每次执行脚本都需要将相同的脚本重新发送一次,显然会浪费网络带宽。为了解决上述问题,Redis提供了Lua脚本缓存功能,这一功能允许用户将给定的Lua脚本缓存在服务器中,然后根据Lua脚本的SHA1校验和直接调用脚本,从而避免了需要重复发送相同脚本的麻烦。

SCRIPT LOAD script

SCRIPT LOAD命令可以将用户发送的脚本缓存在服务器中,并返回脚本的SHA1校验和。

127.0.0.1:6379> script load "return 'hello world'"
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"

然后就可以通过EVALSHA命令来执行被缓存的脚本:

EVALSHA sha1 numkeys key [key ... ] arg [arg ... ]

除了第一个参数是脚本 SHA1校验和之外,其他的参数和EVAL命令的一样。

其实在使用EVAL命令执行脚本的时候,也会把脚本给缓存起来,只不过EVAL并不返回脚本的SHA1校验和,因此需要自己根据脚本生成SHA1校验和。

脚本管理

除了SCRIPT LOAD命令之外,Redis还提供了SCRIPT EXISTSSCRIPT FLUSHSCRIPT KILL 这3个命令来管理脚本。

SCRIPT EXISTS:检查脚本是否已被缓存

SCRIPT EXISTS命令接受一个或多个 SHA1校验和作为参数,检查这些校验和对应的脚本是否已经被缓存到了服务器中:

SCRIPT EXISTS sha1 [sha1 ... ]

如果某个校验和对应的脚本已经缓存到了服务器中,则返回 1,否则返回 0

SCRIPT FLUSH:移除所有已经缓存的脚本

执行SCRIPT FLUSH命令,可以移除所有已经缓存在服务器中的脚本:

SCRIPT FLUSH

成功移除所有缓存脚本之后,返回 0

SCRIPT KILL:强制停止正在运行的脚本

因为Lua脚本在执行的时候会独占Redis服务器,所以如果Lua脚本运行的时间太长,又或者因为编程错误导致脚本无法退出,那么就会导致其他客户端一直无法执行命令。因此Redis提供了 SCRIPT KILL命令,可以用来强制停止正在运行的脚本:

SCRIPT KILL

用户在执行SCRIPT KILL命令之后,服务器可能会有以下反应:

  • 如果正在运行的Lua脚本尚未执行过任何写命令,那么服务器将终止该脚本,然后回到正常状态,继续处理客户端的命令请求;
  • 如果正在运行的Lua脚本已经执行过写命令,并且因为该脚本尚未执行完毕,所以它写入的数据可能并不完整或者错误的,为了防止这些脏数据被保存到数据库中,服务器是不会直接终止脚本并回到正常状态的。在这种情况下,用户只能使用 SHUTDOWN nosave 命令,在不执行持久化操作的情况下关闭服务器,然后重启服务器让它回到正常状态。

内置函数库

Redis在Lua环境中内置了一些函数库,用户可以通过这些函数库对Redis服务器进行操作,或者对给定的数据进行处理,这些函数库分别是:

  • base包
  • table包
  • string包
  • math包
  • redis包
  • bit包
  • struct包
  • cjson包
  • cmsgpack包

其中basetablestring以及math包均为Lua标准库;redis包为调用Redis功能的专用包,而 bitsrtuctcjson以及cmsgpack包则是从外部引入的数据处理包。

redis包

除了前面介绍过的 redis.call() 函数和**redis.pcall()**函数之外,redis包还包含了以下函数:

  • redis.log()
  • redis.sha1hex()
  • redis.error_reply()
  • redis.status_reply()
  • redis.breakpoint()
  • redis.debug()
  • redis.replicate_commands()
  • redis.set_repl()

redis.log()函数

**redis.log()**函数用于在脚本中向Redis服务器写入日志,它接受一个日志等级和一条消息作为参数:

redis.log(loglevel, message)

其中loglevel的值可以是以下4个日志等级中的一个,这些日志等级与Redis服务器本身的日志等级完全一致:

  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE
  • redis.LOG_WARING

当给定的日志等级超过或等同于Redis服务器当前设置的日志等级时,Redis服务器就会把给定的消息写入日志中。

redis.sha1hex()函数

**redis.sha1hex()**函数可以计算出给定字符串的SHA1校验和:

redis.sha1hex(string)

127.0.0.1:6379> eval "return redis.sha1hex('show me your sha1')" 0
"e00ecdbe6ea77b31972c28dccad6aceba9822a12"

redis.error_reply()函数和redis.status_reply()函数

**redis.error_reply()函数和redis.status_reply()**是两个辅助函数,分别用于返回Redis的错误回复以及状态回复:

redis.error_reply(error_message)
redis.status_reply(status_message)

redis.error_reply() 函数会返回一个包含 err 字段的Lua表格,而 err 字段的值则是给定的错误消息;同样的,redis.status_reply() 函数将返回一个只包含 ok 字段的Lua表格,而 ok 字段的值则是给定的状态消息。

127.0.0.1:6379> eval "return redis.error_reply('something wrong')" 0
(error) something wrong
127.0.0.1:6379> eval "return redis.status_reply('all is well')" 0
all is well

bit包

bit 包可以对Lua脚本中的数字执行二进制按位操作,这个包从Redis 2.8.18版本开始可用。bit 包提供了将数字转换为十六进制字符串的 tohex() 函数,以及对给定数字执行 按位反按位或按位并 以及 按位异或bnot()bor()band()bxor() 等函数。

bit.tohex(x [, n])
bit.bnot(x)
bit.bor(x1 [, x2 ...])
bit.band(x1 [, x2 ... ])
bit.bxor(x1 [, x2 ... ])

127.0.0.1:6379> eval "return bit.tohex(65535)" 0
"0000ffff"
127.0.0.1:6379> eval "return bit.tohex(65535,4)" 0
"ffff"
127.0.0.1:6379> eval "return bit.tohex(bit.bnot(0xFFFF))" 0
"ffff0000"
127.0.0.1:6379> eval "return bit.tohex(bit.bor(0xF00F,0x0F00))" 0
"0000ff0f"

struct包

struct包提供了能够在Lua值以及C结构之间进行转换的基本设施,这个包提供了 pack()unpack()size() 这3个函数:

struct.pack(fmt, v1, v2, ...)
struct.unpack(fmt, s, [i])
struct.size(fmt)

其中 struct.pack() 用于将给定的一个或多个Lua值打包为一个类结构字符串,struct.unpack() 用于从给定的类结构字符串中解包出多个Lua值,而 struct.size() 函数则用于计算按照给定格式进行打包需要耗费的字节数量。

-- 打包一个浮点数、一个无符号长整数以及一个11字节长的字符串
127.0.0.1:6379> eval "return struct.pack('fLc11',3.14,10086,'hello world')" 0
"\xc3\xf5H@f'\x00\x00\x00\x00\x00\x00hello world"

-- 计算打包耗费的字节数
127.0.0.1:6379> eval "return struct.size('fLc11')" 0
(integer) 23

-- 对给定的类结构字符串进行解包
127.0.0.1:6379> eval "return {struct.unpack('fLc11',ARGV[1])}" 0 "\xc3\xf5H@f'\x00\x00\x00\x00\x00\x00hello world"
1) (integer) 3
2) (integer) 10086
3) "hello world"
4) (integer) 24

cjson包

cjson包能够为Lua脚本提供快速的JSON编码和解码操作,这个包中最常用的就是将Lua值编码为JSON数据的编码函数 encode(),以及将JSON数据解码为Lua值的解码函数 decode()

cjson.encode(value)
cjson.decode(json_text)

127.0.0.1:6379> eval "return cjson.encode({true,128,'hello world'})" 0
"[true,128,\"hello world\"]"
127.0.0.1:6379> eval "return cjson.decode(ARGV[1])" 0 "[true,128,\"hello world\"]"
1) (integer) 1
2) (integer) 128
3) "hello world"

cmsgpack函数

cmsgpack 函数能够为Lua脚本提供快速的 MessagePack 打包和解包操作,这个包中最常用的就是打包函数 pack() 以及解包函数 unpack(),前者可以将给定的任意多个Lua值打包为msgpack包,而后者则可以将给定的 msgpack 包解包为任意多个Lua值:

cmsgpack.pack(arg1, arg2, arg3 ... )
cmsgpack.unpack(msgpack)

127.0.0.1:6379> eval "return cmsgpack.pack({true,128,'hello world'})" 0
"\x93\xc3\xcc\x80\xabhello world"
127.0.0.1:6379> eval "return cmsgpack.unpack(ARGV[1])" 0 "\x93\xc3\xcc\x80\xabhello world"
1) (integer) 1
2) (integer) 128
3) "hello world"

脚本调试

在早期支持的Lua脚本功能的Redis版本中,为了对脚本进行调试,通常需要重复执行同一个脚本多次,并通过查看返回值的方式验证计算结果,这给脚本调试带来了很大的麻烦。为了解决这个问题,Redis从3.2版本开始引入了新的Lua调试器,这个调试器被称为 Redis Lua调试器,简称 LDB, 用户可以通过 LDB 实现单步调试添加断点返回日志打印调用链重载脚本 等多种功能。

redis-cli --ldb --eval demo.lua "msg" , "hello world"

如上述命令就可以对脚本 demo.lua 开启调试模式。然后可以使用 step命令或者 next命令进行单步调试,使用 print 命令来查看程序当前已有的局部变量以及它们的值。

调试命令

除了 next 命令和 print 命令外,Lua脚本调试器还提供了很多不同的调试命令,如下表所示:

Redis学习手册13—Lua脚本_第3张图片

断点

可以使用 break 命令给脚本添加断点,然后使用 continue 命令执行代码,直到遇到下一个断点为止。

例如如下脚本代码:

1 redis.call("echo", "line 1")
2 redis.call("echo", "line 2")
3 redis.call("echo", "line 3")
4 redis.call("echo", "line 4")
5 redis.call("echo", "line 5")

我们可以通过执行命令 break 3 5,分别在脚本的第3行和第5行添加断点:

lua debugger> break 3 5

动态断点

除了可以使用 break 命令添加断点之外,Redis还允许用户在脚本中通过调用 redis.breakpoint() 函数来添加动态断点,当调试器执行至 redis.breakpoint() 调用所在行时,调试器就会暂停执行过程并等待用户的指示。

if condition == true then
	redis.breakpoint()
	...
end

输出调试日志

使用 redis.debug() 函数能够直接把给定的值输出到调试客户端,使得用户可以方便的得知给定变量或者表达式的值。

执行指定的代码或命令

Lua调试器提供了 evalredis 这两个调试命令,用户可以使用前者来执行指定的Lua代码,并使用后者来执行指定的Redis命令,也可以通过这两个命令来快速地验证一些想法以及结果。

lua debugger> eval redis.sha1hex('hello world')

当在调试过程中需要知道某个键的值时,可以使用 redis 命令:

lua debugger> redis GET msg
 GET msg
 "hello world"

显示调用链

trace调试命令可以用来打印出脚本的调用链信息,这些信息在研究脚本的调用路径时非常有用。

重载脚本

restart 是一个非常重要的调试器命令,它可以让调试客户端重新载入被调试的脚本,并开启一个新的调试会话。

调试模式

Redis的Lua调试器支持两种不同的调试模式,一种是异步调试,另外一种是同步调试。当用户以 ldb 选项启动调试会话时,Redis服务器将以异步方式调试脚本:

redis-cli --ldb --eval script.lua

运行在异步调试模式下的Redis服务器会为每个调试器会话分别创建新的子进程,并将其用作调试进程:

  • 因为Redis服务器可以创建出任意多的子进程作为调试进程,所以异步调试允许多个调试会话同时存在。
  • 因为异步调试是在子进程而不是服务器进程上进行,它不会阻塞服务器进程,所以异步调试的过程中,其他客户端可以继续访问Redis服务器。
  • 因为异步调试期间执行的所有Lua代码以及Redis命令都是在子进程中进行的,所以在调试完成后,调试期间产生的所有数据修改也会随着子进程的终结而消失,它们不会对Redis服务器的数据库产生任何影响。

当用户以 ldb-sync-model 选项启动调试会话时,Redis服务器将以同步的方式调试脚本:

redis-cli --ldb-sync-model --eval script.lua

在同步调试模式下:

  • 因为同步调试不会创建任何子进程,而是直接使用服务器进程作为调试进程,所以同一时间只能有一个调试会话存在。
  • 因为同步调试直接在服务器进程上进行,它需要独占服务器,因此在调试期间,其他客户端的访问都会被阻塞。
  • 因为在同步调试期间,所有Lua代码以及Redis命令都是在服务器进程上执行的,所以调试期间产生的数据修改将保留在服务器的数据库中。

终止调试会话

在调试Lua脚本时,有3种方式可以终止调试会话:

  • 当脚本执行完毕时,调试会话自动终止。
  • 当用户在调试器中按下 Ctrl + C 键时,调试器将执行完整个脚本后终止调试会话。
  • 当用户在调试器中执行 abort 命令时,调试器不再执行任何代码,直接终止调试会话。

上一篇:Redis学习手册12—流水线与事务

下一篇:Redis学习手册14—持久化

你可能感兴趣的:(#,Redis)