15、Redis之LUA脚本及RedisTemplate执行脚本案例源码分析

Lua是什么

Lua是一个小巧的脚本语言。是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个研究小组,由Roberto Ierusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo所组成并于1993年开发。 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。Lua由标准C编写而成,几乎在所有操作系统和平台上都可以编译,运行。Lua并没有提供强大的库,这是由它的定位决定的。所以Lua不适合作为开发独立应用程序的语言。Lua 有一个同时进行的JIT项目,提供在特定平台上的即时编译功能。

Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,这使得Lua在应用程序中可以被广泛应用。不仅仅作为扩展脚本,也可以作为普通的配置文件,代替XML,ini等文件格式,并且更容易理解和维护. Lua由标准C编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译,运行。一个完整的Lua解释器不过200k,在目前所有脚本引擎中,Lua的速度是最快的。这一切都决定了Lua是作为嵌入式脚本的最佳选择。

基本知识

参考文档

Lua 数据类型

ua是动态类型语言,变量不要类型定义,只需要为变量赋值。 值可以存储在变量中,作为参数传递或结果返回。

Lua中有8个基本类型分别为:nil、boolean、number、string、userdata、function、thread和table。

 

Lua 变量

变量在使用前,必须在代码中进行声明,即创建该变量。编译程序执行代码之前编译器需要知道如何给语句变量开辟存储区,用于存储变量的值。

Lua变量有三种类型:全局变量、局部变量、表中的域。

函数外的变量默认为全局变量,除非用 local 显示声明。函数内变量与函数的参数默认为局部变量。

局部变量的作用域为从声明位置开始到所在语句块结束(或者是直到下一个同名局部变量的声明)。

变量的默认值均为 nil。

Lua 流程控制

Lua提供了以下控制结构语句:

 

if(0)
then
    print("0 为真")
end

Lua 函数

在Lua中,函数是对语句和表达式进行抽象的主要方法。既可以用来处理一些特殊的工作,也可以用来计算一些值。

Lua提供了许多的内建函数,你可以很方便的在程序中调用它们,如print()函数可以将传入的参数打印在控制台上。

Lua函数主要有两种用途:

  • 完成指定的任务,这种情况下函数作为调用语句使用;
  • 计算并返回值,这种情况下函数作为赋值语句的表达式使用。

Lua编程语言函数定义格式如下:

optional_function_scope function function_name( argument1, argument2, argument3..., argumentn)
   function_body
 return result_params_comma_separated
end

Lua 运算符

运算符是一个特殊的符号,用于告诉解释器执行特定的数学或逻辑运算。Lua提供了以下几种运算符类型:

  • 算术运算符
  • 关系运算符
  • 逻辑运算符
  • 其他运算符

Redis脚本

Redis 2.6及更高版本通过eval和evalsha命令提供了对运行Lua脚本的支持。

Lua 嵌入 Redis 优势:

  • 减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;
  • 原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;
  • 复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.

命令行运行脚本

命令

EVAL script numkeys key [key …] arg [arg …]

EVAL 和 EVALSHA 命令是从 Redis 2.6.0 版本开始的,使用内置的 Lua 解释器,可以对 Lua 脚本进行求值。

EVAL的第一个参数是一段 Lua 5.1 脚本程序。 这段Lua脚本不需要(也不应该)定义函数。它运行在 Redis 服务器中。

EVAL的第二个参数是参数的个数,后面的参数(从第三个参数),表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。

在命令的最后,那些不是键名参数的附加参数 arg [arg …] ,可以在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。

常用Lua函数:

  • redis.call()
  • redis.pcall()

redis.call() 与 redis.pcall()很类似, 他们唯一的区别是当redis命令执行结果返回错误时, redis.call()将返回给调用者一个错误,而redis.pcall()会将捕获的错误以Lua表的形式返回。

# 这段脚本的确实现了将键 foo 的值设为 bar 的目的,但是,它违反了 EVAL 命令的语义,因为脚本里使用的所有键都应该由 KEYS 数组来传递
127.0.0.1:6379> eval "return redis.call('set','foo','bar')" 0
OK
# 正确方式
127.0.0.1:6379> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK

要求使用正确的形式来传递键(key)是有原因的,因为不仅仅是 EVAL 这个命令,所有的 Redis 命令,在执行之前都会被分析,籍此来确定命令会对哪些键进行操作。

