Redis从入门到精通(3):Lua脚本与持久化

Lua语言

lua是一种轻量级的脚本语言,Redis从2.6版本引入对lua脚本的支持。
Redis会将整个脚本作为一个整体执行,与事务类似。
接下来我们将简单的学习Lua。

数据类型

lua是一个动态类型语言,一个变量可以存储任何类型的值。编写Redis脚本常用的几种数据类型如下:

在这里插入图片描述

变量

Lua的变量分为全局变量和局部变量。全局变量无需声明就可以直接使用,默认值是nil。

例如:
		a=1
		print(b)  --无需声明即可使用,默认值为nil
		a=nil      --删除全局变量的方法是将其赋值为nil,全局变量没有声明和未声明之分,只有非nil和nil的区别

	在Redis脚本中不能使用全局变量,只允许使用局部变量以防止脚本之间相互影响。声明局部变量的方法为local变量名。
		local  c    --声明一个局部变量,默认值为nil
		local  d,e  --可以同时声明多个局部变量

	局部变量的作用域为从声明开始到所在层的语句块末尾。
	例如:
		local x=10
		if true then
		      local x=x+1
		      print(x)
		      do
			local x=x+1
			print(x)
		      end
		end
		print(x)
	
	打印结果为:
		11
		12
		11
		10

注释

单行注释 : --这是一个单行注释
多行注释:–[[
这是多行注释
]]

赋值

Lua支持多重赋值 例如:
local a,b=1,2 --a的值是1,b的值是2
local c,d=1,2,3 --c的值是1,d的值是2,3被舍弃了
local e,f=1 --e的值是1,f的值为 nil

操作符

Lua的基本操作符和java基本类似,下面只写明与JAVA不同用法的操作符。

(1)数学操作符:数学操作符的操作数如果是字符串会自动转换成数字
        print('1'+1)  --结果为2

(2)比较操作符:
        ==:比较两个操作数的类型和值是否都相等
        ~=:检测两个值是否相等,相等返回 false,否则返回 true
        print(1=='1')--false,两者类型不同

        如果需要比较字符串和数字,可以手动进行类型转换。
        print(1==tonumber('1')) --true
        print('1'==tostring(1))   --true
        
        tonumber函数还可以进行进制转换
        print(tonumber('F',16))   --将字符串'F'从16进制转成十进制,结果是15

(3)逻辑操作符
        not:   操作数为真,返回false,反之。
        and:  a and b如果a是真则返回b,否则返回a
        or:     a or b 如果a是真则返回a,否则返回b
	
        只要操作数不是nil或false,逻辑操作符就认为操作数是真,否则是假。
        即使是0或者空字符串也被当作真。
	
        print(1 and 5)   --5
        print(1 or 5)     --1
        print(not 0)      --false
        print(' ' or 1)     -- ' '

        Lua的逻辑操作符支持短路,也就是说对于false  and  add(),Lua不会调用add函数,因为第一个操作数已经决定无论add结果是什么,表达式结果都是false。or也一样。

(4)连接操作符:..  用来连接两个字符串  连接操作符会自动把数字类型的值转换成字符串类型
        print('hello'..' '..'world!')     --'hello world!'

(5)取长度操作符。 #   用来获取字符串或表的长度
        print(#'hello')       --5

运算符的优先顺序如下:
在这里插入图片描述

if语句和循环语句

		if  条件表达式  then
		    语句块
		elseif  条件表达式 then
  		    语句块
		else 
		    语句块
		end

我们需要注意在lua中只有nil和false为假,其余值都被认为是真值
当Redis和Lua结合时要特别注意命令的返回值。

循环语句
lua支持while,repeat和for循环语句。

		(1)while语句的格式为:
			while 条件表达式 do
			         语句块
			end
		
		(2)repeat语句的格式为:
			repeat
			          语句块
			until   条件表达式
		
		(3)for语句有两种格式:
			(1)数字格式
			         for 变量=初值, 终值 ,步长 do
						语句块
			         end
			         其中步长可以省略,默认步长为1.
			         
			         例如计算1-100的和
			         local sum=0
			         for i=1,100 do
                           sum=sum+i
			         end

			(2)通用形式
			          for 变量1,变量2,....变量N  in  迭代器  do
							语句块
			          end
			  在编写Redis脚本时我们常用通用形式的for语句遍历表的值。

表类型

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

	例如:
		a={ }  				--将变量a赋值为一个空表
		
		a['field']='value'  --将field字段赋值value
		
		print(a.field)       --'value'
		
		people={
		      name='Job',
		      age=29
		}
		print(people.name)   --'job'

		当索引为整数的时候表和传统的数组一样
		a={'bob','jeff'}
		print(a[1])     --'bob'
		Lua约定数组的索引是从1开始的。
		
		可以使用通用形式的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='Job',
		      	     age=29
			}

			for index,value in pairs(people) do
			      print(index)
			      print(value)
			end
			打印结果为:
			name
			job
			age
			29
		pairs与ipairs的区别在于前者会遍历所有值不为nil的索引,而后者只会从索引1开始递增遍历到最后一个值不为nil的整数索引。

