Redis中的lua脚本

导读:本篇文章讲解 Redis中的lua脚本,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

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

使用 Lua 脚本的好处:

  1. 减少网络开销,在 Lua 脚本中可以把多个命令放在同一个脚本中运行
  2. 原子操作,redis 会将整个脚本作为一个整体执行,中间不会被其他命令插入。换句话说,编写脚本的过程中无需担心会出现竞态条件
  3. 复用性,客户端发送的脚本会永远存储在 redis 中,这意味着其他客户端可以复用这一脚本来完成同样的逻辑

Lua 的下载和安装

Lua 是一个独立的脚本语言,所以它有专门的编译执行工具。

  • 下载 Lua 源码包:

    https://www.lua.org/ftp/lua-5.4.3.tar.gz

  • 安装步骤如下

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

如果报错,说找不到 readline/readline.h, 可以通过 yum 命令安装

yum -y install readline-devel ncurses-devel

最后,直接输入 lua 命令即可进入 lua 的控制台。

image-20211124155638427

Lua 脚本有自己的语法、变量、逻辑运算符、函数等,这块我就不在这里做过多的说明,用过 JavaScript 的同学,应该只需要花几个小时就可以全部学完,简单演示两个案例如下。

array = {"Lua", "cc"}
for i= 0, 2 do
   print(array[i])
end
array = {"cc", "redis"}

for key,value in ipairs(array)
do
   print(key, value)
end

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 这两个参数向脚本中传递数据,他们的值可以在脚本中分别使用 KEYSARGV 这两个类型的全局变量访问。

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

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

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

image-20211124160046565

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

image-20211124160318692

Redisson 执行 Lua 脚本

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

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

为了防止在输入错误,我们可以创建一个Lua文件,然后编写相关脚本,最后复制进代码中。在idea中下载搜索lua插件安装接口。

  • 定义 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 LuaController {
    
        @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://127.0.0.1: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','mic') while true do end" 0

在第二个窗口运行:

get lua

结果一样,仍然是 busy,但是这个时候通过 script kill 命令,会发现报错,没办法 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 a hard way using the SHUTDOWN NOSAVE command.

遇到这种情况,只能通过 shutdown nosave 命令来强行终止 redis。

shutdown nosave 和 shutdown 的区别在于 shutdown nosave 不会进行持久化操作,意味着发生在上一次快照后的数据库修改都会丢失。

Redisson 中的 Lua 脚本

了解了 lua 之后,我们再回过头来看看 Redisson 的 Lua 脚本,就不难理解了。

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                          "if (redis.call('exists', KEYS[1]) == 0) then " +
                          "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                          "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                          "return nil; " +
                          "end; " +
                          "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                          "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                          "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                          "return nil; " +
                          "end; " +
                          "return redis.call('pttl', KEYS[1]);",
                          Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/16806.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!