分布式缓存Redis之Script脚本

写在前面

  本学习教程所有示例代码见GitHub:https://github.com/selfconzrr/Redis_Learning

一、简介

  Redis 脚本使用单个Lua 解释器来执行脚本,并且Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。 Reids 2.6 版本通过内嵌支持 Lua 环境。执行脚本的常用命令为 EVAL

  使用脚本的好处:

  1. 减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延;
  2. 原子操作。redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。
  3. 复用。客户端发送的脚本会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。

  现在Lua脚本用在很多游戏上,主要是Lua脚本可以嵌入到其他程序中运行,游戏升级的时候,可以直接升级脚本,而不用重新安装游戏。比如游戏的很多关卡,只需要增加lua脚本,在游戏中嵌入Lua解释器,游戏团队线上更新Lua脚本,然后游戏自动下载最新的游戏关卡。例如之前很多的游戏《愤怒的小鸟》就是用Lua语言实现的关卡。

二、常用命令

1)Eval 命令:使用 Lua 解释器执行脚本
  
语法:

redis 127.0.0.1:6379> EVAL script numkeys key [key ...] arg [arg ...]
参数说明:
  • script: 参数是一段 Lua 5.1 脚本程序。脚本不必(也不应该)定义为一个 Lua 函数。
  • numkeys: 用于指定键名参数的个数。当脚本不需要任何参数时,也不能省略这个参数(设为0)
  • key [key …]: 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)
  • arg [arg …]: 附加参数,在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。

  比如:

这里写图片描述

  其中 “return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}” 是被求值的 Lua 脚本,数字 2 指定了键名参数的数量, key1 和 key2 是键名参数,分别使用 KEYS[1] 和 KEYS[2] 访问,而最后的 first 和 second 则是附加参数,可以通过 ARGV[1] 和 ARGV[2] 访问它们。

2)Evalsha 命令:根据给定的 sha1 校验码,执行缓存在服务器中的脚本

语法:

redis 127.0.0.1:6379> EVALSHA sha1 numkeys key [key ...] arg [arg ...]
参数说明:
  • sha1: 脚本的校验码。

实例:

这里写图片描述

注意:EVAL与EVALSHA区别:

  • EVAL命令会在每次执行脚本的时候都发送一次脚本主体,它不会每次都重新编译,但是很多时候它付出了无必要的带宽来传递主体;而EVALSHA命令,它的作用与EVAL相同,但是它解决上面EVAL的带宽消耗,也就是它接受的第一个参数不是脚本而是脚本的SHA1校验和(SUM)。
  • 如果服务器还存在给定的 SHA1 校验和所指定的脚本,那么就执行这个脚本;如果服务器不存在给定的 SHA1 校验和所指定的脚本,那么它返回一个特殊的错误:提醒用户使用 EVAL 代替 EVALSHA。
  • EVAL 和 EVALSHA 可以在 O(1) 复杂度内找到要被执行的脚本,其余的复杂度取决于执行的脚本本身。

客户端库的底层实现可以一直乐观地使用 EVALSHA 来代替 EVAL ,并期望着要使用的脚本已经保存在服务器上了,只有当 NOSCRIPT 错误发生时,才使用 EVAL 命令重新发送脚本,这样就可以最大限度地节省带宽。

这也说明了执行 EVAL 命令时,使用正确的格式来传递键名参数和附加参数的重要性:因为如果将参数硬写在脚本中,那么每次当参数改变的时候,都要重新发送脚本,即使脚本的主体并没有改变,相反,通过使用正确的格式来传递键名参数和附加参数,就可以在脚本主体不变的情况下,直接使用 EVALSHA 命令对脚本进行复用,免去了无谓的带宽消耗。

3)Script Exists 命令:根据脚本的校验码,校验指定的脚本是否已经被保存在缓存当中

语法:

redis 127.0.0.1:6379> SCRIPT EXISTS script [script ...]

返回值:一个列表,包含 0 和 1 ,前者表示脚本不存在于缓存,后者表示脚本已经在缓存里面了。

实例:

这里写图片描述

4)Script Flush 命令:用于清除所有 Lua 脚本缓存

