各位朋友,大家好,我是 Payne,欢迎大家关注我的博客,我的博客地址是https://qinyuanpei.github.io。想起来大概有一个月没有更新博客啦。或许是因为这中间发生了太多的事情,想来人生原本就充满曲折和变数。在微信群里得知家中舅爷去世的消息,突然意识到时间早已摧毁你我的一切。那个曾经同你有千丝万缕联系的人,会在某一刻同你彻底失去联系。所以我更珍视彼此在一起的时光,因为在这个世界上每天都面临着改变。有时候工作上遇到不开心的时候,会想着一个人去一个陌生的地方,我们就在不断地相聚和离别中慢慢老去。这段时间一直在学习做饭,为此特意买了本菜谱,结果发现,最难的并不是如何去做好一道菜,而是你为了做好一道菜需要准备各种食材,就像人与人交流并没有什么困难,真正困难的地方,是你找不到一个可以一直陪你说话的人。熟悉的店面会被拆迁转让,熟悉的人事会被错过改变,上帝想把世界煮成一锅粥,可味道的调配却由我们来掌控。

  好了,所谓“如人饮水,冷暖自知”,人生奇就奇在你没有办法用三言两语去描述它。这段时间面试过两三家公司,整体上感觉自己的生活太安逸了些,虽然我现在依然住在租来的房子里,转眼间 2017 年接近尾声啦,可是回想起来今年年初制定的计划,在广泛阅读和提升技术上都是不及格的状态,印象中打算研究 Redis 和 MonogoDB 这两种数据库的(因为没有购买为知笔记会员导致部分笔记损坏或者丢失),然而到现在为止我还有研究完 Redis。尤其当我面试的时候,我发现好多我写在简历上的内容,都会成为某种意义上的呈堂证供,这让我更加确信好多东西需要不断地去巩固,所以尝试在实际项目上使用 Moq、考虑怎么写出更好的测试方法以及时刻保持自我的不可替代性,这些都是我最近在考虑的事情,有时候发脾气是因为觉得自己在浪费生命,可越是被这种无力感笼罩的时候,就越是要对自己狠一点儿,所以在这篇博客中,让我们重新拾起对 Redis 的学习兴趣,今天我们来说说 Redis 中的 Lua 脚本。

  熟悉我博客的朋友一定都知道,我曾经开发过 Unity3D 相关的项目,而 Lua 脚本正是 Unity3D 中主流的热更新方案。关于 Lua 脚本相关的文章,大家可以通过下面的链接来了解,在这里我们不再讲述 Lua 的基础内容,本篇文章所讲述的是如何通过 Redis 内置的 Lua 解释器来执行脚本,我们为什么使用脚本语言进行开发呢,因为这样可以降低开发的难度啊。

  好了,既然我们已然了解到 Redis 是通过内置的 Lua 解释器来执行脚本,所以 Redis 中的 Lua 脚本其实可以理解为 Lua 语法 + Redis API。为了写作这篇文章,我不得不将我的操作系统切换到 Linux,因为这样我可以随时在写作过程中使用终端,我写作的一个重要特点,就是所有的内容都尽量保证有测试覆盖,我知道有许多人都不喜欢写测试,测试虽然不能保证你没有 BUG,可是有了 BUG 以后可以直接在测试中定位问题,这就是我们为什么要重视测试的原因所在。在 Redis 中我们有两类命令用以处理和脚本相关的事情:

Eval 系列

  熟悉 JavsScript 的朋友应该会更熟悉这个方法,因为 Eval 在 JavaScript 是个神奇的存在,它可以执行任何合法的 JavaScript 代码,我和我的同事就曾经在一个项目中写过两层嵌套的 Eval 方法,显然这是为了实现某种奇怪的需求。那么在 Redis 中有 EVAL 和 EVALSHA 两个命令可以使用,这两个命令是从 Redis2.6.0 版本开始的,通过内置的 Lua 解释器来实现对脚本求值。EVAL 命令的基本格式如下:

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

  我们可以注意到在这里 EVAL 命令由三部分组成,即第一个部分,表示一段 Lua 脚本程序,并且这段脚本不需要更不应该定义函数;第二部分,表示参数列表,指在脚本中需要用到的键,因为 Redis 是一个键值数据库,这些键名可以通过全局变量 KEYS 来访问,默认索引将从 1 开始,事实上我们更推荐你使用这种方式来访问键名;第三部分,表示除建键名参数以外的附加参数,和第二部分类似,这里我们可以通过全局变量 ARGV 来访问,这里就不再赘述啦。我们一起来看下面的例子:

1
EVAL "return {KEYS[1],KEYS[2]}" 2 ab cd

  此时我们会返回一个由 KEYS[1]和 KEYS[2]组成的集合,集合中的两个元素分别是 ab、cd,注意到这里有一个参数 2,它表示我们这里将有两个参数,事实上 Redis 将从这个位置开始解析参数,所以我们必须告诉 Redis 参数解析到什么位置结束,因为主要参数(KEYS)和附加参数(ARGV)是从解析的角度上是无法区分的,所以我们期望的结果会是:

1
2
1) "ab"
2) "cd"

  现在我们来增加点难度,显然你明白我在说什么,请注意我要引入附加参数(ARGV)啦!

1
EVAL  "return {KEYS[1]..ARGV[2] ,KEYS[2]..ARGV[1] }" 2 ab cd ab cd

  这里我们尝试对 KEYS 和 ARGV 进行拼接,需要说明的是 Lua 中连接字符串使用的是. .,所以这里将得到结果:

1
2
1) "abcd"
2) "cdab"

  好了,现在大家应该理解 EVAL 这个命令的使用方法啦,那么对 EVALSHA 命令来说,顾名思义,它就是使用了 SHA1 验证的 EVAL 方法,我们注意到现在脚本都是定义在 EVAL 命令的第一个参数上,假如我们需要复用一个脚本,而该脚本可以为我们提供 Sum 这样的功能,即它可以返回一组参数的和给我们,显然参数的个数是不同的,那么这个时候我们总不能每次都重复写这个脚本吧,所以 Redis 会为脚本创建一个指纹,我们使用 EVALSHA 命令来传入一个指纹,Redis 将从缓存的脚本中找到这个脚本,并结合我们的参数来调用它,这样我们就可以获得脚本执行以后的结果,关于指纹的这种说法,大家可以结合 Git 提交代码时的感受进行理解,除此以外,它和 EVAL 在使用方法上是完全一致的,所以不再举例子说明啦。

Script 系列

  好了,下面我们来介绍第二类和 Lua 脚本相关的 API,相比 Eval 给人云里雾里的感觉,Script 系列的命令处处洋溢着规范命名的美好气息,我们通过这些命令的名字基本上就可以知道它是做什么事情的,这告诉我们平时写代码的时候如何去写出优雅的代码。我们通过下面一组命令来了解 Script 系列命令的具体用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 载入一个脚本到缓存中 */
SCRIPT LOAD "return 'Hello Redis'"
/* Redis返回该脚本的指纹信息 */
"e509eb0869056563287758d23146eb00e0518da5"
/* 查询脚本是否存在于缓存中 */
SCRIPT EXISTS "e509eb0869056563287758d23146eb00e0518da5"
/* Redis返回1表示脚本存在,反之不存在 */
1) (integer) 1
/* 从缓存中清空所有脚本 */
SCRIPT FLUSH
OK
/* 此时脚本在缓存中是不存在的 */
SCRIPT EXISTS "e509eb0869056563287758d23146eb00e0518da5"
1) (integer) 0

  至此,我们了解到了 Redis 中对 Lua 脚本支持的主要特性,坦白地讲,我认为 Lua 脚本在这里的应用极其薄弱,完全达不到我们印象中 Lua 脚本的强大,甚至我对 Redis 中的 KEYS 和 ARGV 依然有些模糊,大概越想搞明白的事情有时候就越搞不清楚。这里我没有提到的一个 SCRIPT 系列的命令是 SCRIPT KILL,这个命令的作用是杀死当前正在运行的脚本,并且当且仅当这个脚本没有执行过任何写操作时,这个命令才会生效,所以这个命令主要用于杀死长时间运行的脚本,执行完这个命令后,执行这个脚本的客户端将从阻塞的 EVAL 命令中退出,并收一个错误作为返回值,所以我们可以理解为这是一个强行终止脚本执行的方法,因为我这里这个脚本非常的简单,所以它执行起来非常快,而我没有这样一个足够长的脚本去验证这个命令,所以在上面的脚本示例中我没有去验证这个命令,对此感兴趣的朋友可以自行去研究啦。

Lua 脚本应用

  通过本文前面两个部分,我们基本了解了 Redis 中 Lua 脚本是如何工作的,在演示示例脚本的时候,我是直接在终端下运行 redis-server 和 redis-cli 的,并且所有的命令都是在终端下手动键入的,难道在实际的使用中我们要这样子玩 Redis 吗?想起来都觉得好可怕是不是?所以我们下面来通过一个具体的案例,来演示 Redis 怎么去和一个 Lua 脚本脚本进行交:

  首先,我们来定义一个简单的 Lua 脚本文件script01.lua,该脚本将对集合中的元素进行求和:

1
2
3
4
5
6
7
8
9
10
11
local sum = 0;
local key = KEYS[1]
local length = redis.call("LLEN",key)
local index = 0
while (index < length)
do
sum = sum + redis.call("LINDEX",key,index)
index = index + 1
end

return sum

  现在我们在终端中执行这个脚本,为了方便起见,我们这里将其放在 redis-3.2.8 目录下的 scripts 目录。我们首先在 Redis 中准备些数据来做好准备,在终端中执行命令:

