likes
comments
collection
share

Redis Lua 脚本分析及 Redisson 客户端的lua的优化

作者站长头像
站长
· 阅读数 22

什么时候才会用lua脚本?

当我要执行一组 redis 指令,减低通讯的成本的时候可以用lua。

但是pipeline这个命令也是可以用的,那为什么不用pipeline呢?

首先说下pipeline的局限,如果我想在管道里面做运算(线程安全)的操作是不能做到的。

如: 卖商品,当库存到0的时候,就不需要再减了。不然就会变负数了。

然后就是 pipeline,如果 A命令要依赖于B的结果,这样也获取不到。

上面这种场景pipeline是做不到的,这个时候就需要用到Redis的lua脚本了。

EVAL 简介

EVAL 和 EVALSHA 从 Redis 2.6 版本开始,使用内置的 Lua 解析器,可以对 Lua进行求值。

EVAL script numkeys key [key ...] arg [arg ...]
  • script :lua 脚本
  • numkeys :key 的数量
  • key :key列表,可以在 Lua 中通过全局变量 KEYS 数组,以 1 为 基址的形式访问 (KEYS[1],KEYS[2] ...)
  • arg :参数列表,可以在 Lua 中通过全局变量 ARGV 数组,以 1 为 基址的形式访问 (ARGV [1],ARGV [2] ...)

例子:

eval "return redis.call('set',KEYS[1],'bar')" 1 foo
如何从 Lua 脚如何调用 Redis 命令?
redis.call() :将返回给调用者一个错误
redis.pcall() :将捕获的错误以Lua表的形式返回

创建并修改Lua环境

Redis为了在Redis服务器中执行 Lua 脚本,Redis在服务器内嵌了一个Lua环境,并对这个 Lua环境进行,并对这个 Lua环境进行一系列修改,从而确保这个Lua环境考验满足 Redis 服务器的需要。

步骤:

  1. 创建一个基础的 Lua 环境,之后所有修改都是针对这个环境进行的。
  2. 载入多个函数库到 Lua 环境
  3. 创建全局表格 reids,这个表格包括对 Redis 进行操作的函数
  4. 创建随机函数 random-with-default-seed.lua
  5. 创建排序辅助函数
  6. 创建 redis.pcall 函数的错误报告

1.创建 Lua 环境

服务器首先调用 Lua 的 C API函数 lua_open,创建一个新的 Lua 环境。

2.载入函数库

基础库 (base) :如 assert、error、pairs、tostring、pcall 等 表格库(table):如 table.concat、table.insert、table.remove、table.sort 等 字符串库(string):处理字符串的通用函数 string.format 、string.len、string.reveres 函数 数学库(math):math.abs、math.max、math.min、math.sqrt、math.log 调用库(debug):提供程序设置钩子 debug.sethook 和 取得钩子 debug.gethook 函数 Lua CJSON库:用于处理 UTF-8 编码的 JSON 格式。 cjson.decode 函数将一个 JSON 格式的字符串转换为一个 Lua值,而 cjson.encode 函数将一个 Lua 值序列化为 JSON 格式字符串。 Struct库:用于 Lua 值和C结果之间转换 Lua cmsgpack库:用于处理 MessagePack 格式数据

3.创建 redis 全局表格

服务器将在 Lua 环境创建一个 reids 表格(table),并将它设为全局变量。reids 表格包括以下函数: 用于执行 Redis 命令的 redis.call 和 redis.pcall 函数。(常用) 用于记录 Redis 日志(log) 的 redis.log 函数,以及相应的日志级别 (level) 处理 用于计算 SHA1 校验和的 redis.sha1hex 函数 用于返回错误信息的 redis.error_reply 函数和 redis.status_reply 函数。

4.伪客户端

因为执行 Redis 命令必须有相应的 客户端状态,所以为了执行 Lua 脚本中包括的命令。Redis 服务器专门为 Lua环境创建了一个伪客户端,并由一个伪客户端处理 Lua脚本中的所有 Redis命令。

EVAL "return redis.call(‘DSSIZE’)" 0

lua_scripts 字典

