当我要执行一组 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 服务器的需要。

  • 创建一个基础的 Lua 环境,之后所有修改都是针对这个环境进行的。
  • 载入多个函数库到 Lua 环境
  • 创建全局表格 reids,这个表格包括对 Redis 进行操作的函数
  • 创建随机函数 random-with-default-seed.lua
  • 创建排序辅助函数
  • 创建 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 命令的执行过程可以分为以下三个步骤:

  • 根据客户端给定的 Lua 脚本,在 Lua 环境中定义一个 Lua函数
  • 将客户端给定的脚本保存到 lua_scripts 字典,等待将来进一步使用
  • 执行刚刚在 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"

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

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

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

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

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

    执行脚本函数

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

    流程如下:

  • 将EVAL 命令传入的键名和参数分别保存到 KEY 数组和 ARGV数组,然后把两个数组作为
  • 全局变量传入 Lua 环境里面。
  • 为 Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况时,让客户端通过 SCRIPT KILL 命令停止脚本,或者通过 SHUTDOWN 命令直接关闭服务器。
  • 执行脚本函数
  • 移除之前装载的超时钩子
  • 将执行脚本函数所得的结果保存到客户端状态的缓冲区里面,等等服务器将结果返回到客户端。
  • 对 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脚本的,
    打开此设置可以提高此类方法的执行速度并节省网络流量。
    
  • 先把命令转换成 sha1码
  • 然后执行一遍 “EVALSHA” 命令,监听命令返回命令
  • 返回 “NOSCRIPT” 就是证明该 Redis服务端没有这个脚本了,执行一遍 “SCRIPT_LOAD” 命令,把脚本load进去,继续监听 “SCRIPT_LOAD” 命令的返回
  • “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;
    
  • redis.cn/commands/ev… 官方文档
  • www.cnblogs.com/chopper-poe… 参考
  • 分类:
    后端
    标签: