• 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:python38libsite-packages
    edisclient.py", line 2817, in eval
        return self.execute_command('EVAL', script, numkeys, *keys_and_args)
      File "C:python38libsite-packages
    edisclient.py", line 839, in execute_command
        return self.parse_response(conn, command_name, **options)
      File "C:python38libsite-packages
    edisclient.py", line 853, in parse_response
        response = connection.read_response()
      File "C:python38libsite-packages
    edisconnection.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
    "x93xa3fooxa3barxa3baz"
    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语言。

  • 相关阅读:
    BZOJ 3668: [Noi2014]起床困难综合症【贪心】
    浅谈错排公式的推导及应用
    Python爬虫笔记(一):爬虫基本入门
    机器理解大数据秘密:聚类算法深度剖析
    想了解概率图模型?你要先理解图论的基本定义与形式
    MATLAB命令大全+注释小结
    【批处理学习笔记】第二十九课:ASCII码
    【批处理学习笔记】第二十八课:声音和控制
    【批处理学习笔记】第二十七课:视窗
    【批处理学习笔记】第二十六课:返回值
  • 原文地址:https://www.cnblogs.com/traditional/p/13336128.html
Copyright © 2020-2023  润新知