Redis与Lua脚本

Posted 并发编程之美

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis与Lua脚本相关的知识,希望对你有一定的参考价值。

介绍

Lua是一个高效的轻量级脚本语言。能够方便地嵌入到其他语言中使用。将逻辑放到脚本上,可以不用重启服务,只更新脚本,就可以实现程序的更新。很多ios游戏中都使用了Lua语言,《愤怒的小鸟》就是使用Lua语言实现的关卡,《魔兽世界》的插件也是使用Lua语言开发的。


一、Lua脚本介绍

Redis允许开发者使用Lua语言编写脚本传到Redis中执行。在Lua脚本中可以调用大部分的Redis命令。使用脚本的好处如下:

  • 减少网络开销:使用脚本功能只需要发送一个请求,减少了网络往返时延

  • 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。事务可以完成的所有功能都可以用脚本来实现

  • 复用:客户端发送的脚本会永久存储在Redis中,这就意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑


因为无需考虑事务,使用Redis脚本实现访问频率限制非常简单。Lua代码如下:

local times=redis.call('incr', KEYS[1]) if times==1 then -- KEYS[1]键刚创建,所以为其设置生存时间 redis.call('expire', KEYS[1], ARGV[1]) end if times>tonumber(ARGV[2]) then  return 0 end  return 1

那么,如何测试这个脚本呢?首先把这段代码存为ratelimiting.lua,然后在命名行中输入:

redis-cli --eval /path/to/ratelimiting.lua rate.limiting:127.0.0.1 , 10 3
  • --eval 参数是告诉redis-cli读取并运行后面的Lua脚本

  • /path/to/ratelimiting.lua是ratelimiting.lua文件的位置

  • 后面跟着的是传给Lua脚本的参数。其中“,”前的rate.limiting:127.0.0.1是要操作的键,可以在脚本中使用KEYS[1]获取,“,”后面的10和3是参数,在脚本中能够使用ARGV[1]和ARGV[2]获得

结合脚本的内容可知这行命令的作用就是将访问频率限制为每10秒最多3次,所以在终端中不断地运行此命令会发现当访问频率在10秒内小于或等于3次时返回1,否则返回0。


注意:上面的命令中","两边的空格不能省略,否则会出错。

二、Lua语法

对Lua语言感兴趣的推荐阅读Lua作者Roberto Ierusalimschy写的《Programming in Lua》这本书。我们重点学习下lua的表结构和函数。

表结构

表是Lua中唯一的数据结构,可以理解为关联数组,任何类型的值(除了空类型)都可以作为表的索引。表的定义方式为:

a={} -- 将变量a赋值为一个空表 a['field']='value' -- 将field字段赋值value print(a.field) -- 打印内容为'value',a.field是a['field']的语法糖。
--也可以这样定义 people={ name='Bob', age=29 }print(people.name) -- 打印的内容为'Bob'

当索引为整数的时候表和传统的数组一样,例如:

a={} a[1]='Bob' a[2]='Jeff'
// 可以写成 注意 Lua约定数组① 的索引是从1开始的,而不是0。a={'Bob', 'Jeff'} print(a[1]) --打印的内容为'Bob'

可以使用通用形式的for语句遍历数组,例如:

for index, value in ipairs(a) do print(index) --index迭代数组a的索引  print(value) --value迭代数组a的值 end-- 打印的结果是: -- 1-- Bob-- 2-- Jeff

ipairs是Lua内置的函数,实现类似迭代器的功能。当然还可以使用数字形式的for语句遍历数组,例如:

for i=1, #a do print(i) print(a[i])end

输出的结果和上例相同。#a的作用是获取表a的长度。Lua还提供了一个迭代器pairs,用来遍历非数组的表值,例如:

people={ name='Bob', age=29}
for index, value in pairs(people) do print(index) print(value)end-- 打印结果为:-- name -- Bob -- age -- 29

pairs与ipairs的区别在于前者会遍历所有值不为nil的索引,而后者只会从索引1开始递增遍历到最后一个值不为nil的整数索引。


函数

函数的定义为:

function(参数列表) 函数体 end

可以将其赋值给一个局部变量,比如:

local square=function(num) return num * numend

这段代码会被转换为:

local squaresquare=function(num)return num * numend