1
2
3
4
LPUSH data 2 4 6 8 10
(integer) 5
src/redis-cli --eval ~/文档/redis-3.2.8/scripts/script01.lua data
(integer) 30

  好了,我们下面来解释下这段脚本,我们向 Redis 中键名为data的集合中添加了 5 个元素,注意这句脚本是在执行src/redis-cli后执行的,这部分内容我们在前面讲解 Redis 中的数据结构的时候提到过,博主表示在写这篇文章的时候依然要去看文档,总之现在我们有一个集合,并且这个集合中有 5 个元素,与此同时呢,我们编写了一个 Lua 脚本文件script01.lua,这个脚本的作用是对集合中的元素进行求和。在这里我们注意到,我们可以通过 redis.call()这个方法来调用 redis 中的命令,具体到这里我们使用 LLEN 命令获取了集合的长度,使用 LINDEX 命令获取了集合中的元素。我们在前面提到两个全局变量 KEYS 和 ARGV,可以完全当作 Lua 脚本中的两个变量来处理,从编程角度来讲,我们可以将其直接在脚本中写死。可是考虑到 Redis 是一个键值数据库,所以我们很容易想到键名应该对外暴露出来,以满足复用 Lua 脚本的目的。这里我们直接用 redis-cli 来运行 EVAL 命令,所以我们注意到它的传参方式有点不一样,事实上 KEYS 和 ARGV 中间使用逗号隔开即可。

  所以我们可以想到一种 Lua 脚本自动管理的思路,即通过命令行读取指定目录下的 Lua 脚本文件,通过 SCRIPT LOAD 方法获得其在 Redis 中的 SHA1 指纹,然后我们将脚本名称或者 ID 和这个指纹关联起来并将其存储在 Redis 中,此时我们只需要传入脚本名称和参数即可返回脚本执行后的结果,这样是不是感觉非常优雅呢?虽然 Redis 是一个键值性数据库,它不具备传统关系型数据库的查询能力,但是现在我们有了 Lua 脚本以后一样可以通过脚本来定制出查询,而到此时此刻我或许才真正明白 Redis 中 Lua 脚本是一种怎样神奇的存在。我们心怀敬畏,同时对这个世界永远充满期待,因为我们从来不知道人类潜能开发的极限在哪里。我们创造了太多不可思议的事情,有时候甚至连我们自己都怀疑,为什么我们会走到今天这一步。在脚本语言里我最喜欢的编程语言是 Lua 和 Python,如果说我喜欢 Lua 源于我对游戏开发的兴趣,喜欢 Python 源于我对编写网页爬虫的兴趣,那么我很庆幸今天我又多了一个使用 Lua 的原因。世上美好的事情莫过于,你喜欢一样东西,恰好有人和你一样喜欢,可惜那是很久以前的事情啦。

  我们现在可以了解到,Redis 提供了一种机制可以让 Lua 脚本同 Redis 进行交互。可是事实上 Redis 和 Lua 在数据结构定义上存在一定差异。所以,下面我们来了解下这两种数据结构是如何进行转换的,了解完这些我认为这篇文章就可以结束啦,因为现在接近 1 点钟啦而明天还要上班。在 Lua 脚本中调用 call()或者 pcall()方法来执行 Redis 命令时,Redis 命令执行的结构会被转换为 Lua 中的数据结构。同理,当 Lua 脚本在终端中执行时,Lua 脚本的返回值会被转化为 Redis 的协议并经由 EVAL 返回给客户端。关于 call()和 pcall()这两个方法,一个显著的区别是前者在出错时返回的是错误信息,而后者返回的是经由 Lua table 包装后的结果。我们知道 table 在 Lua 语言中是一个非常强大的数据结构,显然后者对调用者更为友好些啦。通常在处理类型转换时我们有以下原则:

  • Lua table 结构中不能含有 nil,否则 Redis 将从第一个为 nil 的位置返回
  • Lua number 结构中不能区分浮点类型,默认会转换为整型并舍弃小数部分,如果需要保留小数部分请返回 string 类型
  • Lua boolean 结构在 Redis 中会被转换为 0 和 1 的取值
  • Redis 提供了 redis.error_reply()和 redis.error_status()两个辅助方法来完成 Lua->Redis 的转换

  好了,这篇博客就是这样子啦,关于为什么使用 Lua 脚本这个问题,我认为可以从减少网络开销、原子性和脚本复用三个角度来考虑,尤其是第二点,因为 Redis 执行脚本的时候是整体的、阻塞的执行,中间不会被插入新的命令,因此它完全可以不用担心出现竞态或者事务相关的问题,可是即使这样我们还是建议编写短小精悍的 Lua 脚本。以上就是这篇博客的全部内容啦,感谢大家关注,欢迎在博客留言及讨论相关技术问题,谢谢大家。

参考文章