Redis之Lua的应用(四)

一、什么是Lua脚本

Lua是一个高效的轻量级脚本语言(和JavaScript类似),用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。Lua在葡萄牙语中是“月亮”的意思,它的logo形式卫星,寓意是Lua是一个“卫星语言”,能够方便地嵌入到其他语言中使用;其实在很多常见的框架中,都有嵌入Lua脚本的功能,比如OpenResty、Redis等。

使用Lua脚本的好处:

  1. 减少网络开销,在Lua脚本中可以把多个命令放在同一个脚本中运行

  2. 原子操作,redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。换句话说,编写脚本的过程中无需担心会出现竞态条件

  3. 复用性,客户端发送的脚本会永远存储在redis中,这意味着其他客户端可以复用这一脚本来完成同样的逻辑

二、Lua的下载和安装

Lua是一个独立的脚本语言,所以它有专门的编译执行工具,下面简单带大家安装一下。

安装步骤

tar -zxvf lua-5.4.3.tar.gz
cd lua-5.4.3
make linux
make install

最后,直接输入lua命令即可进入lua的控制台。Lua脚本有自己的语法、变量、逻辑运算符、函数等,这块我就不在这里做过多的说明,可以自己进入下面链接进去看

https://www.runoob.com/lua/lua-tutorial.html

三、Redis与Lua

Redis中集成了Lua的编译和执行器,所以我们可以在Redis中定义Lua脚本去执行。同时,在Lua脚本中,可以直接调用Redis的命令,来操作Redis中的数据。

redis.call(‘set’,'hello','world')
 
local value=redis.call(‘get’,’hello’) 

redis.call 函数的返回值就是redis命令的执行结果,前面我们介绍过redis的5中类型的数据返回的值的类型也都不一样,redis.call函数会将这5种类型的返回值转化对应的Lua的数据类型

在很多情况下我们都需要脚本可以有返回值,毕竟这个脚本也是一个我们所编写的命令集,我们可以像调用其他redis内置命令一样调用我们自己写的脚本,所以同样redis会自动将脚本返回值的Lua数据类型转化为Redis的返回值类型。 在脚本中可以使用return 语句将值返回给redis客户端,通过return语句来执行,如果没有执行return,默认返回为nil。

四、Redis中执行Lua脚本相关的命令

编写完脚本后最重要的就是在程序中执行脚本。Redis提供了EVAL命令可以使开发者像调用其他Redis内置命令一样调用脚本。

EVAL命令-执行脚本

[EVAL] [脚本内容] [key参数的数量] [key …] [arg …]

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

比如我们通过脚本实现一个set命令,通过在redis客户端中调用,那么执行的语句是:

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 lua hello

上述脚本相当于使用Lua脚本调用了Redis的set命令,存储了一个key=lua,value=hello到Redis中。

EVALSHA命令

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

  1. Redis在执行EVAL命令时会计算脚本的SHA1摘要并记录在脚本缓存中

  2. 执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了就执行脚本,否则返回“NOSCRIPT No matching script,Please use EVAL”

# 将脚本加入缓存并生成sha1命令
script load "return redis.call('get','lua')"
# ["13bd040587b891aedc00a72458cbf8588a27df90"]
# 传递sha1的值来执行该命令
evalsha "13bd040587b891aedc00a72458cbf8588a27df90" 0

五、自己通过Redisson执行Lua脚本

通过lua脚本来实现一个访问频率限制功能。

思路,定义一个key,key中包含ip地址。 value为指定时间内的访问次数,比如说是10秒内只能访问3次。

定义Lua脚本

local times=redis.call('incr',KEYS[1])
-- 如果是第一次进来,设置一个过期时间
if times == 1 then
   redis.call('expire',KEYS[1],ARGV[1])
end
-- 如果在指定时间内访问次数大于指定次数,则返回0,表示访问被限制
if times > tonumber(ARGV[2]) then
   return 0
end
-- 返回1,允许被访问
return 1

定义controller,提供访问测试方法

@RestController
public class RedissonLuaController {
    @Autowired
    RedissonClient redissonClient;

    private final String LIMIT_LUA="local times=redis.call('incr',KEYS[1])\n" +
            "if times==1 then\n" +
            "    redis.call('expire',KEYS[1],ARGV[1])\n" +
            "end\n" +
            "if times > tonumber(ARGV[2]) then\n" +
            "    return 0\n" +
            "end \n" +
            "return 1";

    @GetMapping("/lua/{id}")
    public String lua(@PathVariable("id") Integer id) throws ExecutionException, InterruptedException {
        RScript rScript=redissonClient.getScript();
        List<Object> keys= Arrays.asList("LIMIT:"+id);
        RFuture<Object> future=rScript.evalAsync(RScript.Mode.READ_WRITE,LIMIT_LUA, RScript.ReturnType.INTEGER,keys,10,3);
        return future.get().toString();
    }

}

要注意,上述脚本执行的时候会有问题,因为redis默认的序列化方式导致value的值在传递到脚本中时,转成了对象类型,需要修改redisson.yml文件,增加codec的序列化方式。

application.yml

spring:
  redis:
    redisson:
      file: classpath:redisson.yml

redisson.yml

singleServerConfig:
  address: redis://192.168.221.128:6379
 
codec: !<org.redisson.codec.JsonJacksonCodec> {}

六、Lua脚本的原子性

redis的脚本执行是原子的,即脚本执行期间Redis不会执行其他命令。所有的命令必须等待脚本执行完以后才能执行。为了防止某个脚本执行时间过程导致Redis无法提供服务。Redis提供了lua-time-limit参数限制脚本的最长运行时间。默认是5秒钟。

非事务性操作

当脚本运行时间超过这个限制后,Redis将开始接受其他命令但不会执行(以确保脚本的原子性),而是返回BUSY的错误,下面演示一下这种情况。

打开两个客户端窗口,在第一个窗口中执行lua脚本的死循环

eval "while true do end" 0

在第二个窗口中运行get lua,会得到如下的异常。

(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

我们会发现执行结果是Busy, 接着我们通过script kill 的命令终止当前执行的脚本,第二个窗口的显示又恢复正常了。

存在事务性操作

如果当前执行的Lua脚本对Redis的数据进行了修改(SET、DEL等),那么通过SCRIPT KILL 命令是不能终止脚本运行的,因为要保证脚本运行的原子性,如果脚本执行了一部分终止,那就违背了脚本原子性的要求。最终要保证脚本要么都执行,要么都不执行

同样打开两个窗口,第一个窗口运行如下命令

eval "redis.call('set','name','ljx') while true do end" 0

在第二个窗口运行

get lua

结果一样,仍然是busy,但是这个时候通过script kill命令,会发现报错,没办法kill。遇到这种情况,只能通过shutdown nosave命令来强行终止redis。shutdown nosave和shutdown的区别在于 shutdown nosave不会进行持久化操作,意味着发生在上一次快照后的数据库修改都会丢失。

posted @ 2022-06-18 21:14  童话述说我的结局  阅读(1095)  评论(0编辑  收藏  举报