轻量性:lua语言的官方版本只具有核心和最基本的库,启动速度很快,非常适合嵌入其他语言编写的程序里,比如将lua嵌入到Redis里。
扩展性:在lua语言里,包含了非常便于开发使用的扩展接口,能非常方便地扩展实现其他语言的功能。
结合Redis和lua脚本语言的特性,如果在Redis里遇到如下需求,就可以引入lua脚本。
由于Redis是以单线程的形式运行的,如果运行的lua脚本没有响应或者不返回值,就会阻塞整个Redis服务,并且在运行时lua脚本一般很难调试,所以在Redis整合lua脚本时应该确保脚本里的代码尽量少且尽可能结构清晰,以免造成阻塞整个Redis服务的情况。
Redis对lua脚本的支持是从Redis 2.6.0版本开始引入的,它可以让用户在Redis服务器内置的Lua解释器中执行指定的lua脚本。
被执行的lua脚本可以直接调用Redis命令,并使用lua语言以及内置的函数库处理命令结果。
lua脚本是一种解释语言,所以可以安装解释器以后再运行lua脚本,但这里是在redis里引入lua脚本,所以就将给出redis-cli 命令运行lua脚本的相关步骤。
创建/opt/lua
目录,在其中创建redis-demo.lua文件。注意,lua脚本的文件扩展名一般都是.lua
。
redis.call('set','name','zhangsan')
在lua脚本里,可以通过redis.call
方法调用Redis的命令。
通过如下的docker命令创建一个名为redis-lua的Redis容器,在其中通过-v
参数把包含lua脚本的/opt/lua
目录映射为容器里的/lua-script
目录。这样启动后该容器的/lua-script
目录里就能看到在外部操作系统里创建的lua脚本。
docker run -itd --name redis-lua -v /opt/lua:/lua-script -p 6379:6379 redis:latest
启动该容器后,可以通过如下的命令进入该容器的命令行窗口里
docker exec -it redis-lua /bin/bash
可以通过如下的redis-cli
命令执行刚才创建的lua脚本,其中--eval
是redis里执行lua脚本的命令,/lua-script/redis-demo.lua
则表示该脚本的路径和文件名。
redis-cli --eval /lua-script/redis-demo.lua
运行上述命令后,得到的返回值是空(nil),这是因为该lua脚本没有通过return
返回值。
如果用redis-cli
命令进入该Redis服务器,在通过get name
命令就能看到通过上述lua脚本设置到缓存的那么值。
root@c1beeb888673:/data# redis-cli --eval /lua-script/redis-demo.lua
(nil)
root@c1beeb888673:/data# redis-cli
127.0.0.1:6379> get name
"zhangsan"
127.0.0.1:6379>
在实际项目里,如果lua脚本里包含的语句较多,那么一般会以lua脚本文件的方式来维护。
如果lua脚本里的语句很少,那么可以直接通过eval
命令来执行脚本。
通过redis-cli命令进入Redis服务器的客户端里,随后运行如下的eval命令
EVAL 脚本内容 key参数的数量 [key...] [args...]
EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 1 age 22
通过key
和arg
这两类参数向脚本传递数据,他们的值可以在脚本中分别使用KEYS
和ARGV
两个表类型的全局变量访问。
key参数的数量是必须要指定的,没有key参数时必须设为0,EVAL会依据这个数值将传入的参数分别传入KEYS
和ARGV
两个表类型的全局变量。
在刚才redis整合lua脚本的场景里,都是通过redis.call
方法执行redis命令,并没有返回结果,在一些场景里,需要返回结果,此时就需要在脚本里引入return
语句。
到/opt/lua
目录,在其中创建return-lua.lua
文件。在其中加入如下的一句return
代码。返回1这个结果。
return redis.call('set','name','tom')
进入该容器的命令窗口,在其中在运行命令,就能看到返回结果。
redis-cli --eval /lua-script/return-lua.lua
可以通过SCRIOPT LOAD
命令事先装置脚本,随后可以用EVALSHA
命令多次运行该脚本。
SCRIOPT LOAD '脚本内容'
EVALSHA 'id' 0
127.0.0.1:6379> SCRIPT LOAD "return 1"
"e0e1f9fabfc9d4800c877a703b823ac0578ff8db"
127.0.0.1:6379> EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff8db 0
(integer) 1
127.0.0.1:6379> EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff8db 0
(integer) 1
127.0.0.1:6379> EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff8db 0
(integer) 1
127.0.0.1:6379>
通过EVALSHA 命令执行已经缓存到内存中的lua脚本时,第一个参数是该脚本的ID号,第二个参数0表示该脚本的参数个数是0。
可以通过SCRIPT FLUSH
命令清空缓存里的所有lua脚本。
SCRIPT FLUSH
可以通过SCRIPT KILL
命令终止正在运行的脚本,如果当前没有脚本在运行,该命令会返回错误提示。
127.0.0.1:6379> SCRIPT KILL
(error) NOTBUSY No scripts in execution right now.
Redis服务是单线程的,所以如果在lua脚本里代码编写不当,比如引入了死循环,就会阻塞住当前Redis线程,也就是说该Redis服务器就无法在对外提供服务了。
比如运行如下所示的eval命令,由于在脚本里引入了while死循环,之后就无法继续输入其他Redis命令了,也就是说当前Redis服务被阻塞了。
eval "while true do end" 0
可以使用script kill命令结束lua脚本
127.0.0.1:6379> eval "while true do end" 0
root@c1beeb888673:/data# redis-cli
127.0.0.1:6379> get name
(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
127.0.0.1:6379> script kill
OK
127.0.0.1:6379> get name
"tom"
所以,在Redis里整合lua脚本时需要非常小心
Redis在调用lua脚本时,可以传入KEYS和ARGV这两种类型的参数,它们的区别是前者表示要操作的键名,后者表示非键名参数,但是这一要求并不是强制的,比如设置键值的脚本。
EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 1 age 22
也可以写成
EVAL "return redis.call('SET',ARGV[1],ARGV[2])" 0 age 22
虽然规则不是强制的,但是不遵守这样的规则,可能会为后续带来不必要的麻烦,比如Redis 3.0 之后支持集群功能,开启集群后会将键发布到不同的节点上,所以在脚本执行前就需要知道脚本会操作那些键以便找到对应的节点,而如果脚本中的键名没有使用KEYS参数传递则无法兼容集群。
eval "return {KEYS[1],ARGV[1],ARGV[2]}" 1 key1 one two
eval "return {KEYS[1],ARGV[1],ARGV[2]}" 2 key1 one two
127.0.0.1:6379> eval "return {KEYS[1],ARGV[1],ARGV[2]}" 1 key1 one two
1) "key1"
2) "one"
3) "two"
127.0.0.1:6379> eval "return {KEYS[1],ARGV[1],ARGV[2]}" 2 key1 one two
1) "key1"
2) "two"
在第1行运行的脚本里,KEYS[1]
表示KEYS类型的第一个参数。ARGV[1]
和ARGV[2]
分别表示ARGV类型的第一个和第二个参数,注意
,相关下标是从1开始的,不是从0开始。
第1行脚本双引号之后的1表示该脚本KEYS类型的参数是1个,这里在统计参数个数时,并不把ARGV自定义类型的参数统计在内,随后的key1 ,one 和two分别按次序指向KEYS[1],ARGV[1]和ARGV[2]。
执行该return语句后,输出了KEYS[1],ARGV[1]和ARGV[2]这三个参数具体的值。
第二个脚本与第一个脚本的差异在于:表示参数的个数的值从1变成2,所以这里表示KEYS类型的参数个数有两个。
官网说明:
redis-cli --eval 脚本文件 0
root@c1beeb888673:/data# cat /lua-script/script.lua
return redis.call('SET',KEYS[1],ARGV[1])
root@c1beeb888673:/data# redis-cli --eval /lua-script/script.lua location:hastings:temp , 23
OK
root@c1beeb888673:/data#
redis-cli --eval
不需要指定keys的数量,并且keys和argv之间使用,
分隔,同时,两侧必须使用空格。
在lua脚本里,可以用if...else
语句来控制分支流程,具体语法如下
if (condition) then
...
else
...
end
注意,其中if 、then、else和end等关键字的写法,在如下的ifDemo.lua脚本里将演示在lua脚本里使用分支语句的做法。
if redis.call('exists','name')==1 then
return 'existed'
else
redis.call('set','name','tom')
return 'not existed'
end
通过if语句判断redis.call
命令执行的exists name
语句是否返回1,如果返回1,就表示name键存在,执行第二行的return 'existed'
语句。否则执行第4行和第5行的else语句,给name键设值并返回not existed
。
root@c1beeb888673:/data# redis-cli --eval /lua-script/ifDemo.lua
"existed"
在lua脚本里,可以使用while关键字实现循环调用的效果,具体语法如下所示:
while (condition)
do
...
end
当condition
条件为true
时,会执行do
部门的语句块,否则退出该while循环语句。
local i=0
while(i<10)
do
redis.call('set',i,i)
i=i+1
end
在第1行里定义了i变量,在第2行的while循环条件里会判断i变量是否小于10,如果小于就进入第4行执行set操作,随后通过第5行的代码给i进行加1操作并退出本次while循环。
redis-cli --eval /lua-script/while-demo.lua
在lua脚本里,也可以使用for关键字来实现循环的调用,具体语法如下
for var=start ,end,step do
...
end
在执行for循环前,首先会给var赋予start所演示的值,在执行每次循环语句时,会以step为步长递增start,当递增到end所示的值后会退出for循环,实例如下
for i=0,10,1 do
redis.call('del',i)
end
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
spring.redis.host=localhost
spring.redis.port=6379
使用StringRedisTemplate或RedisTemplate来执行Lua脚本
首先我们要初始化成员变量:
//lua脚本
private DefaultRedisScript<Boolean> casScript;
@Resource
private RedisTemplate redisTemplate;
@PostConstruct
public void init(){
casScript=new DefaultRedisScript<>();
//lua脚本类型
casScript.setResultType(Boolean.class);
//lua脚本在哪加载
casScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("compareAndSet.lua")));
}
public Boolean compareAndSet(String key,Long oldValue,Long newValue){
List<String> keys=new ArrayList<>();
keys.add(key);
//参数一为lua脚本
//参数二为keys集合 对应KEYS[1]、KEYS[2]....
//参数三为可变长参数 对应 ARGV[1]、ARGV[2]...
return (Boolean) redisTemplate.execute(casScript,keys,oldValue,newValue);
}
如果对springboot集成redis有问题,可以看我之前的文章SpringBoot集成Redis
初始化:
@Resource
private RedisTemplate redisTemplate;
//lua脚本
private DefaultRedisScript<Boolean> casScript;
@PostConstruct
public void init(){
casScript=new DefaultRedisScript<>();
//lua脚本类型
casScript.setResultType(Boolean.class);
//lua脚本在哪加载
casScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("compareAndSet.lua")));
}
public Boolean compareAndSet(String key,Long oldValue,Long newValue){
List<String> keys=new ArrayList<>();
keys.add(key);
return (Boolean) redisTemplate.execute(casScript,keys,oldValue,newValue);
}
lua脚本:
local key=KEYS[1]
local oldValue=ARGV[1]
local newValue=ARGV[2]
local redisValue=redis.call('get',key)
if(redisValue==false or tonumber(redisValue)==tonumber(oldValue))
then
redis.call('set',key,newValue)
return true
else
return false
end
使用:
public Boolean compareAndSet(String key,Long oldValue,Long newValue){
List<String> keys=new ArrayList<>();
keys.add(key);
return (Boolean) redisTemplate.execute(casScript,keys,oldValue,newValue);
}
基于Redis的lua脚本能确保Redis命令执行时的顺序性和原子性,所以在高并发的场景里会用两者整合的方法实现限流和防超卖等效果。
限流是指某应用模块需要限制指定IP(或指定模块,指定应用)在单位时间内的访问次数。
这里将给出用lua脚本实现的基于计数模式的限流效果,示例如下
编写lua脚本
local obj=KEYS[1]
local limitNum=tonumber(ARGV[1])
local curVisitNum=tonumber(redis.call('get',obj) or '0')
if(limitNum == curVisitNum) then
return 0
else
redis.call('incrby',obj,'1')
redis.call('expire',obj,ARGV[2])
return curVisitNum+1
end
该脚本有三个参数:
该脚本的功能是限制KEYS[1]对象在ARGV[2]时间范围内只能访问ARGV[1]次。
private DefaultRedisScript<Long> limitScript;
@PostConstruct
public void init(){
limitScript=new DefaultRedisScript<>();
//lua脚本类型
limitScript.setResultType(Long.class);
//lua脚本在哪加载
limitScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limit.lua")));
}
@GetMapping("testLimit")
public String testLimit(){
Boolean aBoolean = canVisit("limit", 3, 10);
if (aBoolean){
return "可以访问";
}else {
return "不可以访问";
}
}
public Boolean canVisit(String key,int oldValue,int newValue){
List<String> keys=new ArrayList<>();
keys.add(key);
Long execute = (Long)redisTemplate.execute(limitScript, keys, oldValue , newValue);
System.out.println(execute);
return !(0==execute);
}