语法:

redis 127.0.0.1:6379> SCRIPT FLUSH

5)Script kill 命令:用于杀死当前正在运行的 Lua 脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。这个命令主要用于终止运行时间过长的脚本,比如一个因为 BUG 而发生无限循环的脚本。

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

语法:

redis 127.0.0.1:6379> SCRIPT KILL

6)Script Load 命令:用于将脚本 script 添加到脚本缓存中,但并不立即执行这个脚本

语法:

redis 127.0.0.1:6379> SCRIPT LOAD script

返回值:给定脚本的 SHA1 校验和。

注意:EVAL 命令也会将脚本添加到脚本缓存中,但是它会立即对输入的脚本进行求值。

如果给定的脚本已经在缓存里面了,那么不执行任何操作。在脚本被加入到缓存之后,通过 EVALSHA 命令,可以使用脚本的 SHA1 校验和来调用这个脚本。脚本可以在缓存中保留无限长的时间,直到执行 SCRIPT FLUSH 为止。

实例:

这里写图片描述

三、类型转换

  在Redis中使用lua,那么讨论的数据类型转换自然就是在Redis和lua之间转换了。当Lua通过call()或pcall()函数执行Redis命令时,命令的返回结果被转换为Lua数据结构;而Lua在Redis内置解析器中运行时,Lua脚本返回值被转换为Redis协议,然后由EVAL将处理的结果返回给客户端处理(需要时,可直接参考转换)

A、Redis与Lua转换对照表(存在对应关系)

RedisLua
integernumber
bulkstring
multi bulktable
statustable中状态信息ok
errortable中状态信息err
nil bulk/multi bulkfalse

  
B、Lua到Redis换转对照表(不存在对应关系)

LuaRedis
true1

实例:

这里写图片描述

  注意:Lua中整数和浮点数之间没有区别。所以,我们始终将Lua的数字转换成整数的回复,这样将舍去小数部分。如果就是希望Lua返回一个浮点数,那么应该将它作为一个字符串,比如ZSCORE命令。

四、日志

  在 Lua 脚本中,可以通过调用 redis.log 函数来写 Redis 日志(log):

redis.log(loglevel, message)
     其中, message 参数是一个字符串,而 loglevel 参数可以是以下任意一个值:
  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE

  上面的这些等级(level)和标准 Redis 日志的等级相对应。在redis.conf中添加配置(如果配置已存在就修改):

loglevel notice#日志等级  
logfile "/var/log/redis/redis-server.log"#日志保存路径

  对于脚本散发(emit)的日志,只有那些和当前 Redis 实例所设置的日志等级相同或更高级的日志才会被散发。

  以下是一个日志示例:

redis.log(redis.LOG_WARNING, "Something is wrong with this script.")
     执行上面的函数会产生这样的信息(在你的redis.conf文件中配置日志输出文件的目录,我的是默认的:/var/log/redis/redis-server.log):
[32343] 22 Mar 15:21:39 # Something is wrong with this script.

五、实例

  实例一:利用Redis的高效的I/O特点,实现固定时间内,限制客户端访问服务端次数,目的是为了防止客户端非法刷新或恶意攻击网站等用途。
   A、创建.lua脚本

这里写图片描述

NOTE:

KEYS->获取键名参数,这里指incr的值;
ARGV->获取非键名参数,这里指访问的次数;

在 Lua 脚本中,可以使用两个不同函数来执行 Redis 命令,它们分别是redis.call()和redis.pcall(), 两个函数的参数可以是任何格式良好(well formed)的 Redis 命令。

比如:

eval "return redis.call('set','foo','bar')" 0
   虽然上面这段脚本的确实现了将键 foo 的值设为 bar 的目的,但是,它**违反了 EVAL 命令的语义**,因为脚本里使用的所有键都应该由 KEYS 数组来传递,就像这样:
eval "return redis.call('set',KEYS[1],'bar')" 1 foo
   要求使用正确的形式来传递键(key)是有原因的,因为不仅仅是 EVAL 这个命令,所有的 Redis 命令,在执行之前都会被分析,籍此来确定命令会对哪些键进行操作。