除了伪客户端之外,Redis 服务器为 Lua 环境创建的另外一个主键就是 lua_scripts,这个字典的键为某个Lua 脚本的 SHA1 校验和 (checksum),而字典的值则是 SHA1校验和 lua脚本:

struct redisServer{ // ... dict *lua_scripts; // ... }

Redis 服务器会将所有的被 EVAL 命令执行过的Lua 脚本,以及所有被 SCRIPT LOAD 命令载入过的 Lua 脚本都保存到 lua_scripts 字典里。

例子:

r127.0.0.1:6371> SCRIPT LOAD "return 'hi'"
"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3"
127.0.0.1:6371> SCRIPT LOAD "return 1+1"
"a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9"
127.0.0.1:6371> SCRIPT LOAD "return 2*2"
"4475bfb5919b5ad16424cb50f74d4724ae833e72"

lua_scripts 字典有两个作用,一个是实现 SCRIPT EXISTS 命令,另外一个实现脚本复制功能。

EVAL命令的实现

EVAL 命令的执行过程可以分为以下三个步骤:

  1. 根据客户端给定的 Lua 脚本,在 Lua 环境中定义一个 Lua函数
  2. 将客户端给定的脚本保存到 lua_scripts 字典,等待将来进一步使用
  3. 执行刚刚在 Lua 环境定义的函数,以此来执行客户端执行的 Lua 脚本。
127.0.0.1:6371> EVAL "return 'hello world'" 0
"hello world"

定义脚本函数

当客户端向服务器发送 EVAL 命令,要求执行某个 Lua脚本,服务器首先要做的就是在 Lua环境中为传入的脚本定义一个与这个脚本相对应的 Lua函数,其中,Lua函数的名字是由 f_ 前缀加上脚本的 SHA1 校验和 (四十字字符长) 组成,而函数的体 (body) 则是脚本本身。

我们输入 :EVAL "return 'hello world'" 0

对于服务器来说: function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91() return "hello world" end

因为客户端传入脚本为 return 'hello world',而这个脚本的 SHA1 校验和为 5332031c6b470dc5a0dd9b4bf2030dea6d65de91,所以函数名字为 f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91 ,而函数体则为 return "hello world"。

使用函数来保存客户端传入脚本有以下好处:

执行脚本的步骤非常简单,只要调用脚本相对应的函数即可。 通过函数局部性来让 Lua 环境保存清洁,减少垃圾回收的工作量,并且避免了使用全局变量 如果某个脚本所对应的在 Lua环境中被定义过至少一次,那么只要记得调用这个脚本的 SHA1 校验和,服务器就可以在不知道脚本本身直接调用。

将脚本保存到 lua_script 字典 EVAL命令第二件事就是将客户端传入的脚步保存到服务器的 lua_script 字典里面。

 return 'hello world'
 =====>>>
 5332031c6b470dc5a0dd9b4bf2030dea6d65de91

执行脚本函数

当把脚本保存到 lua_script 字典后,服务器还要设置钩子,传参,才能正式执行脚本。

流程如下:

  1. 将EVAL 命令传入的键名和参数分别保存到 KEY 数组和 ARGV数组,然后把两个数组作为
  2. 全局变量传入 Lua 环境里面。
  3. 为 Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况时,让客户端通过 SCRIPT KILL 命令停止脚本,或者通过 SHUTDOWN 命令直接关闭服务器。
  4. 执行脚本函数
  5. 移除之前装载的超时钩子
  6. 将执行脚本函数所得的结果保存到客户端状态的缓冲区里面,等等服务器将结果返回到客户端。
  7. 对 Lua 环境执行垃圾回收操作。

EVALSHA 命令的实现

每个被EVAL命令的成功执行过的 Lua脚本,在 Lua环境里面都有一个脚本相对应的 Lua函数。

注意这个返回错误 “SCRIPRT NOT FOUND”

SCRIPT LOAD

SCRIPT LOAD 先在 Lua环境创建函数,然后再把脚本保存到 lua_scripts 字典里面。

SCRIPT KILL

如果服务器设置了 lua-time-limit 配置,每次执行 Lua脚本直接,服务器都会在 Lua环境里面设置一个超时处理钩子 (hook)。

主从服复制脚本

主服务器复制 EVAL、SCRIPT FLUSH、SCRIPT LOAD 三个命令和复制普通Redis命令一样,只要将相同的命令传播给从服就可以了。

Redisson 实现的客户端有 Lua脚本进行了什么优化?

参数 : useScriptCache
Default value: false
定义是否在Redis端使用Lua脚本缓存。大多数Redisson方法都是基于Lua脚本的,
打开此设置可以提高此类方法的执行速度并节省网络流量。

思路:

