各位朋友,大家好,我是 Payne,欢迎大家关注我的博客,我的博客地址是https://qinyuanpei.github.io。想起来大概有一个月没有更新博客啦。或许是因为这中间发生了太多的事情,想来人生原本就充满曲折和变数。在微信群里得知家中舅爷去世的消息,突然意识到时间早已摧毁你我的一切。那个曾经同你有千丝万缕联系的人,会在某一刻同你彻底失去联系。所以我更珍视彼此在一起的时光,因为在这个世界上每天都面临着改变。有时候工作上遇到不开心的时候,会想着一个人去一个陌生的地方,我们就在不断地相聚和离别中慢慢老去。这段时间一直在学习做饭,为此特意买了本菜谱,结果发现,最难的并不是如何去做好一道菜,而是你为了做好一道菜需要准备各种食材,就像人与人交流并没有什么困难,真正困难的地方,是你找不到一个可以一直陪你说话的人。熟悉的店面会被拆迁转让,熟悉的人事会被错过改变,上帝想把世界煮成一锅粥,可味道的调配却由我们来掌控。
好了,所谓“如人饮水,冷暖自知”,人生奇就奇在你没有办法用三言两语去描述它。这段时间面试过两三家公司,整体上感觉自己的生活太安逸了些,虽然我现在依然住在租来的房子里,转眼间 2017 年接近尾声啦,可是回想起来今年年初制定的计划,在广泛阅读和提升技术上都是不及格的状态,印象中打算研究 Redis 和 MonogoDB 这两种数据库的(因为没有购买为知笔记会员导致部分笔记损坏或者丢失),然而到现在为止我还有研究完 Redis。尤其当我面试的时候,我发现好多我写在简历上的内容,都会成为某种意义上的呈堂证供,这让我更加确信好多东西需要不断地去巩固,所以尝试在实际项目上使用 Moq、考虑怎么写出更好的测试方法以及时刻保持自我的不可替代性,这些都是我最近在考虑的事情,有时候发脾气是因为觉得自己在浪费生命,可越是被这种无力感笼罩的时候,就越是要对自己狠一点儿,所以在这篇博客中,让我们重新拾起对 Redis 的学习兴趣,今天我们来说说 Redis 中的 Lua 脚本。
熟悉我博客的朋友一定都知道,我曾经开发过 Unity3D 相关的项目,而 Lua 脚本正是 Unity3D 中主流的热更新方案。关于 Lua 脚本相关的文章,大家可以通过下面的链接来了解,在这里我们不再讲述 Lua 的基础内容,本篇文章所讲述的是如何通过 Redis 内置的 Lua 解释器来执行脚本,我们为什么使用脚本语言进行开发呢,因为这样可以降低开发的难度啊。
- 脚本语言编程:Lua 脚本编程入门
- 在 Windows 下使用 Visual Studio 编译 Lua5.3
- Unity3D 游戏开发之 Lua 与游戏的不解之缘(上)
- Unity3D 游戏开发之 Lua 与游戏的不解之缘(中)
- Unity3D 游戏开发之 Lua 与游戏的不解之缘(下)
- Unity3D 游戏开发之 Lua 与游戏的不解之缘终结篇:UniLua 热更新完全解读
好了,既然我们已然了解到 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 命令的基本格式如下:
EVAL script numkeys key [key ...] arg [arg ...]
我们可以注意到在这里 EVAL 命令由三部分组成,即第一个部分,表示一段 Lua 脚本程序,并且这段脚本不需要更不应该定义函数;第二部分,表示参数列表,指在脚本中需要用到的键,因为 Redis 是一个键值数据库,这些键名可以通过全局变量 KEYS 来访问,默认索引将从 1 开始,事实上我们更推荐你使用这种方式来访问键名;第三部分,表示除建键名参数以外的附加参数,和第二部分类似,这里我们可以通过全局变量 ARGV 来访问,这里就不再赘述啦。我们一起来看下面的例子:
EVAL "return {KEYS[1],KEYS[2]}" 2 ab cd
此时我们会返回一个由 KEYS[1]和 KEYS[2]组成的集合,集合中的两个元素分别是 ab、cd,注意到这里有一个参数 2,它表示我们这里将有两个参数,事实上 Redis 将从这个位置开始解析参数,所以我们必须告诉 Redis 参数解析到什么位置结束,因为主要参数(KEYS)和附加参数(ARGV)是从解析的角度上是无法区分的,所以我们期望的结果会是:
1) "ab"
2) "cd"
现在我们来增加点难度,显然你明白我在说什么,请注意我要引入附加参数(ARGV)啦!
EVAL "return {KEYS[1]..ARGV[2] ,KEYS[2]..ARGV[1] }" 2 ab cd ab cd
这里我们尝试对 KEYS 和 ARGV 进行拼接,需要说明的是 Lua 中连接字符串使用的是. .,所以这里将得到结果:
1) "abcd"
2) "cdab"
好了,现在大家应该理解 EVAL 这个命令的使用方法啦,那么对 EVALSHA 命令来说,顾名思义,它就是使用了 SHA1 验证的 EVAL 方法,我们注意到现在脚本都是定义在 EVAL 命令的第一个参数上,假如我们需要复用一个脚本,而该脚本可以为我们提供 Sum 这样的功能,即它可以返回一组参数的和给我们,显然参数的个数是不同的,那么这个时候我们总不能每次都重复写这个脚本吧,所以 Redis 会为脚本创建一个指纹,我们使用 EVALSHA 命令来传入一个指纹,Redis 将从缓存的脚本中找到这个脚本,并结合我们的参数来调用它,这样我们就可以获得脚本执行以后的结果,关于指纹的这种说法,大家可以结合 Git 提交代码时的感受进行理解,除此以外,它和 EVAL 在使用方法上是完全一致的,所以不再举例子说明啦。
Script 系列
好了,下面我们来介绍第二类和 Lua 脚本相关的 API,相比 Eval 给人云里雾里的感觉,Script 系列的命令处处洋溢着规范命名的美好气息,我们通过这些命令的名字基本上就可以知道它是做什么事情的,这告诉我们平时写代码的时候如何去写出优雅的代码。我们通过下面一组命令来了解 Script 系列命令的具体用法:
/* 载入一个脚本到缓存中 */
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,该脚本将对集合中的元素进行求和:
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 中准备些数据来做好准备,在终端中执行命令:
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 脚本。以上就是这篇博客的全部内容啦,感谢大家关注,欢迎在博客留言及讨论相关技术问题,谢谢大家。