因此,对于 EVAL 命令来说,必须使用正确的形式来传递键,才能确保分析工作正确地执行。 除此之外,使用正确的形式来传递键还有很多其他好处,它的一个特别重要的用途就是确保 Redis 集群可以将你的请求发送到正确的集群节点。 (对 Redis 集群的工作还在进行当中,但是脚本功能被设计成可以与集群功能保持兼容。)不过,这条规矩并不是强制性的, 从而使得用户有机会滥用(abuse) Redis 单实例配置(single instance configuration),代价是这样写出的脚本不能被 Redis 集群所兼容。

Lua脚本能返回一个值,这个值能按照一组转换规则从Lua转换成redis的返回类型。

Lua 数据类型和 Redis 数据类型之间转换

当Lua 通过 call() 或 pcall() 函数执行 Redis 命令的时候,命令的返回值会被转换成 Lua 数据结构。 同样地,当 Lua 脚本在 Redis 内置的解释器里运行时,Lua 脚本的返回值也会被转换成 Redis 协议(protocol),然后由 EVAL 将值返回给客户端。

数据类型之间的转换遵循这样一个设计原则:如果将一个 Redis 值转换成 Lua 值,之后再将转换所得的 Lua 值转换回 Redis 值,那么这个转换所得的 Redis 值应该和最初时的 Redis 值一样。

换句话说, Lua 类型和 Redis 类型之间存在着一一对应的转换关系。

Redis 到 Lua 的转换表:

  • Redis integer reply -> Lua number / Redis 整数转换成 Lua 数字
  • Redis bulk reply -> Lua string / Redis bulk 回复转换成 Lua 字符串
  • Redis multi bulk reply -> Lua table (may have other Redis data types nested) / Redis 多条 bulk 回复转换成 Lua 表,表内可能有其他别的 Redis 数据类型
  • Redis status reply -> Lua table with a single ok field containing the status / Redis 状态回复转换成 Lua 表,表内的 ok 域包含了状态信息
  • Redis error reply -> Lua table with a single err field containing the error / Redis 错误回复转换成 Lua 表,表内的 err 域包含了错误信息
  • Redis Nil bulk reply and Nil multi bulk reply -> Lua false boolean type / Redis 的 Nil 回复和 Nil 多条回复转换成 Lua 的布尔值 false

Lua到 Redis 的转换表:

  • Lua number -> Redis integer reply (the number is converted into an integer) / Lua 数字转换成 Redis 整数
  • Lua string -> Redis bulk reply / Lua 字符串转换成 Redis bulk 回复
  • Lua table (array) -> Redis multi bulk reply (truncated to the first nil inside the Lua array if any) / Lua 表(数组)转换成 Redis 多条 bulk 回复
  • Lua table with a single ok field -> Redis status reply / 一个带单个 ok 域的 Lua 表,转换成 Redis 状态回复
  • Lua table with a single err field -> Redis error reply / 一个带单个 err 域的 Lua 表,转换成 Redis 错误回复
  • Lua boolean false -> Redis Nil bulk reply. / Lua 的布尔值 false 转换成 Redis 的 Nil bulk 回复
  • 从 Lua 转换到 Redis 有一条额外的规则,这条规则没有和它对应的从 Redis 转换到 Lua 的规则:
  • Lua boolean true -> Redis integer reply with value of 1. / Lua 布尔值 true 转换成 Redis 整数回复中的 1

还有下面两点需要重点注意:

  • lua中整数和浮点数之间没有什么区别。因此,我们始终Lua的数字转换成整数的回复,这样将舍去小数部分。如果你想从Lua返回一个浮点数,你应该将它作为一个字符串(见比如ZSCORE命令)。
  • There is no simple way to have nils inside Lua arrays, this is a result of Lua table semantics, so when Redis converts a Lua array into Redis protocol the conversion is stopped if a nil is encountered.
脚本的原子性

Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。 这和使用 MULTI / EXEC 包围的事务很类似。 在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。 另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难, 因为脚本的运行开销(overhead)非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心, 因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。

RedisTemplate运行脚本

Spring Data Redis为正在运行的脚本提供了高级抽象,该脚本处理序列化并自动使用Redis脚本缓存。