  1. 先把命令转换成 sha1码
  2. 然后执行一遍 “EVALSHA” 命令,监听命令返回命令
  3. 返回 “NOSCRIPT” 就是证明该 Redis服务端没有这个脚本了,执行一遍 “SCRIPT_LOAD” 命令,把脚本load进去,继续监听 “SCRIPT_LOAD” 命令的返回
  4. “SCRIPT_LOAD”命令返回成功就再发一次 “EVALSHA” 命令

代码如下:

private <T, R> RFuture<R> evalAsync(NodeSource nodeSource, boolean readOnlyMode, Codec codec, RedisCommand<T> evalCommandType, String script, List<Object> keys, Object... params) {
    // 如果 useScriptCache == true 并且是 脚本命令 EVAL
    if (isEvalCacheActive() && evalCommandType.getName().equals("EVAL")) {
        RPromise<R> mainPromise = new RedissonPromise<R>();
        
        Object[] pps = copy(params);
        
        RPromise<R> promise = new RedissonPromise<R>();
        // 先把命令转成 sha1码
        String sha1 = calcSHA(script);
        RedisCommand cmd = new RedisCommand(evalCommandType, "EVALSHA");
        List<Object> args = new ArrayList<Object>(2 + keys.size() + params.length);
        args.add(sha1);
        args.add(keys.size());
        args.addAll(keys);
        args.addAll(Arrays.asList(params));

        // 发送 EVALSHA 命令
        RedisExecutor<T, R> executor = new RedisExecutor<>(readOnlyMode, nodeSource, codec, cmd,
                                                    args.toArray(), promise, false, connectionManager, objectBuilder, referenceType);
        executor.execute();
        // 监听返回
        promise.onComplete((res, e) -> {
            if (e != null) {
                // redis 服务端返回 “NOSCRIPT” 证明 Redis 没有这个脚本
                if (e.getMessage().startsWith("NOSCRIPT")) {
                    // 执行 “SCRIPT_LOAD” 命令先把脚本加载到 Redis服务端
                    RFuture<String> loadFuture = loadScript(executor.getRedisClient(), script);
                    // 监听返回
                    loadFuture.onComplete((r, ex) -> {
                        if (ex != null) {
                            free(pps);
                            mainPromise.tryFailure(ex);
                            return;
                        }
                        // “SCRIPT_LOAD” 执行成功,再一次执行 “EVALSHA”
                        RedisCommand command = new RedisCommand(evalCommandType, "EVALSHA");
                        List<Object> newargs = new ArrayList<Object>(2 + keys.size() + params.length);
                        newargs.add(sha1);
                        newargs.add(keys.size());
                        newargs.addAll(keys);
                        newargs.addAll(Arrays.asList(pps));

                        NodeSource ns = nodeSource;
                        if (ns.getRedisClient() == null) {
                            ns = new NodeSource(nodeSource, executor.getRedisClient());
                        }

                        async(readOnlyMode, ns, codec, command, newargs.toArray(), mainPromise, false);
                    });
                } else {
                    free(pps);
                    mainPromise.tryFailure(e);
                }
                return;
            }
            free(pps);
            mainPromise.trySuccess(res);
        });
        return mainPromise;
    }
    // 没有做优化,正常发送请求
    RPromise<R> mainPromise = createPromise();
    List<Object> args = new ArrayList<Object>(2 + keys.size() + params.length);
    args.add(script);
    args.add(keys.size());
    args.addAll(keys);
    args.addAll(Arrays.asList(params));
    async(readOnlyMode, nodeSource, codec, evalCommandType, args.toArray(), mainPromise, false);
    return mainPromise;
}

转载自:https://juejin.cn/post/6954750552894881799
评论
请登录