EVAL:执行脚本

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

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

其中:

  • script参数用于传递脚本本身。因为Redis目前内置的是Lua 5.1版本的解释器,所以用户在脚本中也只能使用Lua 5.1版本的语法。

  • numkeys参数用于指定脚本需要处理的键数量,而之后的任意多个 key参数则用于指定被处理的键。通过key参数传递的键可以在脚本中通过KEYS数组进行访问。根据Lua的惯例,KEYS数组的索引将以1为开始:访问KEYS[1]可以取得第一个传入的key参数,访问KEYS[2]可以取得第二个传入的key参数,以此类推。

  • 任意多个arg参数用于指定传递给脚本的附加参数,这些参数可以在脚本中通过ARGV数组进行访问。与KEYS参数一样,ARGV数组的索引也是以1为开始的。

作为例子,以下代码展示了如何执行一个只会返回字符串"hello world"的脚本:

redis> EVAL "return 'hello world'" 0
"hello world"

这个命令将脚本 "return’hello world'" 传递给了 Lua 环境执行,其中 Lua 关键字 return 用于将给定值返回给脚本调用者,而 'hello world’则是被返回的字符串值。跟在脚本后面的是numkeys参数的值 0,说明这个脚本不需要对Redis的数据库键进行处理。除此之外,这个命令也没有给定任何arg参数,说明这个脚本也不需要任何附加参数。

使用脚本执行Redis命令

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

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

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

作为例子,以下代码展示了如何在脚本中执行Redis的SET命令,并将"message"键的值设置为"hello world":

redis> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 "message" "hello world"
OK
redis> GET "message"
"hello world"

脚本中的redis.call('SET',KEYS[1],ARGV[1])表示被执行的是 Redis的SET命令,而传给命令的两个参数则分别是KEYS[1]和 ARGV[1],其中KEYS[1]为"message",而ARGV[1]则为"hello world"。

以下是另一个使用脚本执行ZADD命令的例子:

redis> EVAL "return redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])" 1 "fruit-price"
8.5 "apple"
(integer) 1
redis> ZRANGE "fruit-price" 0 -1 WITHSCORES
1) "apple"
2) "8.5"

redis.call()函数和redis.pcall()函数都可以用于执行Redis命令,它们之间唯一不同的就是处理错误的方式。前者在执行命令出错时会引发一个Lua错误,迫使EVAL命令向调用者返回一个错误;而后者则会将错误包裹起来,并返回一个表示错误的Lua表格:

-- Lua的type()函数用于查看给定值的类型
redis> EVAL "return type(redis.call('WRONG_COMMAND'))" 0
(error) ERR Error running script (call to f_2c59998e8c4eb7f9fdb467ba67ba43dfaf8a6592): @user_scr
ipt:1: @user_script: 1: Unknown Redis command called from Lua script
redis> EVAL "return type(redis.pcall('WRONG_COMMAND'))" 0
"table"

在第一个EVAL命令调用中,redis.call()无视type()函数引发了一个错误;而在第二个EVAL命令调用中,redis.pcall()向type()函数返回了一个包含出错信息的表格,因此脚本返回的结果为 "table"。

值转换

在EVAL命令出现之前,Redis服务器中只有一种环境,那就是Redis命 令执行器所处的环境,这一环境接受Redis协议值作为输入,然后返回 Redis协议值作为输出。 但是随着EVAL命令以及Lua解释器的出现,使得Redis服务器中同时出 现了两种不同的环境:一种是Redis命令执行器所处的环境,而另一种 则是Lua解释器所处的环境。因为这两种环境使用的是不同的输入和输 出,所以在这两种环境之间传递值将引发相应的转换操作: 1)当Lua脚本通过redis.call()函数或者redis.pcall()函数执行 Redis命令时,传入的Lua值将被转换成Redis协议值;比如,当脚本调 用redis.call('SET',KEYS[1],ARGV[1])的时候,'SET'、KEYS[1] 以及ARGV[1]都会从Lua值转换为Redis协议值。 2)当redis.call()函数或者redis.pcall()函数执行完Redis命令 时,命令返回的Redis协议值将被转换成Lua值。比如,当 redis.call('SET',KEYS[1],ARGV[1])执行完毕的时候,执行SET 命令所得的结果OK将从Redis协议值转换为Lua值。 3)当Lua脚本执行完毕并向EVAL命令的调用者返回结果时,Lua值将被 转换为Redis协议值。比如,当脚本"return’hello world'"执行完毕 的时候,Lua值’hello world’将转换为相应的Redis协议值。 虽然引发转换的情况有3种,但转换操作说到底只有“将Redis协议值 转换成Lua值”以及“将Lua值转换成Redis协议值”这2种,表14-1和 表14-2分别展示了这2种情况的具体转换规则。

image 2025 01 05 21 07 43 971
Figure 1. 表14-1 将Redis协议值转换成Lua值的规则
image 2025 01 05 21 08 14 821
Figure 2. 表14-2 将Lua值转换为Redis协议值的规则
image 2025 01 05 21 14 21 078