脚本可以通过调用运行execute的方法RedisTemplate和ReactiveRedisTemplate。两者都使用可配置的ScriptExecutor(或ReactiveScriptExecutor)来运行提供的脚本。默认情况下,ScriptExecutor(或ReactiveScriptExecutor)负责序列化提供的键和参数并反序列化脚本结果。这是通过模板的键和值序列化程序完成的。还有一个额外的重载,可让您传递脚本参数和结果的自定义序列化程序。

默认值ScriptExecutor通过检索脚本的SHA1并首先尝试运行来优化性能,如果脚本未在Redis脚本缓存中存在,则evalsha返回默认值eval。

以下示例通过使用Lua脚本运行常见的“检查并设置”方案。这是Redis脚本的理想用例,因为它需要原子地运行一组命令,并且一个命令的行为会受到另一个命令的结果的影响。

核心类

RedisTemplate提供了两个execute执行Lua脚本:

    // 传入脚本对象、Key队列、多个参数
    // execute没有传入序列化器时,默认使用的是RedisTemplate的ValueSerializer序列化器
    @Nullable
    <T> T execute(RedisScript<T> var1, List<K> var2, Object... var3);
    // 传入脚本对象、Key队列、Key和参数序列化器、返回值序列化器、多个参数
    @Nullable
    <T> T execute(RedisScript<T> var1, RedisSerializer<?> var2, RedisSerializer<T> var3, List<K> var4, Object... var5);

首先需要创建一个RedisScript脚本对象DefaultRedisScript,核心代码如下:

public class DefaultRedisScript<T> implements RedisScript<T>, InitializingBean {

    // synchronized锁对象,通过文件创建的脚本,每次执行时,都会加锁,判断文件是否被修改
    // 可以自己实现ScriptSource对象及方法,去掉synchronized,提高性能
    private final Object shaModifiedMonitor;
    @Nullable
    // 脚本源对象,实现类为ResourceScriptSource,可以通过文件加载脚本
    private ScriptSource scriptSource;
    @Nullable
    private String sha1;
    @Nullable
    // 脚本执行返回结果类型
    private Class<T> resultType;
    // 构造方法,传入脚本及返回结果类型,设置为Java中的数据类型即可,会转换
    public DefaultRedisScript(String script, @Nullable Class<T> resultType) {

        this.shaModifiedMonitor = new Object();
        this.setScriptText(script);
        this.resultType = resultType;
    }
    //  获取脚本源对象中的脚本,会加锁
    pulic String getScriptAsString() {

        try {

            return this.scriptSource.getScriptAsString();
        } catch (IOException var2) {

            throw new ScriptingException("Error reading script text", var2);
        }
    }

最终的execute方法

    public <T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args) {

        return this.template.execute((connection) -> {

            // script.getResultType(),获取脚本对象设置的返回结果类型
            // 将JAVA中的类型转为Lua脚本中的数据类型
            ReturnType returnType = ReturnType.fromJavaType(script.getResultType());
            // 将key和参数序列化为二进制
            byte[][] keysAndArgs = this.keysAndArgs(argsSerializer, keys, args);
            int keySize = keys != null ? keys.size() : 0;
            if (!connection.isPipelined() && !connection.isQueueing()) {

                // 执行脚本
                return this.eval(connection, script, returnType, keySize, keysAndArgs, resultSerializer);
            } else {

                connection.eval(this.scriptBytes(script), returnType, keySize, keysAndArgs);
                return null;
            }
        });
    }

案例演示

1、 resources/META-INF/scripts目录下创建脚本文件checkandset.lua;

-- checkandset.lua
-- 获取一个Key的值
local current = redis.call('GET', KEYS[1])
-- 如果这个值等于传入的第一个参数
if current == ARGV[1]
  -- 设置这个Key的值为第二个参数
  then redis.call('SET', KEYS[1], ARGV[2])
  return true
end
-- 如果这个值不等于传入的第一个参数,直接返回false
return false

&nbsp;

1、 使用redisTemplate执行脚本;

    @Test
    void luaTest() throws IOException {

        redisTemplate.opsForValue().set("lua:key", "aaa");
        // 根据脚本文件位置创建ScriptSource对象
        ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("META-INF/scripts/checkandset.lua"));
        // 根据脚本和返回值类型创建DefaultRedisScript对象,泛型定义为返回值类型
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(scriptSource.getScriptAsString(), Boolean.class);
        // 执行脚本
        ArrayList<String> keys = Lists.newArrayList("lua:key");
        Boolean result = redisTemplate.execute(redisScript, keys, "aaa", "bbb");
        System.out.println(result);
    }