函数

		函数的定义为:
			function (参数列表)
			       函数体
			end
		可以将其赋值给一个局部变量
			local  square =function(num)
				return num*num
			end
		如果没有参数,括号也不能被省略。
		
		lua提供了一个语法糖来简化函数的定义
			local  function square(num)
			       return num*num
			end
		这段代码会被转换为
			local square
			square =function(num)
				return num*num
			end
		因为在赋值前声明了局部变量square,所以可以在函数内部引用自身(实现递归)

		如果实参的个数小于形参的个数,没有匹配到的形参的值为nil。
		如果实参的个数大于形参的个数,多处的实参会被忽略。
		如果希望实现可变参数个数,可以让最后一个形参为...
		例如
			local function square(...)
			        local argv={...}
			        for  i=1,#argv do
				argv[i]=argv[i]*argv[i]
			        end
			        return unpack(argv)
			end

			a,b,c=square(1,2,3)
			print(a)   --1
			print(b)   --4
			print(b)   --9
		
		我们首先将...转换为表argv,然后计算每个元素的平方值。
		unpack函数用来返回表中的元素。
		例子中argv表中有3个元素,所以return unpack(argv)相当于 return argv[1],argv[2],argv[3]
		在Lua中return和break语句必须是语句块中的最后一条语句,简单的说这两条语句后面只能是end,else或until。
		如果希望在语句块中间使用这两条语句,可以人为地使用do end将其包围。

标准库

Lua的标准库提供了很多实用的函数。比如前面介绍的函数ipairs和pairs,类型转换函数tonumber和tostring等都属于标准库中的Base库。

Redis支持大部分Lua标准库,如图:
在这里插入图片描述
下面我们简单介绍几个常用的标准库函数,详细的请查阅Lua文档

(1)String库
		string.len(string):获取字符串长度
				例如:print(string.len('hello'))   --5
				
		string.lower(string):将字符串转换为小写
				例如:print(string.lower('HELLO'))    --hello
				
		string.upper(string):将字符串转换为大写
				例如:print(string.lower('hello'))       --HELLO
		
		string.sub(string,start [,end]):截取字符串,可以截取从start到end的字符串,索引从1开始,-1代表最后一个元素。省略end参数默认是-1.
				例如:print(string.sub('hello',2)) --ello
						   print(string.sub('hello',2,-2)) --ell
				

除了标准库以外,Redis还通过cjson库和cmsgpack库提供了对Json和MessagePack的支持。Redis自动加载了这两个库,在脚本中可以通过cjson和cmsgpack两个全局变量来访问对应的库。

	例如:
		local people={
				name='bob',
				age=29
		}

		local jsonstr=cjson.encode(people)  --使用cjson序列化成字符串
		local cmsstr=cmsgpack.pack(people) --使用cmsgpack序列化成字符串

		local jsontable=cjson.decode(jsonstr)  --使用cjson将序列化后的字符串还原成表
		local cmstable=cmsgpack.unpack(cmsstr)  --使用cmsgpack将序列化后的字符串还原成表
	

Redis与Lua

编写Lua脚本的目的就是读写Redis的数据。

lua脚本中可以使用redis.call函数调用Redis命令
redis.call(‘set’,‘test’,‘1’)
redis.call(‘get’,‘test’)
返回值就是Redis命令的执行结果。

Redis还提供了redis.pcall函数,功能和上面相同,唯一区别是当命令执行出错时redis.pcall会记录错误并继续执行,而redis.call会直接返回错误,不会继续执行.

有很多情况下都需要脚本可以返回值,脚本中可以使用return语句将值返回给客户端,如果没有执行return语句则默认返回nil.
因为我们可以像调用Redis内置命令一样调用我们自己写的脚本,所以Redis会将脚本返回值的Lua数据类型转换成Redis的返回类型.

我们可以通过Redis-cli来调用编写好的Lua脚本文件.一定要在文件所在目录执行。
redis -cli --eval 文件名称 KEYS[] ARGV[]

Redis提供了EVAL命令使开发者可以调用脚本。

	EVAL 脚本内容  key参数的数量  [key1...] [arg...] :EVAL命令可以使开发者调用脚本.
	key和arg参数可以向脚本传递数据,他们的值可以在脚本中分别使用KEYS和ARGV两个表类型的全局变量访问.
	
	例如: EVAL "return  redis.call('SET',KEYS[1],ARGV[1])" 1 testKey  testArgv
	        GET  testKey   --结果为testArgv
	
 	EVAL命令依据第二个参数将后面的所有参数分别存入脚本中KEYS和ARGV两个表类型的全局变量。
 	当脚本不需要任何参数时也不能省略这个参数(设为0)

如果脚本过长,每次调用都需要将整个脚本传给Redis的话会占用很多的资源.
为了解决这个问题,Redis提供了EVALSHA命令允许开发者通过脚本内容的SHA1摘要来执行脚本,该命令的用法和EVAL一样,只不过是将脚本内容替换成脚本内容的SHA1摘要.

Redis在执行EVAL命令时会计算脚本的SHA1摘要并记录在脚本缓存中,执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,找到了则执行脚本,否则返回错误.
程序中使用EVALSHA,命令的一般流程如下:
(1)先计算脚本的SHA1摘要,并使用EVALSHA命令执行脚本
(2)获得返回值,如果返回"NOSCRIPT"错误则使用EVAL命令重新执行脚本

深入脚本

向脚本传递的参数分为KEYS和ARGV两种.前者表示要操作的键名,后者表示非键名参数.
事实上这个要求并不是强制的.
比如:EVAL “redis.call(‘get’,KEYS[1])” 1 test 可以获得test的键值.
还可以通过 EVAL “redis.call(‘get’,‘te’…ARGV[1])” 0 st 完成同样的功能
虽然规则不是强制的,但是不遵守会出现一些问题.
Redis的集群会将数据库中的键分散到不同的节点上,这意味着在脚本执行前就需要知道脚本会操作哪些键以便找到对应的节点.
所以如果脚本中的键名没有使用KEYS参数传递则无法兼容集群.

	有时候键名是根据脚本的执行结果生成的,这时就无法在执行前将键名明确标出.
	例如:一个集合类型键存储了用户id列表,每个用户使用散列键存储,其中有一个字段是年龄.
	下面的脚本可以计算某个集合中用户的平均年龄
	local sum=0
	local users =redis.call('SMEMBERS',KEYS[1])
	for _,user_id in ipairs(users) do
		local user_age=redis.call('HGET','user:'..user_id,'age')
		sum=sum+user_age
	end
	
	return sum/#users

	这个脚本同样无法兼容集群功能,因为第四行访问了KEYS变量中没有的键.
	为了兼容集群,可以在客户端获取集合中的用户id列表,然后将用户id组装成键名列表传给脚本计算平均年龄.

沙盒与随机数
Redis脚本禁止使用Lua标准库中与文件或系统调用相关的函数,在脚本中只允许对Redis的数据进行处理.
并且Redis还通过禁用脚本的全局变量的方式保证每个脚本都是相对隔离的,不会互相干扰.

使用沙盒不仅是为了保证服务器的安全性,而且还确保了脚本的执行结果只和脚本本身和执行时传递的参数有关,不依赖外界条件(系统时间、系统某个文件内容等)
这是因为在执行复制和AOF持久化操作时记录的是脚本的内容而不是脚本调用的命令,所以必须保证在脚本内容和参数一样的前提下脚本的执行结果必须是一样的。

除了使用沙盒外,为了确保执行的结果可以重现,Redis还对随机数和会产生随机结果的命令进行了特殊的处理。
对于随机数而言,Redis替换了math.random和math.randomseed函数使得每次执行脚本时生成的随机数列都相同。
如果希望获得不同的随机数列,可以在程序中生成随机数通过参数传递给脚本。

对于会产生随机结果的命令如SMEMBERS(因为集合类型是无序的)或HKEYS(因为散列类型的字段也是无序的)等
Redis会对结果按照字典顺序排序。内部是通过调用Lua标准库的table.sort函数实现的。

对于类似SPOP,SRANDMEMBER等只产生一个元素但是是随机结果并且无法排序的命令,Redis会在这类命令执行后将该脚本标记为lua_random_dirty,此后只允许调用只读命令,不允许修改数据库的值,否则会报错。

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

SCRIPT EXISTS SHA1摘要...:可以查找一个或多个脚本的SHA1摘要是否被缓存

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

SCRIPT KILL:强制终止当前正在执行的脚本。