正如上述转换规则所示,因为带有小数部分的Lua数字将被转换为 Redis整数回复:

redis> EVAL "return 3.14" 0
(integer) 3

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

redis> EVAL "return tostring(3.14)" 0
"3.14"

调用者在接收到这个值之后,只需要再将它转换为小数即可。

全局变量保护

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

作为例子,以下代码通过Lua的local关键字,在脚本中定义了一个临时变量database:

redis> EVAL "local database='redis';return database" 0
"redis"

如果我们尝试在脚本中定义一个全局变量number,那么Redis将返回一个错误:

redis> EVAL "number=10" 0
(error) ERR Error running script (call to f_a2754fa2d614ad76ecfd143acc06993bedf1f691): @enable_s
trict_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中执行的代码,它证明了脚本执行的 SELECT命令并不会对客户端的当前数据库产生影响:

redis> SET dbnumber 0 -- 将0号数据库的dbnumber键的值设置为0
OK
redis> SELECT 1 -- 切换至1号数据库
OK
redis[1]> SET dbnumber 1 -- 将1号数据库的dbnumber键的值设置为1
OK
redis[1]> SELECT 0 -- 切换回0号数据库
OK
redis> EVAL "redis.call('SELECT', ARGV[1]); return redis.call('GET', KEYS[1])" 1 "dbnumber" 1
"1" -- 在脚本中切换至1号数据库,并获取dbnumber键的值
redis> GET dbnumber
"0" -- dbnumber 键的值为0,这表示客户端的当前数据库仍然是0号数据库

如果我们在Redis 2.8.12之前的版本中执行以上代码,那么在EVAL命 令执行之后,客户端的当前数据库将切换至1号数据库,而GET dbnumber命令则会返回"1"作为结果。

脚本的原子性

Redis的Lua脚本与Redis的事务一样,都是以原子方式执行的:在 Redis服务器开始执行EVAL命令之后,直到EVAL命令执行完毕并向调用 者返回结果之前,Redis服务器只会执行EVAL命令给定的脚本及其包含 的Redis命令调用,至于其他客户端发送的命令请求则会被阻塞,直到 EVAL命令执行完毕为止。 基于上述原因,用户在使用Lua脚本的时候,必须尽可能地保证脚本能 够高效、快速地执行,从而避免因为独占服务器而给其他客户端造成 影响。

以命令行方式执行脚本

用户除了可以在redis-cli客户端中使用EVAL命令执行给定的脚本之 外,还可以通过redis-cli客户端的eval选项,以命令行方式执行给定 的脚本文件。在使用eval选项执行Lua脚本时,用户不需要像执行EVAL 命令那样指定传入键的数量,只需要在传入键和附加参数之间使用逗 号进行分割即可。 举个例子,如果我们要执行代码清单14-1所示的set_and_get.lua脚 本:

代码清单14-1 简单的脚本文件:/script/set_and_get.lua
redis.call("SET", KEYS[1], ARGV[1])
return redis.call("GET", KEYS[1])

那么只需要在命令行中执行以下命令即可:

$ redis-cli --eval set_and_get.lua 'msg' , 'Ciao!'
"Ciao!"

其他信息

  • 复杂度:EVAL 命令的复杂度由被执行的脚本决定。

  • 版本要求:EVAL 命令从 Redis 2.6.0 版本开始可用。

示例:使用脚本重新实现带有身份验证功能的锁

第13章中,我们使用乐观锁事务实现了一个安全的、带有身份验证功 能的锁程序,代码清单14-2展示了如何使用Lua脚本来实现相同的程 序。

代码清单14-2 使用Lua脚本实现带身份验证功能的锁:/script/identity_lock.py
class IdentityLock:

    def __init__(self, client, key):
        self.client = client
        self.key = key

    def acquire(self, identity, timeout):
        """
        尝试获取一个带有身份标识符和最大使用时限的锁,
        成功时返回 True ,失败时返回 False 。
        """
        result = self.client.set(self.key, identity, ex=timeout, nx=True)
        return result is not None

    def release(self, input_identity):
        """
        根据给定的标识符,尝试释放锁。
        返回 True 表示释放成功;
        返回 False 则表示给定的标识符与锁持有者的标识符不相同,释放请求被拒绝。
        """
        script = """
        -- 使用局部变量储存锁键键名以及标识符,提高脚本的可读性
        local key = KEYS[1]
        local input_identity = ARGV[1]

        -- 获取锁键储存的标识符
        -- 当标识符为空时,Lua 会将 GET 返回的 nil 转换为 false
        local lock_identity = redis.call("GET", key)

        if lock_identity == false then
            -- 如果锁键储存的标识符为空,那么说明锁已经被释放
            return true
        elseif input_identity == lock_identity then
            -- 如果给定的标识符与锁键储存的标识符相同,那么释放这个锁
            redis.call("DEL", key)
            return true
        else
            -- 如果给定的标识符与锁键储存的标识符并不相同
            -- 那么说明当前客户端不是锁的持有者,拒绝本次释放请求
            return false
        end
        """
        # 因为 Redis 会将脚本返回的 true 转换为数字 1
        # 所以这里通过检查脚本返回值是否为 1 来判断解锁操作是否成功
        result = self.client.eval(script, 1, self.key, input_identity)
        return result == 1

