Redis Lua 脚本

Redis 使用 Lua 的好处

Lua 简介就不复制了.

Redis 提供了非常富的命令集, 但是用户依然不满足, 希望可以自定义扩充若干指令来完成一些特定领域的问题.

Redis 为这样的用户场景提供了 lua 脚本支持, 用户可以向服务器发送 lua 脚本来执行自定义动作, 获取脚本的响应数据. Redis 服务器会单线程原子性执行 lua 脚本, 保证 lua 脚本在处理的过程中不会被任意其它请求打断.

对于这点非常像事务, 事务是需要先将命令发给一条一条的发送给 Redis, 然后调用 EXEC 执行事务. 而 Lua 脚本, 可以在服务端直接执行, 所以相应的也减少了网络带宽.

《Redis Lua 脚本》

Redis 中 Lua 脚本相关命令

SCRIPT LOAD 命令

SCRIPT LOAD script

脚本 script 添加到脚本缓存中, 但并不立即执行这个脚本.

返回给定 script 的 SHA1 校验和.

一个最简单的例子:

127.0.0.1:6379> SCRIPT LOAD "return 'hello moto'"
"232fd51614574cf0867b83d384a5e898cfd24e5a"
127.0.0.1:6379> EVALSHA 232fd51614574cf0867b83d384a5e898cfd24e5a 0
"hello moto"

值得注意的是:

如果给定的脚本已经在缓存里面了, 那么不做动作.

脚本可以在缓存中保留无限长的时间, 直到执行
SCRIPT FLUSH 为止.

SCRIPT FLUSH 命令

SCRIPT FLUSH

这个命令没啥好说的, 就是清除所有 Lua 脚本缓存.

返回值总是返回 OK

EVALSHA 命令

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

根据给定的 sha1 校验码, 对缓存在服务器中的脚本进行执行.

EVAL 命令

EVAL script numkeys key [key …] arg [arg …]

script 参数是一段 Lua 5.1 脚本程序, 它会被运行在 Redis 服务器上下文中.

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 指定了键名参数的数量, key1key2 是键名参数, 分别使用 KEYS[1]KEYS[2] 访问, 而最后的 firstsecond 则是附加参数, 可以通过 ARGV[1]ARGV[2] 访问它们.

键名参数 可以理解为, 脚本可能读取或写入的键.

附加参数 可以理解为, 逻辑判断条件或要写入的数据.

在 Lua 脚本中执行 Redis 命令.

在 Lua 脚本中, 可以使用两个不同函数来执行 Redis 命令, 它们分别是:

  • redis.call()
  • redis.pcall()

比如下面的这段脚本:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

使用 EVAL 命令执行:

127.0.0.1:6379> set foo bar
OK
127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 foo bar
(integer) 1

SCRIPT EXISTS 命令

SCRIPT EXISTS sha1 [sha1 …]

判断一个或多个脚本的 SHA1 校验和, 是否已经添加到脚本缓存中.

存在返回1, 不存在返回0.

127.0.0.1:6379> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a 232fd51614574cf0867b83d384a5e898cfd24e5b
1) (integer) 1
2) (integer) 0

SCRIPT KILL 命令

SCRIPT KILL

杀死当前正在运行的 Lua 脚本, 只有这个脚本没有执行过任何写操作时, 这个命令才生效.

SCRIPT KILL 执行之后, 当前正在运行的脚本会被杀死, 执行这个脚本的客户端会从 EVAL script numkeys key [key …] arg [arg …] 命令的阻塞当中退出, 并收到一个错误作为返回值.

另一方面, 假如当前正在运行的脚本已经执行过写操作, 那么即使执行 SCRIPT KILL, 也无法将它杀死, 因为这是违反 Lua 脚本的原子性执行原则的.

在这种情况下, 唯一可行的办法是使用 SHUTDOWN NOSAVE 命令, 通过停止整个 Redis 进程来停止脚本的运行, 并防止不完整 (half-written) 的信息被写入数据库中.

执行成功返回 OK, 否则返回一个错误.

# 没有脚本在执行时

redis> SCRIPT KILL
(error) ERR No scripts in execution right now.