原子性和执行时间

Redis的脚本执行是原子的,脚本执行期间Redis不会执行其他命令。所有命令都必须等待脚本执行完成后才能执行。
为了防止某个脚本执行时间过长导致Redis无法提供服务(比如陷入死循环),Redis提供了lua-time-limit参数限制脚本的最长运行时间,默认为五秒钟。
当脚本运行时间超过这一限制后,Redis将开始接受其他命令但不会执行(保证脚本的原子性),而是会返回“BUSY”错误。
此时虽然Redis可以接受任何命令,但实际会执行的只有两个:SCRIPT KILL和SHUTDOWN NOSAVE.
如果当前的脚本对Redis的数据进行了修改,则SCRIPT KILL命令不会终止脚本的运行以防止脚本只执行了一部分,会违背脚本的原子性要求。
这时候只能通过SHUTDOWN NOSAVE强行终止Redis。

Redis的持久化

Redis性能之所以强大很大一部分原因就是将数据都存储在了内存中,然而当Redis重启后,所有存储在内存中的数据就会丢失。在某些情况下,我们希望Redis在重启后能够保证数据不丢失。
例如:将Redis作为缓存服务器,但缓存被穿透后会对性能造成较大影响,所有缓存同时失效会导致缓存雪崩,从而使服务无法响应。
这时我们希望Redis能够将数据从内存中同步到硬盘中,使得重启后可以根据硬盘中的记录恢复数据。这个过程就是持久化。

Redis支持两种方式的持久化,一种是RDB方式,另一种是AOF方式。
前者会根据指定的规则"定时"将内存中的数据存储在硬盘上,而后者在每次执行命令后将命令本身记录下来。
两种持久化方式可以单独使用其中一种,但更多情况下是将二者结合使用。

RDB方式

RDB方式的持久化是通过快照(snapshotting)完成的。当符合一定条件时Redis会自动将内存中的所有数据生成一份副本并存储在硬盘上,这个过程即为"快照"。

Redis会在以下几种情况下对数据进行快照:
(1)根据配置规则进行自动快照
(2)用户执行SAVE或BGSAVE命令
(3)执行FLUSHALL命令
(4)执行复制(replication)时

根据配置规则进行自动快照
Redis允许用户自定义快照条件,当符合快照条件时,Redis会自动执行快照操作。
进行快照的条件可以由用户在配置文件中自定义,由两个参数构成:时间窗口M和改动的键的个数N
每当时间M内被更改的键的个数大于N时,即符合自动快照条件。

例如Redis安装目录中包含的样例配置文件中预置的三个条件:
save 900 1 --在900s内有一个或一个以上的键被更改则进行快照
save 300 10
save 60 10000
每条快照条件占一行,并且以save参数开头。
同时可以存在多个条件,条件之间是"或"的关系。

要禁用RDB持久化,可以将条件全部注释或者删除。
在这里插入图片描述

用户执行SAVE或BGSAVE命令

SAVE:当执行SAVE命令时,Redis同步地进行快照操作,在快照执行地过程中会阻塞所有来自客户端地请求。
当数据库中地数据比较多时,这一过程会导致Redis较长时间不响应,尽量避免在生产环境使用这一命令

BGSAVE:手动执行快照时推荐使用BGSAVE命令。BGSAVE命令可以在后台异步进行快照操作,快照地同时服务器还可以继续响应客户端地请求。
执行BGSAVE后Redis会立即返回OK表示开始执行快照操作。
如果向知道快照是否完成,可以通过LASTSAVE命令获取最近一次成功执行快照地时间,返回结果是一个Unix时间戳。
执行自动快照时Redis采用地策略就是异步快照。

执行FLUSHALL命令

当执行FLUSHALL命令时,Redis会清除数据库中地所有数据。
不论清空数据库地过程是否触发了自动快照条件,只要自动快照条件不为空,Redis就会执行一次快照操作。
例如:当定义地快照条件为当1s修改10000个键时进行自动快照,而当数据库里只有一个键时,执行FLUSHALL命令也会触发快照。
当没有定义自动快照条件时,执行FLUSHALL则不会进行快照。

执行复制时

当设置了主从模式时,Redis会在复制初始化时进行自动快照。
当使用复制操作时,即使没有定义自动快照条件,并且没有手动执行过快照操作,也会生成RDB快照文件。

快照原理