这个锁程序的acquire()方法没有做任何修改,与之前完全一样,修 改后的release()

方法的核心原理与之前也是相同的:它首先获取锁键存储的标识符, 然后根据标识符是否为空以及它与用户给定的标识符是否相同来决定 是否释放锁。

与乐观锁事务实现的锁程序相比,使用Lua脚本实现的锁程序不需要对 键实施监视,并且与乐观锁实现需要两次网络通信相比,Lua脚本实现 只需要一次网络通信,因此Lua脚本实现在编程复杂度以及执行速度方 面都有优势。

Lua脚本实现唯一的缺点在于,值在Lua环境和Redis环境之间传递时可 能会发生变化。因此我们在编写Lua脚本时必须熟悉相应的转换规则, 否则脚本很容易会出现错误。

示例:实现LPOPRPUSH命令

14.2节展示了如何使用Lua脚本重新实现使用乐观锁事务实现过的锁程 序,但是在某些情况下,有些程序是无法使用乐观锁事务来实现的, 或者无法高效直接地使用乐观锁来实现,这时我们只能选择使用Lua脚 本。

举个例子,Redis虽然提供了RPOPLPUSH命令,但并没有提供相对应的 LPOPRPUSH命令:

LPOPRPUSH source target

为了实现一个安全的LPOPRPUSH命令,程序必须以原子的方式执行以下两个操作:

item = LPOP source
RPUSH target item

初看上去,要做到这一点似乎并不困难,但如果仔细地思考一下,就 会发现使用乐观锁事务是无法做到这一点的。首先,因为LPOP命令会 对源列表执行弹出操作,并将被弹出的元素返回给客户端,然后客户 端再使用RPUSH命令将这个元素推入目标列表中,这个过程必然会引起 两次单独的写入操作,而这样的操作是无法使用乐观锁事务来保证安 全性的。换句话说,用户是无法执行以下代码的,尝试执行类似的代 码将会引发错误:

WATCH source target
MULTI
item = LPOP source
RPUSH target item
EXEC

另一方面,虽然我们可以只使用乐观锁来保护LPOP命令:

WATCH source
MULTI
item = LPOP source
EXEC
RPUSH target item

或者只使用乐观锁来保护RPUSH命令:

item = LPOP source
WATCH target
MULTI
RPUSH target item
EXEC

或者使用两个乐观锁分别保护LPOP命令和RPUSH命令:

WATCH source
MULTI
item = LPOP source
EXEC
WATCH target
MULTI
RPUSH target item
EXEC

但整个操作的安全性仍然无法保证。

退一步说,虽然我们可以通过以下方法,使用乐观锁事务实现一个安 全的LPOPRPUSH命令,但这个实现并不如直接使用LPOP命令和RPUSH命 令那么直观:

WATCH source target
item = LRANGE source 0 0 -- 获取源列表的左端元素
MULTI
LPOP source -- 弹出源列表的左端元素
RPUSH target item -- 向目标列表右端推入之前获取的左端元素
EXEC

作为例子,代码清单14-3展示了一个使用Lua脚本实现的LPOPRPUSH命 令,这个实现不仅安全,而且相当直观。

代码清单14-3 使用Lua脚本实现的 LPOPRPUSH:/script/lpoprpush.py
def lpoprpush(client, source, target):
    script = """
    local source = KEYS[1]
    local target = KEYS[2]

    -- 从源列表左端弹出一个元素
    -- 当源列表为空时,LPOP 返回的 nil 将被 Lua 转换为 false
    local item = redis.call("LPOP", source)

    -- 如果被弹出元素不为空,那么将它推入到目标列表的右端
    -- 并向调用者返回该元素
    if item ~= false then
        redis.call("RPUSH", target, item)
        return item
    end
    """
    return client.eval(script, 2, source, target)

这个实现只使用了必需的LPOP命令和RPUSH命令,我们可以很容易就理 解它想要做的事情。与前一个脚本程序一样,编写这个脚本程序也需 要注意Lua环境与Redis环境之间的值转换问题。

以下是这个lpoprpush函数的使用方法:

>>> from redis import Redis
>>> from lpoprpush import lpoprpush
>>> client = Redis(decode_responses=True)
>>> client.rpush("source", "a", "b", "c") # 创建源列表和目标列表
3L
>>> client.rpush("target", "d", "e", "f")
3L
>>> lpoprpush(client, "source", "target") # 弹出源列表的左端元素
'a' # 并将其推入目标列表的右端
>>> client.lrange("source", 0, -1)
['b', 'c']
>>> client.lrange("target", 0, -1)
['d', 'e', 'f', 'a']