因为在赋值前声明了局部变量square,所以可以在函数内部引用自身(实现递归)。如果实参的个数小于形参的个数,则没有匹配到的形参的值为nil。相对应的,如果实参的个数大于形参的个数,则多出的实参会被忽略。如果希望捕获多出的实参(即实现可变参数个数),可以让最后一个形参为...。比如,希望传入若干个参数计算这些数的平方:

local function square (...) local argv = {...}  for i=1, #argv do argv[i]=argv[i] * argv[i] end return unpack(argv)enda, b, c=square(1, 2, 3) print(a)print(b)print(c)//输出结果为:-- 1-- 4-- 9

在第二个square函数中,我们首先将...转换为表argv,然后对表的每个元素计算其平方值。unpack函数用来返回表中的元素,在上例中argv表中有3个元素,所以return unpack(argv)相当于 return argv[1], argv[2], argv[3]。


在Lua中return和break语句必须是语句块中的最后一条语句,简单地说在这两条语句后面只能是end,else或until三者之一。如果希望在语句块的中间使用这两条语句的话可以人为地使用do和end将其包围。


三、Redis使用Lua脚本

在脚本中可以使用redis.call函数调用Redis命令。就像这样:

redis.call('set', 'foo', 'bar')local value=redis.call('get', 'foo') --value的值为bar

redis.call函数的返回值就是Redis命令的执行结果。redis.call函数会将5种类型的回复转换成对应的Lua的数据类型,空结果比较特殊,其对应Lua的false。具体的对应规则如下:

Redis返回值类型 Lua数据类型
整数回复
数字类型
字符串回复
字符串类型
多行字符串回复
表类型(数组形式)
状态回复
表类型(只有一个ok字段存储状态信息)
错误回复
表类型(只有一个err字段存储状态信息)

Redis还提供了redis.pcall函数,功能与redis.call相同,唯一的区别是当命令执行出错时redis.pcall会记录错误并继续执行,而redis.call会直接返回错误,不会继续执行。下面介绍几个执行脚本的redis命令。


EVAL命令

编写完脚本后最重要的就是在程序中执行脚本。Redis提供了EVAL命令可以使开发者像调用其他Redis内置命令一样调用脚本。可以通过key和arg这两类参数向脚本传递数据,它们的值可以在脚本中分别使用KEYS和ARGV两个表类型的全局变量访问。例如,我们希望用脚本功能实现一个SET命令,脚本内容是这样的:

return redis.call('SET', KEYS[1], ARGV[1])

打开redis-cli执行此脚本:

127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 foo barOK127.0.0.1:6379> GET foo"bar"

注意:EVAL命令依据第二个参数将后面的所有参数分别存入脚本中KEYS和ARGV两个表类型的全局变量。当脚本不需要任何参数时也不能省略这个参数(设为0)。


EVALSHA命令

考虑到在脚本比较长的情况下,如果每次调用脚本都需要将整个脚本传给Redis会占用较多的带宽。为了解决这个问题,Redis提供了EVALSHA命令允许开发者通过脚本内容的SHA1摘要来执行脚本,该命令的用法和EVAL一样,只不过是将脚本内容替换成脚本内容的SHA1摘要。


Redis在执行EVAL命令时会计算脚本的SHA1摘要并记录在脚本缓存中,执行EVALSHA 命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了则执行脚本,否则会返回错误:“NOSCRIPT No matching script. Please use EVAL.” 在程序中使用EVALSHA命令的一般流程如下:

  • 先计算脚本的SHA1摘要,并使用EVALSHA命令执行脚本

  • 获得返回值,如果返回“NOSCRIPT”错误则使用EVAL命令重新执行脚本

除了EVAL和EVALSHA外,Redis还提供了其他4个脚本相关的命令,一般都会被客户端封装起来,开发者很少能使用到。


1. 将脚本加入缓存:SCRIPT LOAD

每次执行EVAL命令时Redis都会将脚本的SHA1摘要加入到脚本缓存中,以便下次客户端可以使用EVALSHA命令调用该脚本。如果只是希望将脚本加入脚本缓存而不执行则可以使用SCRIPT LOAD命令,返回值是脚本的SHA1摘要。如下:

127.0.0.1:6379> SCRIPT LOAD "return 1""e0e1f9fabfc9d4800c877a703b823ac0578ff8db"

2. 判断脚本是否已经被缓存:SCRIPT EXISTS

