14. Redis中引入Lua脚本

楔子

这次我们来说一下如何在Redis中嵌入Lua脚本,Lua和Python一样,是一门脚本语言。只不过Lua解释器非常的精简,所以它不具备像Python一样独立开发大型应用程序的能力,它的目的就是为别的语言提供扩展功能的。一般都会嵌入到C++中,我们知道C++在编译的时候是比较耗时的,而我们每做一次修改都要重新编译,这是让人有点难以接受的,所以这个时候就可以把那些非性能核心的代码交给Lua去做。

当然Lua也是可以嵌入在Python中的,Python有一个第三方模块叫lupa,完全实现了Lua解释器的功能。所以你使用Python的lupa模块话,甚至都不需要安装Lua环境就可以执行,我们举个栗子:

import lupa

# 调用lupa.LuaRuntime实例化一个Lua解释器(运行时)
Lua = lupa.LuaRuntime()

# 随便写一些Lua代码
Lua_code = """
function (a, b)
    if a >= b then return a + b, a - b
    else return a + b, b - a
    end
end
"""
print(Lua.eval(Lua_code))  # <Lua function at 0x000001CC73B4C4E0>
print(Lua.eval(Lua_code)(11, 22))  # (33, 11)
print(Lua.eval(Lua_code)(22, 8))  # (30, 14)

我们甚至可以在Lua.eval中写Python的语法,主要原因就在于这里不是通过Lua解释器调用、再返回结果给Python,而是这个lupa模块已经完全实现了Lua解释器的功能,在支持Lua语法 的同时,还对Python多了一些照顾。

关于Lua的语法,这里不再赘述了,可以网上搜索,这门语言非常简单,基本上一天入门足矣,当然我在其它系列中也介绍过,可以去找一找。

而最关键的是Redis中也可以嵌入Lua脚本,同样可以为Redis提供扩展功能,比如我们上一篇介绍的分布式锁,就可以是使用Lua来实现,我们后面会说,目前先来看看Redis中如何引入Lua脚本吧。

Redis中引入Lua脚本

Redis中引入Lua脚本还有一个好处,那就是执行Lua脚本的时候是原子性的。我们知道Redis不支持事务回滚,中间一个命令出错,那么后面的命令依旧可以执行,当然这也是和Redis的定位有关系,人家设计的时候就是这么设计的。

但如果我们引入的是Lua脚本,那么就可以保证整体事务性,要么都成功要么都失败。

下面我们介绍Redis中如何执行Lua语言,首先Redis可以执行字符串形式的Lua代码,也可以将Lua代码写在文件里让Redis执行。

eval

命令:eval script numkeys key[key···] arg[arg···]

127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 name age hanser 28
1) "name"
2) "age"
3) "hanser"
4) "28"
127.0.0.1:6379>
  • script:Lua脚本,直接写上一个返回值即可,显然这返回的是Lua中的表。
  • numkeys:keys参数的个数。
  • key、arg:分别对应键和值,键可以通过KEYS[索引]获取,值可以通过ARGV[索引]获取,注意:Lua中的索引是从1开始的,不是从0开始。
127.0.0.1:6379> # 显然脚本中只有两个key,但是我们却说有3个,那么前两个正常返回
127.0.0.1:6379> # 但是第3个是ARGV[1],不是KEYS因此返回失败,最终只保留了一个值
127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 3 name age hanser 28
1) "name"
2) "age"
3) "28"
127.0.0.1:6379> # 同样的道理
127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 4 name age hanser 28
1) "name"
2) "age"

所以一定要保证KEYS的个数正确。

但是这样是不是相当于设置了键值对呢?

127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 name age hanser 28
1) "name"
2) "age"
3) "hanser"
4) "28"
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get age
(nil)
127.0.0.1:6379> 

但是显然结果让我们失望了,Redis只是以批量回复的形式返回了Lua数组,这是Redis返回的一种类型,如果是Python操作的话,那么会得到一个list,举栗说明:

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

res = client.eval("return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}", 2,
                  "name", "hanser", "age", 28)
print(res)  # ['name', 'hanser', 'age', '28']

我们看到这个eval貌似没什么用啊,单独使用感觉确实没啥用,但是里面的KEYS、ARGV、numkeys是我们接下来所需要的,因为eval一旦搭配redis.call或者redis.pcall就有用了。

