最近在做K线的项目中,需要计算商品的分时数据。为了保证多台机器对同一商品的计算的有序性,所以在Redis中进行计算,同时为了保证在分时数据计算过程的原子性所以使用了LUA脚本,Redis内置了对LUA脚本的支持,并且在计算过程中保证了脚本中执行的原子性。因此在开发过程中对Redis对Lua的支持进行了学习。从 Redis 2.6.0 版本开始,通过内置的 Lua 解释器,可以使用EVAL命令对 Lua 脚本进行求值。以下将Redis对LUA的支持进行总结。
EVAL
从Redis2.6.0版本开始,通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。EVAL命令的格式如下:
EVAL script numkeys key [key ...] arg [arg ...]
script参数是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。numkeys参数用于指定键名参数的个数。键名参数 key [key ...] 从EVAL的第三个参数开始算起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在 Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。在命令的最后,那些不是键名参数的附加参数 arg [arg ...] ,可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。例如
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
其中 "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 是被求值的Lua脚本,数字2指定了键名参数的数量, key1和key2是键名参数,分别使用 KEYS[1] 和 KEYS[2] 访问,而最后的 first 和 second 则是附加参数,可以通过 ARGV[1] 和 ARGV[2] 访问它们。在 Lua 脚本中,可以使用两个不同函数来执行Redis命令,它们分别是:
redis.call()
redis.pcall()
这两个函数的唯一区别在于它们使用不同的方式处理执行命令所产生的错误。redis.call() 和 redis.pcall() 两个函数的参数可以是任何格式良好(well formed)的 Redis 命令:
> eval "return redis.call('set','foo','bar')" 0
OK
需要注意的是,上面这段脚本的确实现了将键 foo 的值设为 bar 的目的,但是,它违反了 EVAL 命令的语义,因为脚本里使用的所有键都应该由 KEYS 数组来传递,就像这样:
> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK
要求使用正确的形式来传递键(key)是有原因的,因为不仅仅是EVAL这个命令,所有的Redis命令,在执行之前都会被分析,籍此来确定命令会对哪些键进行操作。因此,对于EVAL命令来说,必须使用正确的形式来传递键,才能确保分析工作正确地执行。除此之外,使用正确的形式来传递键还有很多其他好处,它的一个特别重要的用途就是确保Redis集群可以将你的请求发送到正确的集群节点。
当Lua通过call()或 pcall()函数执行Redis命令的时候,命令的返回值会被转换成Lua数据结构。同样地,当Lua脚本在 Redis 内置的解释器里运行时Lua脚本的返回值也会被转换成Redis协议(protocol),然后由EVAL将值返回给客户端。Lua 类型和 Redis 类型之间存在着一一对应的转换关系。以下列出的是详细的转换规则:
- Redis integer reply -> Lua number / Redis 整数转换成 Lua 数字
- Redis bulk reply -> Lua string / Redis bulk 回复转换成 Lua 字符串
- Redis multi bulk reply -> Lua table (may have other Redis data types nested) / Redis 多条 bulk 回复转换成 Lua 表,表内可能有其他别的 Redis 数据类型
- Redis status reply -> Lua table with a single ok field containing the status / Redis 状态回复转换成 Lua 表,表内的 ok 域包含了状态信息
- Redis error reply -> Lua table with a single err field containing the error / Redis 错误回复转换成 Lua 表,表内的 err 域包含了错误信息
- Redis Nil bulk reply and Nil multi bulk reply -> Lua false boolean type / Redis 的 Nil 回复和 Nil 多条回复转换成 Lua 的布尔值 false
- Lua boolean true -> Redis integer reply with value of 1 / Lua 布尔值 true 转换成 Redis 整数回复中的 1
以下是几个类型转换的例子:
> eval "return 10" 0
(integer) 10 > eval "return {1,2,{3,'Hello World!'}}" 0
1) (integer) 1
2) (integer) 2
3) 1) (integer) 3
2) "Hello World!" > eval "return redis.call('get','foo')" 0
"bar"
Redis使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难,因为脚本的运行开销(overhead)非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。
redis.call() 和 redis.pcall() 的唯一区别在于它们对错误处理的不同。当 redis.call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因:
redis> lpush foo a
(integer) 1 redis> 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 value
和 redis.call() 不同, redis.pcall() 出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误:
redis 127.0.0.1:6379> EVAL "return redis.pcall('get', 'foo')" 0
(error) ERR Operation against a key holding the wrong kind of value
EVAL 命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。为了减少带宽的消耗, Redis 实现了 EVALSHA 命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)。EVALSHA 命令的表现如下:
- 如果服务器还记得给定的 SHA1 校验和所指定的脚本,那么执行这个脚本
- 如果服务器不记得给定的 SHA1 校验和所指定的脚本,那么它返回一个特殊的错误,提醒用户使用 EVAL 代替 EVALSHA
以下是示例:
> set foo bar
OK > eval "return redis.call('get','foo')" 0
"bar" > evalsha 6b1bf486c81ceb7edf3c093f4c48582e38c0e791 0
"bar" > evalsha ffffffffffffffffffffffffffffffffffffffff 0
(error) `NOSCRIPT` No matching script. Please use [EVAL](/commands/eval).
客户端库的底层实现可以一直乐观地使用 EVALSHA 来代替 EVAL ,并期望着要使用的脚本已经保存在服务器上了,只有当 NOSCRIPT 错误发生时,才使用 EVAL 命令重新发送脚本,这样就可以最大限度地节省带宽。这也说明了执行 EVAL 命令时,使用正确的格式来传递键名参数和附加参数的重要性:因为如果将参数硬写在脚本中,那么每次当参数改变的时候,都要重新发送脚本,即使脚本的主体并没有改变,相反,通过使用正确的格式来传递键名参数和附加参数,就可以在脚本主体不变的情况下,直接使用 EVALSHA 命令对脚本进行复用,免去了无谓的带宽消耗。
Redis 保证所有被运行过的脚本都会被永久保存在脚本缓存当中,这意味着,当 EVAL 命令在一个 Redis 实例上成功执行某个脚本之后,随后针对这个脚本的所有 EVALSHA 命令都会成功执行。刷新脚本缓存的唯一办法是显式地调用 SCRIPT FLUSH 命令,这个命令会清空运行过的所有脚本的缓存。缓存可以长时间储存而不产生内存问题的原因是,它们的体积非常小,而且数量也非常少,即使脚本在概念上类似于实现一个新命令,即使在一个大规模的程序里有成百上千的脚本,即使这些脚本会经常修改,即便如此,储存这些脚本的内存仍然是微不足道的。事实上,用户会发现 Redis 不移除缓存中的脚本实际上是一个好主意。比如说,对于一个和 Redis 保持持久化链接(的程序来说,它可以确信,执行过一次的脚本会一直保留在内存当中,因此它可以在流水线中使用 EVALSHA 命令而不必担心因为找不到所需的脚本而产生错误(稍候我们会看到在流水线中执行脚本的相关问题)。Redis 提供了以下几个 SCRIPT 命令,用于对脚本子系统(scripting subsystem)进行控制:
- SCRIPT FLUSH :清除所有脚本缓存
- SCRIPT EXISTS :根据给定的脚本校验和,检查指定的脚本是否存在于脚本缓存
- SCRIPT LOAD :将一个脚本装入脚本缓存,但并不立即运行它
- SCRIPT KILL :杀死当前正在运行的脚本
在编写脚本方面,一个重要的要求就是,脚本应该被写成纯函数(pure function)。也就是说,脚本应该具有以下属性:
- 对于同样的数据集输入,给定相同的参数,脚本执行的Redis写命令总是相同的。脚本执行的操作不能依赖于任何隐藏(非显式)数据,不能依赖于脚本在执行过程中、或脚本在不同执行时期之间可能变更的状态,并且它也不能依赖于任何来自I/O设备的外部输入。
使用系统时间(system time)、调用像RANDOMKEY那样的随机命令、或者使用 Lua 的随机数生成器,类似以上的这些操作,都会造成脚本的求值无法每次都得出同样的结果。为了确保脚本符合上面所说的属性, Redis做了以下工作:
- Lua没有访问系统时间或者其他内部状态的命令
- Redis会返回一个错误,阻止这样的脚本运行: 这些脚本在执行随机命令之后(比如RANDOMKEY 、 SRANDMEMBER或TIME等),还会执行可以修改数据集的Redis命令。如果脚本只是执行只读操作,那么就没有这一限制。注意,随机命令并不一定就指那些带RAND字眼的命令,任何带有非确定性的命令都会被认为是随机命令,比如TIME命令就是这方面的一个很好的例子。
- 每当从Lua脚本中调用那些返回无序元素的命令时,执行命令所得的数据在返回给Lua之前会先执行一个静默(slient)的字典序排序(lexicographical sorting)。举个例子,因为 Redis的Set保存的是无序的元素,所以在Redis命令行客户端中直接执行SMEMBERS ,返回的元素是无序的,但是,假如在脚本中执行 redis.call("smembers", KEYS[1]) ,那么返回的总是排过序的元素。
- 对Lua的伪随机数生成函数math.random和math.randomseed进行修改,使得每次在运行新脚本的时候,总是拥有同样的 seed 值。这意味着,每次运行脚本时,只要不使用 math.randomseed ,那么 math.random产生的随机数序列总是相同的。
为了防止不必要的数据泄漏进Lua环境, Redis脚本不允许创建全局变量。如果一个脚本需要在多次执行之间维持某种状态,它应该使用Redis key来进行状态保存。企图在脚本中访问一个全局变量(不论这个变量是否存在)将引起脚本停止, EVAL命令会返回一个错误:
redis 127.0.0.1:6379> eval 'a=10' 0
(error) ERR Error running script (call to f_933044db579a2f8fd45d8065f04a8d0249383e57): user_script:1: Script attempted to create global variable 'a'
Lua的debug工具,或者其他设施,比如打印(alter用于实现全局保护的meta table ,都可以用于实现全局变量保护。实现全局变量保护并不难,不过有时候还是会不小心而为之。一旦用户在脚本中混入了 Lua 全局状态,那么 AOF持久化和复制(replication)都会无法保证,所以,请不要使用全局变量。避免引入全局变量的一个诀窍是:将脚本中用到的所有变量都使用 local 关键字定义为局部变量。
Redis内置的Lua解释器加载了以下Lua库:base、table、string、math、debug、cjson、cmsgpack。其中cjson库可以让Lua以非常快的速度处理JSON数据,除此之外,其他别的都是Lua的标准库。每个Redis实例都保证会加载上面列举的库,从而确保每个 Redis 脚本的运行环境都是相同的。
在Lua脚本中,可以通过调用redis.log函数来写Redis日志(log):redis.log(loglevel, message)其中, message 参数是一个字符串,而 loglevel 参数可以是以下任意一个值:
- redis.LOG_DEBUG
- redis.LOG_VERBOSE
- redis.LOG_NOTICE
- redis.LOG_WARNING
上面的这些等级(level)和标准 Redis 日志的等级相对应。对于脚本散发(emit)的日志,只有那些和当前 Redis 实例所设置的日志等级相同或更高级的日志才会被散发。以下是一个日志示例:
redis.log(redis.LOG_WARNING, "Something is wrong with this script.")
执行上面的函数会产生这样的信息:
[32343] 22 Mar 15:21:39 # Something is wrong with this script.
脚本应该仅仅用于传递参数和对Redis数据进行处理,它不应该尝试去访问外部系统(比如文件系统),或者执行任何系统调用。除此之外,脚本还有一个最大执行时间限制,它的默认值是5秒钟,一般正常运作的脚本通常可以在几分之几毫秒之内完成,花不了那么多时间,这个限制主要是为了防止因编程错误而造成的无限循环而设置的。最大执行时间的长短由lua-time-limit选项来控制(以毫秒为单位),可以通过编辑 redis.conf 文件或者使用 CONFIG GET 和 CONFIG SET 命令来修改它。当一个脚本达到最大执行时间的时候,它并不会自动被Redis 结束,因为Redis必须保证脚本执行的原子性,而中途停止脚本的运行意味着可能会留下未处理完的数据在数据集(data set)里面。因此,当脚本运行的时间超过最大执行时间后,以下动作会被执行:
- Redis记录一个脚本正在超时运行
- Redis开始重新接受其他客户端的命令请求,但是只有SCRIPT KILL和SHUTDOWN NOSAVE两个命令会被处理,对于其他命令请求, Redis服务器只是简单地返回BUSY错误。
- 可以使用 SCRIPT KILL 命令将一个仅执行只读命令的脚本杀死,因为只读命令并不修改数据,因此杀死这个脚本并不破坏数据的完整性
- 如果脚本已经执行过写命令,那么唯一允许执行的操作就是 SHUTDOWN NOSAVE ,它通过停止服务器来阻止当前数据集写入磁盘
在流水线请求的上下文中使用EVALSHA命令时,要特别小心,因为在流水线中,必须保证命令的执行顺序。一旦在流水线中因为EVALSHA命令而发生NOSCRIPT错误,那么这个流水线就再也没有办法重新执行了,否则的话,命令的执行顺序就会被打乱。为了防止出现以上所说的问题,客户端库实现应该实施以下的其中一项措施:
- 总是在流水线中使用EVAL命令
- 检查流水线中要用到的所有命令,找到其中的 EVAL命令,并使用 SCRIPT EXISTS 命令检查要用到的脚本是不是全都已经保存在缓存里面了。如果所需的全部脚本都可以在缓存里找到,那么就可以放心地将所有 EVAL 命令改成 EVALSHA 命令,否则的话,就要在流水线的顶端(top)将缺少的脚本用 SCRIPT LOAD 命令加上去。
EVALSHA
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
根据给定的sha1校验码,对缓存在服务器中的脚本进行求值。将脚本缓存到服务器的操作可以通过 SCRIPT LOAD 命令进行。这个命令的其他地方,比如参数的传入方式,都和 EVAL 命令一样。
redis> SCRIPT LOAD "return 'hello moto'"
"232fd51614574cf0867b83d384a5e898cfd24e5a"
redis> EVALSHA "232fd51614574cf0867b83d384a5e898cfd24e5a" 0
"hello moto"
SCRIPT EXISTS
SCRIPT EXISTS sha1 [sha1 ...]
给定一个或多个脚本的SHA1校验和,返回一个包含0和1的列表,表示校验和所指定的脚本是否已经被保存在缓存当中。返回值:
- 一个列表,包含0和1 ,前者表示脚本不存在于缓存,后者表示脚本已经在缓存里面了。
- 列表中的元素和给定的SHA1校验和保持对应关系,比如列表的第三个元素的值就表示第三个SHA1校验和所指定的脚本在缓存中的状态。
redis> SCRIPT LOAD "return 'hello moto'" # 载入一个脚本
"232fd51614574cf0867b83d384a5e898cfd24e5a" redis> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a
1) (integer) 1 redis> SCRIPT FLUSH # 清空缓存
OK redis> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a
1) (integer) 0
SCRIPT KILL
杀死当前正在运行的Lua脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。这个命令主要用于终止运行时间过长的脚本,比如一个因为BUG而发生无限loop的脚本,诸如此类。SCRIPT KILL执行之后,当前正在运行的脚本会被杀死,执行这个脚本的客户端会从EVAL命令的阻塞当中退出,并收到一个错误作为返回值。另一方面,假如当前正在运行的脚本已经执行过写操作,那么即使执行 SCRIPT KILL ,也无法将它杀死,因为这是违反 Lua 脚本的原子性执行原则的。在这种情况下,唯一可行的办法是使用 SHUTDOWN NOSAVE 命令,通过停止整个 Redis 进程来停止脚本的运行,并防止不完整(half-written)的信息被写入数据库中。