Redis默认会将快照文件存储在Redis当前进程地工作目录中地dump.rdb文件中,可以通过配置dir和dbfilename两个参数指定快照文件地存储路径和文件名。
快照地过程如下:
(1)Redis使用fork函数复制一份当前进程(父进程)的副本(子进程)
(2)父进程继续接收并处理客户端发来的命令,而子进程开始将内存中的数据写入硬盘中的临时文件。
(3)当子进程写入完所有数据后会用该临时文件替换旧的RDB文件,至此一次快照操作完成。
通过上述过程可以发现Redis在进行快照的过程中不会修改RDB文件,只有快照结束后才会将旧文件替换掉。
也就是说在任何时候RDB文件都是完整的。这使得我们可以通过定时备份RDB文件来实现Redis数据库备份。
Redis启动后会读取RDB快照文件,将数据从硬盘载入到内存。
通过RDB方式实现持久化,一旦Redis异常退出,就会丢失最后一次快照以后更改的所有数据。
如果数据相对重要,希望将损失降到最小,可以使用AOF方式进行持久化。

AOF方式

当使用Redis存储非临时数据时,一般需要打开AOF持久化来降低进程中止导致的数据丢失。
AOF可以将Redis执行的每一条写命令追加到硬盘文件中,这一过程显然会降低Redis的性能。

默认情况下Redis没有开启AOF方式的持久化,可以通过appendonly参数启用。
启用后记得重启服务。
appendonly yes
开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。

AOF文件的保存位置和RDB文件位置相同,都是通过dir参数设置的,默认文件名是appendonly.aof,可以通过appendfilename参数修改。

AOF的实现
AOF文件以纯文本的形式记录了Redis执行的写命令。
例如在开启AOF持久化的情况下执行了如下命令:

在这里插入图片描述
此时AOF文件的内容如下
在这里插入图片描述
可见Redis确实记录了三条记录,但是前两天命令完全是多余的,因为第三条命令能够覆盖前两条命令的值,如果这样的话,AOF文件会越来越大。Redis通过配置可以自动地优化AOF文件。

在这里插入图片描述

**auto-aof-rewrite-percentage参数的意义是当目前的AOF文件大小超过上一次重写时的AOF文件大小的百分之多少时会再次进行重写,如果之前没重写过,则以启动时的AOF文件大小为依据。

auto-aof-rewrite-min-size参数限制了允许重写的最小文件大小。**

除了让Redis自动执行重写外,我们还可以主动执行BGREWRITEAOF手动重写AOF文件。
上面文件重写后的内容为:

在这里插入图片描述
在启动时Redis会逐个执行AOF文件中的命令来将硬盘中的数据载入到内存中,载入的速度相较RDB会慢一点。

同步硬盘数据

虽然每次执行更改数据库内容的操作时,AOF都会将命令记录在AOF文件中,
但是事实上,由于操作系统的缓存机制,数据并没有真正地写入硬盘,而是进入了系统地硬盘缓存。
在默认情况下系统每30s会执行一次同步操作,以便将硬盘缓存中的内容真正地写入硬盘。
在这30s地过程中如果系统异常退出则会导致硬盘缓存中地数据丢失。
一般来将启动AOF持久化地应用都无法忍受这样地损失。
这就需要Redis在写入AOF文件后主动要求系统将缓存内容同步到硬盘中。
在Redis中我们可以通过appendfsync参数设置同步地时机。
默认情况下Redis采用everysec规则,即每秒执行一次同步操作。
always表示每次执行写入都会执行同步,这是最安全也是最慢地方式。
no表示不主动进行同步操作,而是交由操作系统来做(即每30s一次),这是最快但最不安全地方式。

在这里插入图片描述

Redis允许同时开启AOF和RDB,既保证了数据安全又使得进行备份等操作十分容易。
此时重新启动Redis后Redis会使用AOF文件来恢复数据,因为AOF方式的持久化可能丢失地数据更少。

在同时启用了AOF和RDB后,我们将配置参数aof-use-rdb-preamble设置为yes,来启用Redis4.x开始提供的新的混合持久化功能。当这个选项被设置为yes的时候,在重写AOF文件时,Redis首先会把数据集以RDB的格式转储到内存中并作为AOF文件的开始部分。在重写之后,Redis继续使用传统的AOF格式在AOF文件中记录写入命令。如果启用了混合持久化,那么在AOF文件的开头首先使用的是RDB格式。因为RDB的压缩格式可以更快速的重写和加载数据文件,并且也保留了传统的AOF持久化的特点,使Redis更好用一些。

Redis保证RDB和AOF不会同时执行,即使RDB和AOF同时开启,Redis总是会先加载AOF文件,因为AOF数据更加完整。如果启用了混合持久化,Redis会先加载RDB部分,再加载AOF部分。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值