Redis整合Lua脚本

发布时间:2023年12月19日

一、Lua介绍

  • Lua是一种用C语言编写而成的轻量级的脚本语言。

1.1 Lua特点

  • 轻量性:lua语言的官方版本只具有核心和最基本的库,启动速度很快,非常适合嵌入其他语言编写的程序里,比如将lua嵌入到Redis里。

  • 扩展性:在lua语言里,包含了非常便于开发使用的扩展接口,能非常方便地扩展实现其他语言的功能。

  • 结合Redis和lua脚本语言的特性,如果在Redis里遇到如下需求,就可以引入lua脚本。

    • 重复执行相同类型的命令,比如要缓存1到1000的数字到内存里。
    • 在高并发场景下减少网络调用的开销,一次性执行多条命令。
    • Redis会将lua脚本作为一个整体来执行,天然具有原子性。
  • 由于Redis是以单线程的形式运行的,如果运行的lua脚本没有响应或者不返回值,就会阻塞整个Redis服务,并且在运行时lua脚本一般很难调试,所以在Redis整合lua脚本时应该确保脚本里的代码尽量少且尽可能结构清晰,以免造成阻塞整个Redis服务的情况。

  • Redis对lua脚本的支持是从Redis 2.6.0版本开始引入的,它可以让用户在Redis服务器内置的Lua解释器中执行指定的lua脚本。

  • 被执行的lua脚本可以直接调用Redis命令,并使用lua语言以及内置的函数库处理命令结果。

二、在Redis里调用lua脚本

2.1 redis-cli 命令执行脚本

lua脚本是一种解释语言,所以可以安装解释器以后再运行lua脚本,但这里是在redis里引入lua脚本,所以就将给出redis-cli 命令运行lua脚本的相关步骤。

创建/opt/lua目录,在其中创建redis-demo.lua文件。注意,lua脚本的文件扩展名一般都是.lua

redis.call('set','name','zhangsan')

在lua脚本里,可以通过redis.call方法调用Redis的命令。

  • 该方法的第一个参数是Redis命令。
  • 第二个以及后继参数是该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> 

2.2 eval命令执行脚本

在实际项目里,如果lua脚本里包含的语句较多,那么一般会以lua脚本文件的方式来维护。

如果lua脚本里的语句很少,那么可以直接通过eval命令来执行脚本。

通过redis-cli命令进入Redis服务器的客户端里,随后运行如下的eval命令

EVAL 脚本内容 key参数的数量 [key...] [args...]
EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 1  age 22

通过keyarg这两类参数向脚本传递数据,他们的值可以在脚本中分别使用KEYSARGV两个表类型的全局变量访问。

key参数的数量是必须要指定的,没有key参数时必须设为0,EVAL会依据这个数值将传入的参数分别传入KEYSARGV两个表类型的全局变量。

2.3 return返回脚本运行结果

在刚才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

2.4 Redis和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.

2.5 观察lua脚本阻塞Redis

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缓存服务。

三、进阶

3.1 参数传递

KEYS和ARGV参数

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 和eval命令

官网说明:

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之间使用,分隔,同时,两侧必须使用空格

3.2 流程控制

分支语句

在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"

while循环调用

在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

for循环调用

在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

四、springboot结合redis实现lua脚本的操作

4.1 springboot集成redis

  1. 添加Redis依赖项到你的pom.xml文件:
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-data-redis</artifactId>  
</dependency>
  1. 在application.properties或application.yml文件中配置Redis连接参数:
spring.redis.host=localhost  
spring.redis.port=6379
  1. 使用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")));
    }
  1. 使用:
    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

4.2 使用lua脚本实现cas操作

初始化:

 @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);
    }

4.3 Redis整合lua脚本实例

基于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[1]表示限流的次数
  • ARGV[2]表示限流的时间单位

该脚本的功能是限制KEYS[1]对象在ARGV[2]时间范围内只能访问ARGV[1]次。

  • 首先用KEYS[1]接收待限流的对象,比如模块或应用等,并把它赋给obj变量。
  • 用ARGV[1]参数接收到的表示限流次数的对象赋给limitNum ,注意这里需要用tonumber方法把包含限流次数的ARGV[1]参数转换成数值类型。
  • 通过if语句判断待限流对象的访问次数是否达到限流标准,如果达到,则返回0。
  • 如果没有达到限流标准,首先通过incrby命令对访问次数加1,然后通过expire命令设置表示访问次数的键值对的生存时间,即限流的时间范围,最后通过return语句返回当前对象的访问次数。
  • 在调用该lua脚本时,如果返回值是0,就说明当前访问量已经达到限流标准,否则还可以继续访问。
  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);
    }
文章来源:https://blog.csdn.net/qq_39093474/article/details/134914831
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。