redis中使用Lua脚本

本文中的相关环境版本信息如下

redis版本:Redis 5.0.14

介绍

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了可以让Lua脚本嵌入到应用程序中,从而为应用程序提供灵活的扩展和定制功能虽然简单小巧但是功能强大,所以许多应用都选用它作为脚本语言。


Lua 是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de janeiro)里的一个研究小组于1993年开发的,该小组成员有:Robertolerusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo。


redis从2.6的版本开始引入对Lua脚本的支持,redis服务器内部集成了Lua解释器,从而允许redis客户端通过发送Lua脚本到redis服务器,redis服务器会单线程原子性的执行lua脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。


Lua脚本语言官方地址:点我去Lua脚本语言官网

redis中使用Lua脚本的优点

①、Lua脚本在redis中是原子执行的 执行过程中不会插入其他命令。

在redis中 使用 EVAL 或 EVALSHA 命令 执行Lua脚本时 整个脚本作为一个单一操作执行 它具有原子性。这意味着在脚本执行期间 不会有任何其他redis客户端的命令请求插进来执行,保证了lua脚本中多条redis命令的连续性和事务完整性。


这里需要注意2点的是:

1、lua本身并没有提供对于原子性的直接支持,它只是一种脚本语言,通常是嵌入到其它宿主程序中运行,比如redis。

2、在redis中执行lua脚本的原子性是指:整个lua脚本在执行期间 不会被其它redis客户端的命令打断。


Lua脚本可以将多条命令一次性进行打包 只需要执行一次 而这一次执行 可以执行多条redis命令  且只需要连接一次redis服务端 从而有效地减少网络IO开销。

Lua脚本能够一次性打包多个redis命令 这样客户端只需要向服务器发送一次请求(包含脚本内容或哈希值) 就能执行一组命令 大大减少了网络往返次数 提高了系统的性能和效率。尤其对于需要保证一致性且涉及多个键操作的场景尤为适用。


③、Lua脚本可以帮助开发和运维人员创造出自己定制的命令 并可以将这些命令常驻在redis内存中 实现复用的效果。

通过编写lua脚本 开发和运维人员可以创建出满足特定业务需求的复合操作 这些脚本一旦被加载到redis 中 就可以像内置命令一样反复调用。由于redis支持将Lua脚本script的SHA1哈希值保存 并用于后续快速调用 因此可以实现脚本的复用 避免了每次调用都重新发送整个脚本文本带来的网络开销。

开胃小菜 小试牛刀 先来举个使用例子扒

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> expire k1 30
(integer) 1
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379>

上面这三个redis命令 分别需要执行三次 发生了三次网络访问IO 而且这三个redis命令无法保证原子性。我们就可以在redis中使用lua脚本 将这三个redis命令 一次性全部进行执行。 就是说 redis最终只会只执行一次  redis只发生一次网络IO  并且还会保证这三个redis命令执行的原子性。


上述操作 换成在redis cli中使用lua脚本的具体执行命令如下所示:

127.0.0.1:6379>EVAL "redis.call('set','k1','v1') redis.call('expire','k1','30') return redis.call('get','k1')" 0
"v1"#上面这一行代码回车后 redis最终返回了v1


#大概解释下上面代码的意思#
# EVAL 表示 用于在redis服务器端执行lua脚本


# redis.call() 是redis为Lua脚本提供的一个函数 通过它可以在lua脚本中调用redis的命令 在Lua脚本中使用redis.call('COMMAND_NAME', key1, arg1, arg2...) 时 实际上是在调用redis对应的命令。
#例如redis.call('set','k1','v1')这段lua脚本 它会在redis中执行set k1 v1命令。


# return redis.call('get','k1') 会在redis中执行 get k1 命令 并将结果值进行return(返回)


# 0 表示不需要向该lua脚本中传递任何键参数 虽然脚本里有'k1'这个键 但它是直接写在lua代码里的 并非作为外部传递给脚本的参数。
#由于这个例子中的所有键都是在lua脚本内部固定写死的 所以numkeys的值是要给0。

123.png

正式开始 在redis中使用Lua

在redis中执行Lua脚本主要有两个命令:EVAL 和 SCRIPT LOAD 和 EVALSHA 命令

EVAL命令

eval script numkeys key [key ...] arg [arg ...]


script:是一段lua脚本代码

numkeys:指定这段lua脚本代码 参数有几个key,即:key [key ...]中key的个数。如没有key 则为0

key [key ...] 表示在这段lua脚本中所用到的那些key,在redis中 该参数一般都会传具体的要使用的redis的命令。在lua脚本中可以通过KEYS[1] KEYS[2]获取。注意:这里的下标索引是从1开始 不是从0开始 即KEYS[1]表示获取第一个key(键)参数