SCRIPT EXISTS命令可以同时查找1个或多个脚本的SHA1摘要是否被缓存,如:

127.0.0.1:6379> SCRIPT EXISTS e0e1f9fabfc9d4800c877a703b823ac0578ff8dbabcdefghijklmnopqrstuvwxyzabcdefghijklmn 1) (integer) 1 2) (integer) 0

3. 清空脚本缓存:SCRIPT FLUSH

Redis将脚本的SHA1摘要加入到脚本缓存后会永久保留,不会删除,但可以手动使用SCRIPT FLUSH命令清空脚本缓存:

127.0.0.1:6379> SCRIPT FLUSHOK

4. 强制终止当前脚本的执行:SCRIPT KILL

如果想终止当前正在执行的脚本可以使用SCRIPT KILL命令。


四、原子性

Redis的脚本执行是原子的,即脚本执行期间Redis不会执行其他命令。所有的命令都必须等待脚本执行完成后才能执行。为了防止某个脚本执行时间过长导致Redis无法提供服务,Redis提供了lua-time-limit参数限制脚本的最长运行时间,默认为5秒钟。当脚本运行时间超过这一限制后,Redis将开始接受其他命令但不会执行(以确保脚本的原子性,因为此时脚本并没有被终止),而是会返回“BUSY”错误。现在我们打开两个redis-cli实例A和B来演示这一情况。首先在A中执行一个死循环脚本:

127.0.0.1:6379> EVAL "while true do end" 0

然后马上在B中执行一条命令:

127.0.0.1:6379> GET foo(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL orSHUTDOWNNOSAVE.(3.74s)


这时实例B中的命令并没有马上返回结果,因为Redis已经被实例A发送的死循环脚本阻塞了,无法执行其他命令。等到脚本执行5秒钟后实例B收到了“BUSY”错误。此时Redis虽然可以接受任何命令,但实际会执行的只有两个命令:SCRIPT KILL和 SHUTDOWN NOSAVE。在实例B中执行SCRIPT KILL命令可以终止当前脚本的运行:

127.0.0.1:6379> SCRIPT KILLOK

此时脚本被终止并且实例A中会返回错误:

(error) ERR Error running script (call f_694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7): Script killed by user with SCRIPT KILL...(28.77s)

需要注意的是如果当前执行的脚本对Redis的数据进行了修改(如调用SET、LPUSH或DEL等命令)则SCRIPT KILL命令不会终止脚本的运行以防止脚本只执行了一部分。因为如果脚本只执行了一部分就被终止,会违背脚本的原子性要求,即脚本中的所有命令要么都执行,要么都不执行。比如在实例A中执行:

127.0.0.1:6379> EVAL "redis.call('SET', 'foo', 'bar') while true do end" 0

5秒钟后在实例B中尝试终止该脚本:

127.0.0.1:6379> SCRIPT KILL(error) UNKILLABLE Sorry the script already executed write commands against the dataset.You can either wait the script termination or kill the server in an hard way using the SHUTDOWN NOSAVE command.

这时只能通过SHUTDOWN NOSAVE命令强行终止Redis。SHUTDOWN NOSAVE命令与SHUTDOWN命令的区别在于前者将不会进行持久化操作,这意味着所有发生在上一次快照后的数据库修改 都会丢失。


由于Redis脚本非常高效,所以在大部分情况下都不用担心脚本的性能。但同时由于脚本的强大功能,很多原本在程序中执行的逻辑都可以放到脚本中执行,通常来讲不应该在脚本中进行大量耗时的计算, 因为Redis是单进程单线程执行脚本,而程序能够多线程运行。


推荐阅读



看完本文有收获?请转发分享给更多人

关注「并发编程之美」,一起交流Java学习心得


以上是关于Redis与Lua脚本的主要内容,如果未能解决你的问题,请参考以下文章

Redis:ioredis 与 lua 脚本

redis与lua

Redis | 第9章 Lua 脚本与排序《Redis设计与实现》#yyds干货盘点#

redis Lua学习与坑

Redis | 第9章 Lua 脚本与排序《Redis设计与实现》

Redis主从与哨兵架构详解 Redis主从架构 如何在同一台机器搭建主从架构 Redis主从工作原理 数据部分复制 Jedis使用 Redis的管道(Pipeline) Redis Lua脚本(代码