因此,对于 EVAL 命令来说,必须使用正确的形式来传递键,才能确保分析工作正确地执行。除此之外,使用正确的形式来传递键还有很多其他好处,它的一个特别重要的用途就是确保 Redis 集群可以将你的请求发送到正确的集群节点。

区别:

  call()和pcall()很类似,唯一的区别是对错误处理的不同

  当 redis.call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因;

  redis.pcall() 出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误,但是仍继续执行。

这里写图片描述

B、如何运行

$redis-cli -a redis --eval /home/zhangruirui/script/test.lua test:127.0.0.1 , 5 2

(注意ip地址和5之间的逗号,左右各有一个空格,不能省略。。。)

NOTE:

-a redis:是你为客户端设置的密码,否则没有权限
--eval:代表通知redis-cli使用eval命令调用脚本;
/home/zhangruirui/script/test.lua:为.lua文件的位置;
test:127.0.0.1:代表模拟的访问ip地址动作;
5 2 :代表5秒之内只允许访问2次;

正如上图所示,如果5秒内访问的次数小于等于2次,则返回1,否则返回0,捕获到0这个状态之后,我们就可以做出对应的解决办法了。

实例二:通过lua脚本获取指定的key的List中的所有数据
   1)创建.lua文件

这里写图片描述

2)通过lpush给person塞入三条数据,然后输入命令来执行这个lua脚本

这里写图片描述

实例三:Ubuntu的Redis与windows的Mysql数据同步(未完成)
     从MySQL中将数据导入到Redis的Hash结构中。当然,最直接的做法就是遍历MySQL数据,一条一条写入到Redis中。这样没什么错,但是速度会非常慢。如果能够想法使得MySQL的查询输出数据直接能够与Redis命令行的输入数据协议相吻合,可以节省很多消耗和缩短时间。
实例四:找到hash中age小于指定值的所有数据,lua脚本如下:
  

这里写图片描述

其中:-- 代表注释
–[[ ]]代表多行注释

1)先储存键值对

这里写图片描述

2)在命令行中执行

$redis-cli -a redis --eval /home/zhangruirui/script/age.lua user , 24(注意逗号前后的空格)
  

最终输出

这里写图片描述

收获:老是报错:

这里写图片描述

  以为是命令写的不对,少了空格,看了好几遍,都觉得没问题啊。于是,断定问题出在.lua文件里,又去学习lua文件的调试,通过redis.log打印信息一步步找问题,恍然大悟!!!因为我在key:user里还有其他的键值对name:value

这里写图片描述

  所以,在lua脚本代码遍历myresult时(居然把所有的key排序了,按字典序遍历),所以当遍历到键name时,其对应的value值为ym,下图是redis-server.log里的信息。

这里写图片描述

  于是执行tonumber(hval)自然就是nil了,也就是那个错误attempt to compare nil with number出现的原因了,所以我又加了个特殊处理,最后结果就对啦!!!唉,折腾死我了,居然是这个原因!!!)

之所以排序的原因:

Redis中带有不确定性的命令:

SINTER
SUNION
SDIFF
SMEMEBERS
HKEYS
HVALS
KEYS

Redis提供了排序函数,使用上述命令后,会返回相同的排序结果。

六、注意事项

A、全局变量

  为了防止数据泄漏进Lua环境,Redis 脚本不允许创建全局变量。如果一个脚本需要在多次执行之间维持某种状态,应该使用Redis key来进行状态保存。如果试图在脚本中访问一个全局变量(不论这个变量是否存在)将引起脚本停止。

NOTE:

  为了防止这个问题,这里有个好的建议:将脚本中用到的所有变量,都使用local关键字显式的声明为局部变量,这也是个好的习惯。