arg [arg ...] 普通参数列表。在Lua脚本中通过ARGV[1] ARGV[2]获取 和上面的KEYS一样 下标索引都是从1开始


#eval script numkeys key [key ...] arg [arg ...]翻译成中文 如下所示:#
eval lua脚本内容 键名参数的总数 键名参数列表 普通参数列表


eval命令的使用示例:

#例1:numkeys=1,KEYS数组只有1个元素hellolua,ARGV数组无元素
127.0.0.1:6379> eval "return KEYS[1]" 1 hellolua
"hellolua"
127.0.0.1:6379>


#例2:numkeys=2,KEYS数组有2个元素 hellolua 和 wzyl,ARGV数组无元素。另外下面中的".."符号 是lua脚本语言中的连接字符串的意思 相当于php中的"." 或者js中的"+" 就是一个拼接字符串的意思
127.0.0.1:6379> eval "return KEYS[1]..KEYS[2]" 2 hellolua wzyl
"helloluawzyl"
127.0.0.1:6379>


#例3:numkeys=0,KEYS数组无元素,ARGV数组元素中有1个元素value1
127.0.0.1:6379>eval "return ARGV[1]" 0 value1
"value1"
127.0.0.1:6379>

eval命令 之 例4:以参数形式动态调用redis命令

127.0.0.1:6379> eval "redis.call(KEYS[1], KEYS[2], ARGV[1]) redis.call(KEYS[3],KEYS[4],ARGV[2]) return redis.call(KEYS[5], ARGV[3])" 5 set kk expire kk get vv 30 kk


上面这行以参数形式动态调用redis命令的lua脚本 其实就是执行了三个redis命令 分别是 set kk vv; expire kk 30; get kk; 最后将get kk的结果返回


所以 KEYS[1] KEYS[2] KEYS[3] KEYS[4] KEYS[5] 分别对应 set kk expire kk get; 而ARGV[1] ARGV[2] ARGV[3] 分别对应 vv 30 kk;


#例5:使用lua脚本设置一个redis的其它数据类型(主要演示 在lua中调用redis 除了常见的string类型 看下redis的其它类型在lua中如何进行设置使用) 这里以redis的hash类型为例:
127.0.0.1:6379>eval "redis.call('hset','site','redis','redis.com') return redis.call('hget','site','redis')" 0
"redis.com"
127.0.0.1:6379>


#例6:使用if else的使用示例
set k1 v1 #先设置一个k1这个key 值是v1

eval "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end " 1 k1 v1
(integer) 1 #执行成功 lua脚本判断成立 执行了then后面的redis.call 而redis.call里面调用了redis的del命令 所以删除了k1这个键 因为我们上面先设置了k1这个键
127.0.0.1:6379>


在lua脚本中除了可以使用redis.call()函数来调用redis命令之外 还可以使用redis.pcall()函数来调用redis命令,这两个函数的主要区别就是遇到错误的处理方式及遇到错误后 lua脚本是否继续执行 具体如下所示:


①、reids.call():当redis.call()函数在执行redis命令的过程中发生错误时 lua脚本会停止执行并抛出一个lua类型的错误 这个错误会被 EVAL 命令捕获并返回给调用者。并且不在执行lua脚本的后续代码,示例如下:

127.0.0.1:6379>  EVAL "redis.call('geta', 'foo') return 'qaq' " 0
(error) ERR Error running script (call to f_134fcbd7959fa1cdd6609189ea538155ad4fcb8e): @user_script:1: @user_script: 1: 
Unknown Redis command called from Lua script

# 可以看到 遇到错误 直接报错并且没有执行后续的 return 'qaq'


②、reids.pcall():当redis.pcall()函数在执行redis命令的过程中遇到错误时不会引发错误。它会捕获错误并返回一个表示错误的lua表格(table)类型,该表格包含一个名为error的域,用于存储错误信息,以及可能的其他相关数据。 另外 它并不会停止lua脚本的执行。所以 即使一个redis.pcall()遇到错误执行失败 脚本中的后续代码仍然可以继续执行 示例如下:

#执行redis命令出错 但是后续仍有代码可以执行的情况下:
127.0.0.1:6379>  EVAL "redis.pcall('geta', 'foo') return 'qaq' " 0
"qaq"  #可以看到redis.pcall在执行命令过程中遇到错误 但是后续仍有代码需要执行 则继续执行后续代码 并没有因为遇到错误而停止整个lua脚本的代码执行
127.0.0.1:6379>



#执行redis命令出错 后续没有代码可以执行的情况下:
127.0.0.1:6379> EVAL "return redis.pcall('getaa', 'foo')" 0
(error) @user_script: 1: Unknown Redis command called from Lua script
127.0.0.1:6379>