redis.call和redis.pcall

如果我们希望通过Lua脚本的方式,给Redis设置键值对的话,那么可以使用redis.call或者redis.pcall

127.0.0.1:6379> get name  # 此时不存在name
(nil)
127.0.0.1:6379> # 调用redis.call进行设置,将命令的各个部分按照redis要求的顺序传递即可
127.0.0.1:6379> # 这里没有KEYS,所以numkeys是0
127.0.0.1:6379> eval "return redis.call('set', 'name', 'yousa')" 0  
OK
127.0.0.1:6379> get name  # 再次获取,发现name被设置了
"yousa"
127.0.0.1:6379> 
127.0.0.1:6379> get age  # 此时不存在age
(nil)
127.0.0.1:6379> eval "return redis.pcall('set', 'age', 18)" 0  # 调用pcall设置,调用方式和call一样
OK
127.0.0.1:6379> get age  # age被设置
"18"
127.0.0.1:6379> 

既然redis.call和redis.pcall都可以设置值,那么这两者有什么区别呢?答案是区别只有一个:

如果Redis命令调用发生了错误,redis.call将抛出一个Lua类型的错误,再强制eval命令把错误返回给命令的调用者,而redis.pcall将捕获错误并返回表示错误的Lua表的类型

这里的参数我们可不可以通过上面的KEYS和ARGV传递呢?显然是可以的。

# 显然我们指定了一个key,三个ARG
# 所以name yousa ex 30会按照顺序传递给KEYS[1], ARGV[1], ARGV[2], ARGV[3]
127.0.0.1:6379> eval "return redis.call('set', KEYS[1], ARGV[1], ARGV[2], ARGV[3])" 1 name yousa ex 30
OK
127.0.0.1:6379> get name # 成功获取
"yousa"
127.0.0.1:6379> ttl name  # 查看过期时间
(integer) 23
127.0.0.1:6379> 
127.0.0.1:6379> # 还可以设置多个值
127.0.0.1:6379> eval "return redis.call('mset', KEYS[1], KEYS[2], ARGV[1], ARGV[2])" 2 name hanser age 28
OK
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get age
"28"
127.0.0.1:6379> 

如果是设置多个key的话,那么numkeys的数量一定要指定正确,并且KEYS在前、ARGV在后,索引各自从1开始。对于这里的KEYS[1]就会和ARGV[1]组合,KEYS[2]和ARGV[2]组合。如果我们这样写会怎么样:

127.0.0.1:6379> eval "return redis.call('mset', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 name hanser age 28
OK
127.0.0.1:6379> get name
"age"
127.0.0.1:6379> get age
(nil)
127.0.0.1:6379> get hanser
"28"
127.0.0.1:6379>

我们看到,本来是希望将:name和hanser组合起来,age和28组合起来的,结果变成了name和组合、hanser和28组合了,显然这不是我们期望的。

看一下如何使用Python进行调用。

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

client.eval("return redis.call('set', 'age', 18)", 0)
res = client.eval("return redis.call('incrby', KEYS[1], ARGV[1])", 1, "age", 10)
# 得到的是incrby命令的返回值,当然使用get age也是一眼给的
print(res)  # 28

evalsha

这个命令和eval类似,只不过它需要搭配script load来使用。

# 这个不会立刻执行,而是会返回一个哈希值
127.0.0.1:6379> script load "return redis.call('set', 'name', 'yousa')"
"1737531390c4e2ba0f7a42bc644b531e962cf235"
127.0.0.1:6379> get name  # 此时为空
(nil)
127.0.0.1:6379> evalsha "1737531390c4e2ba0f7a42bc644b531e962cf235" 0  # 对哈希值使用evalsha即可
OK
127.0.0.1:6379> get name
"yousa"
127.0.0.1:6379>

eval和evalsha的语法是一样的,只不过eval后面的字符串是具体的Lua代码,evalsha后面是Lua代码使用script load得到的哈希值。

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

hash_value = client.script_load("return redis.call('set', KEYS[1], ARGV[1])")
client.evalsha(hash_value, 1, "name", "神楽めあ")
print(client.get("name"))  # 神楽めあ

Lua和Redis数据类型之间的转换

当使用redis.call和redis.pcall调用Redis命令时,Redis命令的返回值会转换为Lua的数据类型,然后再eval的时候,再将Lua返回的数据类型转换为Redis支持的协议。