B、脚本缓存

  Redis保证所有被运行过的脚本都会被永久保存在脚本缓存当中,当EVAL 命令在一个 Redis实例上成功执行某个脚本后,针对这个脚本的所有EVALSHA命令都会成功执行。

  另外,刷新脚本缓存的唯一办法是显式调用SCRIPT FLUSH 命令,这个命令会清空运行过的所有脚本的缓存,通常只有在云计算环境中,Redis 实例被改作其他客户或者别的应用程序的实例时,才会执行这个命令。

  缓存可以长时间储存而不产生内存问题的原因是,它们的体积非常小,而且数量也非常少,即使脚本在概念上类似于实现一个新命令,或者在一个大规模程序里有成百上千的脚本,即使这些脚本会经常修改,储存这些脚本的内存仍然是微不足道的。

  实际上,用户会发现 Redis 不移除缓存中的脚本是一个好的设计。因为对于一个和 Redis 保持持久化连接的程序来说,执行过一次的脚本会一直保留在内存中,因此它可以在管道中使用 EVALSHA 命令而不必担心因为找不到所需的脚本而产生错误。

C、纯函数脚本

  在编写脚本方面,一个重要的要求就是,脚本应该被写成纯函数(pure function)。

  也就是说,脚本应该具有以下属性:

  • 对于同样的数据集输入,给定相同的参数,脚本执行的 Redis 写命令总是相同的。脚本执行的操作不能依赖于任何隐藏(非显式)数据,不能依赖于脚本在执行过程中、或脚本在不同执行时期之间可能变更的状态,并且它也不能依赖于任何来自 I/O 设备的外部输入。

  为了确保脚本符合上面所说的属性, Redis 做了以下工作:

  • Lua 没有访问系统时间或者其他内部状态的命令;
  • Redis 会返回一个错误,阻止这样的脚本运行: 这些脚本在执行随机命令之后(比如 RANDOMKEY 、 SRANDMEMBER 或 TIME 等),还会执行可以修改数据集的 Redis 命令。如果脚本只是执行只读操作,那么就没有这一限制。注意,随机命令并不一定就指那些带 RAND 字眼的命令,任何带有非确定性的命令都会被认为是随机命令,比如 TIME 命令就是这方面的一个很好的例子。
  • 每当从 Lua 脚本中调用那些返回无序元素的命令时,执行命令所得的数据在返回给 Lua 之前会先执行一个静默(slient)的字典序排序(lexicographical sorting)。举个例子,因为 Redis 的 Set 保存的是无序的元素,所以在 Redis 命令行客户端中直接执行SMEMBERS ,返回的元素是无序的,但是,假如在脚本中执行 redis.call(“smembers”, KEYS[1]) ,那么返回的总是排过序的元素
  • 对 Lua 的伪随机数生成函数 math.random 和 math.randomseed 进行修改,使得每次在运行新脚本的时候,总是拥有同样的 seed 值。这意味着,每次运行脚本时,只要不使用 math.randomseed ,那么 math.random 产生的随机数序列总是相同的。

D、最大执行时间

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

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

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

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

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

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

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

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

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

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

  • 总是在流水线中使用 EVAL 命令;
  • 检查流水线中要用到的所有命令,找到其中的 EVAL 命令,并使用 SCRIPT EXISTS 命令检查要用到的脚本是不是全都已经保存在缓存里面了。如果所需的全部脚本都可以在缓存里找到,那么就可以放心地将所有 EVAL 命令改成 EVALSHA 命令,否则的话,就要在流水线的顶端(top)将缺少的脚本用 SCRIPT LOAD 命令加上去。

------至所有正在努力奋斗的程序猿们!加油!!
有码走遍天下 无码寸步难行
1024 - 梦想,永不止步!
爱编程 不爱Bug
爱加班 不爱黑眼圈
固执 但不偏执
疯狂 但不疯癫
生活里的菜鸟
工作中的大神
身怀宝藏,一心憧憬星辰大海
追求极致,目标始于高山之巅
一群怀揣好奇,梦想改变世界的孩子
一群追日逐浪,正在改变世界的极客
你们用最美的语言,诠释着科技的力量
你们用极速的创新,引领着时代的变迁

——乐于分享,共同进步,欢迎补充
——Any comments greatly appreciated
——诚心欢迎各位交流讨论!QQ:1138517609
——CSDN:https://blog.csdn.net/u011489043
——简书:https://www.jianshu.com/u/4968682d58d1
——GitHub:https://github.com/selfconzrr

  • 6
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BugFree_张瑞

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值