redis.call()函数和redis.pcall()函数的总结:

①、redis.call() 在遇到错误时会中断脚本的执行 而redis.pcall()则会捕获错误并允许脚本继续执行。如果你希望在执行redis命令过程中发生错误时 继续执行lua脚本的其余部分 则可以选择使用redis.pcall() 否则就选择使用redis.call()。使用时一定要注意redis.call()函数出错时会中断lua脚本的执行,为了保证脚本的原子性 要谨慎使用。


②、redis.call()和redis.pcall都是同步执行的 所以 它会阻塞lua脚本的执行。 当lua脚本在redis中通过redis.call()或redis.pcall()发出命令请求时,lua会暂停脚本的进一步执行,等待redis服务端处理该命令并返回结果。在此期间 redis服务依然继续运行并处理其它客户端请求,但当前lua脚本是处于阻塞等待状态的。这里所说的“阻塞”是指阻塞了lua脚本在redis中的执行流程,并非阻塞整个redis服务器的执行。

SCRIPT LOAD 和 EVALSHA命令

在上面的例子中 lua脚本的内容很短。如果脚本的内容很长 而且客户端需要频繁执行 那么每次都需要传递冗长的脚本内容势必比较浪费网络流量。所以redis还提供了 SCRIPT LOAD 和 EVALSHA 命令来解决这个问题。


LOAD 命令用于将redis客户端提供的lua脚本传递到redis服务而不执行,但是会得到脚本的唯一id,这个唯一id是用来唯一标识redis服务缓存的这段lua脚本,它是由redis使用sha1算法揉捏脚本内容而得到的一个很长的字符串。有了这个唯一id,后面redis客户端就可以通过 EVALSHA 命令来反复执行这个脚本了。 我们知道redis有incrby命令可以完成整数的自增操作,但是没有提供自乘这样的指令。


下面我们使用 SCRIPT LOAD 和 EVALSHA 命令来完成自乘运算:

local curVal = redis.call("get", KEYS[1])
if curVal == false then
  curVal = 0
else
  curVal = tonumber(curVal)
end
curVal = curVal * tonumber(ARGV[1])
redis.call("set", KEYS[1], curVal)
return curVal


先将上面的脚本单行化,语句之间使用分号隔开:

local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal


然后在redis中加载这段lua脚本代码:

127.0.0.1:6379> script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'


"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"


redis客户端命令行输出了一串很长的字符串,它就是这段lua脚本的唯一标识id 下面我们使用EVALSHA命令配合这个唯一标识id来执行已经被缓存在redis服务中的这段lua脚本代码:

127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 notexistskey 5
(integer) 0
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 notexistskey 5
(integer) 0
127.0.0.1:6379> set foo 1
OK
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo 5
(integer) 5
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo 5
(integer) 25

其它相关命令

SCRIPT EXISTS 命令

命令格式:

SCRIPT EXISTS sha1 [sha1 …]

作用:给定一个或多个脚本的 SHA1 校验和,返回一个包含 0 和 1 的列表 表示校验和所指定的脚本是否已经被保存在redis服务端中的缓存当中。

SCRIPT FLUSH 命令

命令格式:

SCRIPT FLUSH

作用:清除redis服务端所有lua脚本缓存。

SCRIPT KILL 命令

命令格式:

SCRIPT KILL

作用:杀死当前正在运行的lua脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。 这个命令主要用于终止运行时间过长的脚本,比如一个因为bug而发生无限loop的脚本,诸如此类。


假如当前正在运行的脚本已经执行过写操作,那么即使执行SCRIPT KILL,也无法将它杀死,因为这是违反lua脚本的原子性执行原则的。在这种情况下 唯一可行的办法是使用SHUTDOWN NOSAVE命令 通过停止整个redis进程来停止脚本的运行,并防止不完整(half-written)的信息被写入数据库中。

最后需要注意的点

本文中一直在强调lua脚本保证原子性操作,其实这个lua脚本的原子性操作跟传统的关系型数据库(如MySQL)中的原子性(ACID原子性)操作还是有一些区别的。


关系型数据库中的ACID(如MySQL)的原子性 和 redis执行lua脚本原子性在概念上的差异如下:

ACID的原子性是指:命令要么全执行,要么全部不执行。


redis中执行lua脚本原子性是指:lua脚本中的代码 需要作为一个整体执行且执行过程中不被其它事务打断,这里强调的是 lua脚本中的代码 它们作为一个整体、一次连续的操作被执行。至于lua脚本里面的命令是否必须全部成功,或者全部失败,并不要求。



声明:禁止任何非法用途使用,凡因违规使用而引起的任何法律纠纷,本站概不负责。

小周博客
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦

精彩评论

全部回复 0人评论 7,777人参与

loading