数据类型之间的转换原则是:如果将Redis类型转换为Lua类型,然后将结果转换回Redis类型,则结果与初始值相同。换句话说,Lua和Redis类型之间存在一对一的转换。

127.0.0.1:6379> eval "return 10" 0
(integer) 10
127.0.0.1:6379> eval "return 'mea'" 0
"mea"
127.0.0.1:6379> eval "return {1, 2, 3, 'xxx'}" 0
1) (integer) 1
2) (integer) 2
3) (integer) 3
4) "xxx"
127.0.0.1:6379> 

但是有两点需要注意:

  • 1. 在Lua中,整数和浮点数都属于number类型,因此我们总是将Lua数字转换为整数返回。因此如果有浮点数的话,会删除数字的小数部分。所以如果你想从Lua脚本中返回一个浮点数,你应该像字符串一样返回它,就像Redis自己做的那样,比如zset。
  • 2. 在Lua的表中尽量不要出现nil,因为Lua的表中一旦出现nil,会出现异常不到的结果,这是由Lua的表的语义决定的。如果出现nil,Redis的转换会中止。
127.0.0.1:6379> eval "return 10.5" 0  # 10.5被强制截断了
(integer) 10
127.0.0.1:6379> eval "return '10.5'" 0
"10.5"
127.0.0.1:6379> 
127.0.0.1:6379> eval "return {1, 2, 3, nil, 4, 5}" 0  # 出现了nil,转换中止
1) (integer) 1
2) (integer) 2
3) (integer) 3
127.0.0.1:6379> # 当然你可以把eval "lua_code"想象成直接执行Redis命令,如果Redis命令是有返回值的,那么eval "lua_code"也会直接返回
127.0.0.1:6379> eval "return redis.call('set', 'a', 2)" 0
OK
127.0.0.1:6379> eval "return redis.call('get', 'a')" 0
"2"
127.0.0.1:6379> 

redis.error_reply和redis.status_reply

这两个老铁是做什么的,我们来看一下。

127.0.0.1:6379> set name  # 设置失败返回一个error
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> # 这种方式也是返回一个error,内容就是我们这里传递的内容
127.0.0.1:6379> eval "return redis.error_reply('error occurred')" 0
(error) error occurred
127.0.0.1:6379> # 等价于下面这种方式
127.0.0.1:6379> eval "return {err='error occurred'}" 0
(error) error occurred
127.0.0.1:6379> # 如果不是err,那么就不会设置异常了
127.0.0.1:6379> eval "return {err1='error occurred'}" 0
(empty array)
127.0.0.1:6379> # redis.status_reply就是设置一个状态,返回的就是其本身内容
127.0.0.1:6379> eval "return redis.status_reply('abc')" 0
abc
127.0.0.1:6379> 

我们使用Python来操作一下。

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

print(client.eval("return redis.status_reply('abc')", 0))  # abc

# 直接抛异常了,程序终止
client.eval("return redis.error_reply('abc')", 0)
"""
Traceback (most recent call last):
  File "D:/satori/1.py", line 7, in <module>
    client.eval("return redis.error_reply('abc')", 0)
  File "C:\python38\lib\site-packages\redis\client.py", line 2817, in eval
    return self.execute_command('EVAL', script, numkeys, *keys_and_args)
  File "C:\python38\lib\site-packages\redis\client.py", line 839, in execute_command
    return self.parse_response(conn, command_name, **options)
  File "C:\python38\lib\site-packages\redis\client.py", line 853, in parse_response
    response = connection.read_response()
  File "C:\python38\lib\site-packages\redis\connection.py", line 718, in read_response
    raise response
redis.exceptions.ResponseError: abc
"""

脚本的原子性

我们知道Redis可以执行Lua脚本,因为Redis源码里面包含了Lua解释器的源代码

所以Redis会使用相同的Lua解释器来运行所有命令。另外,Redis保证以原子方式执行脚本:执行脚本时不会执行其他脚本或Redis命令。与 MULTI/EXEC 事务的概念相似。从所有其他客户端的角度来看,脚本要不已经执行完成,要不根本不执行。