# 成功杀死脚本时

redis> SCRIPT KILL
OK
(1.30s)

# 尝试杀死一个已经执行过写操作的脚本,失败

redis> SCRIPT KILL
(error) ERR Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in an hard way using the SHUTDOWN NOSAVE command.
(1.69s)

以下是脚本被杀死之后, 返回给执行脚本的客户端的错误:

redis> EVAL "while true do end" 0
(error) ERR Error running script (call to f_694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7): Script killed by user with SCRIPT KILL...
(5.00s)

错误处理

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

带宽 和 EVALSHA

EVAL 命令要求你在每次执行脚本的时候都发送一次脚本主体(script body).

Redis 有一个内部的缓存机制, 因此它不会每次都重新编译脚本, 不过在很多场合, 付出无谓的带宽来传送脚本主体并不是最佳选择.

为了减少带宽的消耗, Redis 实现了 EVALSHA 命令, 它的作用和 EVAL 一样, 都用于对脚本求值, 但它接受的第一个参数不是脚本, 而是脚本的 SHA1 校验和.

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 命令对脚本进行复用, 免去了无谓的带宽消耗.

全局变量保护

为了防止不必要的数据泄漏进 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 关键字定义为局部变量.

沙箱(sandbox) 和 最大执行时间

脚本应该仅仅用于传递参数和对 Redis 数据进行处理, 它不应该尝试去访问外部系统(比如文件系统), 或者执行任何系统调用.

除此之外, 脚本还有一个最大执行时间限制, 它的默认值是 5 秒钟, 一般正常运作的脚本通常可以在几分之几毫秒之内完成, 花不了那么多时间, 这个限制主要是为了防止因编程错误而造成的无限循环而设置的.

最大执行时间的长短由 lua-time-limit 选项来控制(以毫秒为单位), 可以通过编辑 redis.conf 文件或者使用 CONFIG GET parameterCONFIG SET parameter value 命令来修改它.

当一个脚本达到最大执行时间的时候, 它并不会自动被 Redis 结束, 因为 Redis 必须保证脚本执行的原子性, 而中途停止脚本的运行意味着可能会留下未处理完的数据在数据集里面.

因此, 当脚本运行的时间超过最大执行时间后, 以下动作会被执行:

  • Redis 记录一个脚本正在超时运行.
  • Redis 开始重新接受其他客户端的命令请求, 但是只有 SCRIPT KILLSHUTDOWN NOSAVE 两个命令会被处理, 对于其他命令请求, Redis 服务器只是简单地返回 BUSY 错误.
  • 可以使用 SCRIPT KILL 命令将一个仅执行只读命令的脚本杀死, 因为只读命令并不修改数据, 因此杀死这个脚本并不破坏数据的完整性.
  • 如果脚本已经执行过写命令, 那么唯一允许执行的操作就是 SHUTDOWN NOSAVE, 它通过停止服务器来阻止当前数据集写入磁盘.

流水线 (pipeline) 上下文 (context) 中的 EVALSHA

在流水线请求的上下文中使用 EVALSHA 命令时, 要特别小心, 因为在流水线中, 必须保证命令的执行顺序.

一旦在流水线中因为 EVALSHA 命令而发生 NOSCRIPT 错误, 那么这个流水线就再也没有办法重新执行了, 否则的话, 命令的执行顺序就会被打乱.

为了防止出现以上所说的问题, 客户端库实现应该实施以下的其中一项措施:

  • 总是在流水线中使用 EVAL 命令.
  • 检查流水线中要用到的所有命令, 找到其中的 EVAL 命令, 并使用 SCRIPT EXISTS sha1 [sha1 …] 命令检查要用到的脚本是不是全都已经保存在缓存里面了. 如果所需的全部脚本都可以在缓存里找到, 那么就可以放心地将所有 EVAL 命令改成 EVALSHA 命令, 否则的话, 就要在流水线的顶端 (top) 将缺少的脚本用 SCRIPT LOAD script 命令加上去.
    原文作者:sc_ik
    原文地址: https://segmentfault.com/a/1190000020040902
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