各位朋友,大家好,我是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脚本。以上就是这篇博客的全部内容啦,感谢大家关注,欢迎在博客留言及讨论相关技术问题,谢谢大家。

参考文章