因此运行一个缓慢的Lua脚本是一个非常愚蠢的做法,其实创建能够快速执行的脚本并不难,因为脚本开销很低,而且Lua中也引入了JIT(即时编译)功能。所以如果执行了运行缓慢的Lua脚本,由于其原子性,导致其他客户端的命令都是得不到执行的,这并不是我们想要的结果,因此要注意这一点。

此外,Redis对Lua脚本的执行时间也有一个限制,最长不能超过5s,可以通过配置文件redis.conf中的lua-time-limit进行设置,默认是5000,单位是毫秒。

那么此时,我们就可以实现上一篇博客中说的分布式锁了。

import time
import threading
import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

# 随便起一个ID,如果返回的值是设置的ID,那么删除,否则进行设置
# 我们看到可以写多行的Lua脚本,而且里面出现了return,但是我们并没有放到函数中
# 这是因为Redis会自动帮我们创建一个函数,函数体就是我们这里的代码,当然上面例子中出现的return也是一样的道理。
lua_code = """ 
    if redis.call('get', 'lock') == ARGV[1] then  
        return redis.call('del', 'lock')
    else
        return redis.call('set', 'lock', ARGV[1])
    end    
    """


def func1():
    for _ in range(3):
        client.eval(lua_code, 0, "线程1")
        print("线程1获取了锁,开始执行任务")
        time.sleep(3)


def func2():
    for _ in range(3):
        client.eval(lua_code, 0, "线程2")
        print("线程2获取了锁,开始执行任务")
        time.sleep(3)


t1 = threading.Thread(target=func1)
t2 = threading.Thread(target=func2)
t1.start()
t2.start()
t1.join()
t2.join()
"""
线程2获取了锁,开始执行任务
线程1获取了锁,开始执行任务
线程1获取了锁,开始执行任务
线程2获取了锁,开始执行任务
线程2获取了锁,开始执行任务
线程1获取了锁,开始执行任务
"""

script命令

Redis提供了一个可用于控制脚本子系统的SCRIPT命令。 SCRIPT目前接受以下几种不同的命令:

script load

这个我们之前就说过了,它是根据Lua脚本得到一个哈希值,但是里面的命令不会立刻执行。

127.0.0.1:6379> script load "return redis.call('set', 'foo', 'bar')" 
"a0c38691e9fffe4563723c32ba77a34398e090e6"
127.0.0.1:6379>

script exists

判断哈希值是否存在,就是有没有通过script load得到这样的哈希值。

127.0.0.1:6379> script load "return redis.call('set', 'foo', 'bar')" 
"a0c38691e9fffe4563723c32ba77a34398e090e6"
127.0.0.1:6379> # 显然存在,返回1
127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e6"
1) (integer) 1
127.0.0.1:6379> # 将哈希值的最后一位给改掉,发现返回0,不存在。
127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e5"
1) (integer) 0
127.0.0.1:6379> 

script flush

强制Redis刷新脚本缓存,将加载脚本得到的哈希值清空,我们举个栗子:

127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e6"
1) (integer) 1
127.0.0.1:6379> script flush  # 清空之后 就不存在了
OK
127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e6"
1) (integer) 0
127.0.0.1:6379>

script kill

当脚本的执行时间达到配置的脚本最大执行时间时,此命令是中断长时间运行的脚本的唯一方法。 script kill命令只能用于在执行期间没有修改数据集的脚本(因为停止只读脚本不会违反脚本引擎的所保证的原子性)。

全局变量保护

Redis脚本不允许创建全局变量,以避免用户的状态数据和Lua全局变量之间造成混乱。

127.0.0.1:6379> eval "a = 10" 0
(error) ERR Error running script (call to f_d1c61e47e71a9af32fe0564b32c2bd85e845c304):
@enable_strict_lua:8: user_script:1: Script attempted to create global variable 'a' 
127.0.0.1:6379> # 告诉我们脚本试图创建一个全局变量
127.0.0.1:6379> # 创建一个局部变量是可以的
127.0.0.1:6379> eval "local a = 10" 0
(nil)

可用的库

Lua语言中提供了一些库,在Lua5.3中是直接内嵌在解释器里面的,当然在Redis中也是可以直接用的,那么都可以使用哪些库呢?

可使用的库:base、table、string、math、struct、cjson、cmsgpack、bitop、redis.sha1hex、redis.breakpoint、redis.debug等等

我们举个栗子:

127.0.0.1:6379> eval "return math.sin(math.pi / 2)" 0
(integer) 1
127.0.0.1:6379> eval "return cjson.encode({['foo']= 'bar'})" 0
"{\"foo\":\"bar\"}"
127.0.0.1:6379> eval 'return cmsgpack.pack({"foo", "bar", "baz"})' 0
"\x93\xa3foo\xa3bar\xa3baz"
127.0.0.1:6379>

使用脚本写Redis日志

可以在Lua脚本中写入Redis日志文件:redis.log(日志级别, 日志信息)

日志级别可以是以下几种:

  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE
  • redis.LOG_WARNING
127.0.0.1:6379> eval "return redis.log(redis.LOG_DEBUG, 'test input')" 0
(nil)

沙箱和最大执行时间

Redis对Lua脚本做了一个限制,所有脚本都必须是无副作用的纯函数(pure function)。比如:生成随机数,因为是随机的,所以在master生成的随机数和在slave节点生成随机数是不一样的,这样就破坏了主从节点的一致性。

那么Redis都对Lua脚本做了哪些限制呢?

  • 1. 不允许访问系统状态状态的库(比如系统时间库)
  • 2. 禁止使用 loadfile 函数
  • 3. 如果脚本在执行带有随机性质的命令(比如 RANDOMKEY ),或者带有副作用的命令(比如 TIME )之后,试图执行一个写入命令(比如 SET ),那么 Redis 将阻止这个脚本继续运行,并返回一个错误。
  • 4. 如果脚本执行了带有随机性质的读命令(比如 SMEMBERS ),那么在脚本的输出返回给 Redis 之前,会先被执行一个自动的字典序排序,从而确保输出结果是有序的。
  • 5. 用Redis自己定义的随机生成函数,替换Lua环境中 math表原有的 math.random 函数和 math.randomseed 函数,新的函数具有这样的性质:每次执行 Lua 脚本时,除非显式地调用math.randomseed,否则 math.random生成的伪随机数序列总是相同的。

此外,lua脚本也受最大执行时间(默认为5秒)的限制。这个默认的超时时间可以说有点长,因为脚本运行很快,执行时间通常在毫秒以下,之所以有这个限制主要是为了处理在开发过程中产生的意外死循环。

可以通过redis.conf配置文件或者使用 config set 命令修改lua脚本以毫秒级精度执行的最长时间。修改的配置参数就是 lua-time-limit,比如:

127.0.0.1:6379> config get lua-time-limit
1) "lua-time-limit"
2) "5000"
127.0.0.1:6379> config set lua-time-limit 1000  # 表示Lua脚本的最长执行时间不能超过1s
OK
127.0.0.1:6379> 

但如果脚本真的执行超时了,那么Redis并不会自动终止它的执行,因为这违反了Redis和脚本引擎之间的合约,Redis要保证脚本执行是原子性的。出于这个原因,当脚本执行超时时,会发生以下情况:

  • 1. Redis日志记录脚本运行时间过长。
  • 2. 此时如果又有客户端再次向Redis服务器端发送了命令,服务器端则会向所有发送命令的客户端回复BUSY错误。在这种状态下唯一允许的命令是SCRIPT KILL和SHUTDOWN NOSAVE。
  • 3. 可以使用SCRIPT KILL命令终止一个只执行只读命令的脚本。这不会违反脚本语义,因为脚本不会将数据写入数据集。
  • 4. 如果脚本已经执行了写入命令,则唯一允许的命令将是 SHUTDOWN NOSAVE,它会在不保存磁盘上当前数据集(基本上服务器已中止)的情况下停止服务器。

小结

在Redis中嵌入Lua脚本是很有用的,只是Lua语言用的人不是很多,所以这个功能也很少被使用。但是实际上,对Lua语言的要求并不高,从目前来看,貌似没有用到关于Lua语言的太多语法,最复杂也就是出现了一个if else语句罢了。

Lua脚本执行的很快,即使你嵌入了一个上千行的Lua脚本,只要代码正确,执行时间也会很短,只不过此时就需要你了解一下Lua的语法了。而Lua语言也非常简单,那么精简的一个语言能难哪里去,基本上一天之内就可以入门,如果想成为一个Redis高手的话,那么还是建议了解一下Lua语言。

posted @ 2020-07-18 16:14  古明地盆  阅读(1829)  评论(0编辑  收藏  举报