暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Redis源码学习(63)-Redis脚本(下)

马基雅维利incoding 2021-03-13
561

Lua环境的初始化

现在我们来看看,Redis是如何初始化一个Lua环境的:

  1. Redis会在服务器通用的初始化函数initServer
    的最后,为服务器初始化Lua环境。

  2. 在执行SCRIPT FLUSH
    命令清空服务器上的Lua脚本缓存时,为了清空Lua环境之中可能残留的数据,也会对系统的Lua环境进行初始化。

    void scriptingInit(int setup);

    通过setup
    参数,可以确定是对Lua环境的初始化是发生在系统启动时,还是通过SCRIPT
    进行的。scriptingInit
    这个初始话函数主要执行的逻辑为:

    1. Redis会同通过lua_open
      函数来创建一个新的Lua环境,并通过luaLoadLibraries
      为这个Lua环境加载脚本执行时必要的库;并通过luaRemoveUnsupportedFunctions
      来移除Redis不支持的Lua原生函数。

    2. 初始化Redis服务器全局变量中缓存脚本的哈希表redisServer.lua_script

    3. 在禁止创建Lua全局变量之前,先为Lua环境创建一个全局的表redis
      ,并将前面定义的C语言接口以及其他系统变量注册进这个全局表redis
      之中。例如将luaRedisCallCommand
      这个C语言进口注册进redis
      全局表,成为redis.call
      这个函数。

    4. scriptingInit
      会将Lua库函数之中一些具有副作用的函数替换成Redis自己实现的函数接口,需要被替换的函数有两个math.random
      以及math.randomseed
      这两个Lua库函数。

    5. 为执行Lua脚本之中的Redis原生命令,Redis需要初始化一个用于执行命令的伪客户端redisServer.lua_client

    6. 最后Redis会通过scriptingEnableGlobalsProtection
      函数,来保护Lua环境,禁止后续用户在Lua脚本之中声明全局变量。

    脚本命令的实现

    对于Redis原生的脚本命令之中,较为重要的便是通过EVAL来执行脚本的命令以及通过SCRIPT命令来杀掉出现Bug脚本这两个命令,本小节之中也着重对这两个部分进行介绍。

      void evalGenericCommand(client *c, int evalsha);

      通过EVAL命令以及EVALSHA命令来执行Lua脚本代码的,下面我们来简要介绍一下evalGenericCommand
      这个函数的实现逻辑:

      1. 首先清空服务器全局变量之中关于Lua脚本的数据字段,包括redisServer.lua_random_dirty
        以及redisServer.lua_write_dirty
        等这样的字段,将系统之中的Lua环境的数据重置。

      2. 获取Lua脚本对应的SHA1哈希值,可以如果是EVAL命令,则是通过命令之中给定的脚本计算出来;如果是EVALSHA命令则从命令参数之中获取到脚本的哈希值。

      3. 查找脚本的哈希值对应的函数是否存在于Lua环境之中,如果不存在说明是通过EVAL命令执行的新的Lua脚本。对于这种情况则调用luaCreateFunction
        ,将这个新的Lua脚本加入系统缓存之中。

      4. 将命令之中的key
        以及arg
        参数,写入Lua脚本的全局变量KEYS
        以及ARGV
        之中,供脚本的函数逻辑使用。

      5. 设置一些服务器关于Lua脚本执行的字段,包括当前执行脚本的客户端指针redisServer.lua_caller
        ,脚本执行的开始时间redisServer.lua_time_start
        。如果系统设置了脚本执行时间的上限redisServer.lua_time_limit
        ,那么会设置Lua的钩子luaMaskCountHook
        用于检测脚本执行是否超时。

      6. 通过lua_pcall
        来执行这个脚本函数。

      7. 通过luaReplyToRedisReply
        这个接口,将Lua环境之中的返回数据输出给客户端对象的应用层输出缓冲区。

      在了解了Redis如何通过EVAL以及EVALSHA命令执行Lua脚本之后,我们看看Redis服务器如何检测Lua脚本执行超时,并通过SCRIPT命令将超时的脚本杀掉的实现细节。

      首先Redis是通过luaMaskCountHook
      这个钩子来检测的:

        void luaMaskCountHook(lua_State *lua, lua_Debug *ar);

        evalGenericCommand
        函数之中的调用场景为:

          void evalGenericCommand(client *c, int evalsha)
          {
          ...
          if (server.lua_time_limit > 0 ldb.active == 0)
          {
          lua_sethook(lua, luaMaskCountHook, LUA_MASKCOUNT, 100000);
          delhook = 1;
          }
          ...
          }

          这里相当于Lua脚本每执行100000次执行单元就会调用luaMaskCountHook
          函数来检查脚本是否超时,

            void luaMaskCountHook(lua_State *lua, lua_Debug *ar)
            {
            long long elapsed = mstime() - server.lua_time_start;
            if (elapsed >= server.lua_time_limit && server.lua_timedout == 0)
            {
            server.lua_timedout = 1;
            protectClient(server.lua_caller);
            }
            if (server.lua_timedout) processEventsWhileBlocked();
            if (server.lua_kill) {
            lua_error(lua);
            }
            }

            每次调用luaMaskCountHook
            这个函数时,都会检查从redisServer.lua_time_start
            这个时间戳开始,Lua脚本执行到现在是否超时,如果超时则会将redisServer.lua_timedout
            字段设置为1。当Lua脚本出现超时时,本质上是主线程卡死在redisServer.lua_caller
            这个客户端上来执行Lua脚本的流程上,此时如果不进行特殊的处理,Redis主线程将无法重新进入事件循环去处理其他的客户端发来的命令。因此Redis在检测到Lua脚本执行超时时,会调用processEventsWhileBlocked
            来强制Redis进入事件循环接收其他客户端的命令,这样其他客户端可以发送SCRIPT命令来终结这个超时脚本的执行。

            Redis处理客户端命令的函数接口之中:

              int processCommand(client *c) {
              ...
              if (server.lua_timedout &&
              c->cmd->proc != authCommand &&
              c->cmd->proc != replconfCommand &&
              !(c->cmd->proc == shutdownCommand &&
              c->argc == 2 &&
              tolower(((char*)c->argv[1]->ptr)[0]) == 'n') &&
              !(c->cmd->proc == scriptCommand &&
              c->argc == 2 &&
              tolower(((char*)c->argv[1]->ptr)[0]) == 'k'))
              {
              flagTransaction(c);
              addReply(c, shared.slowscripterr);
              return C_OK;
              }
              ...
              }

              上述代码之中,表明当Lua脚本执行超时时,仅允许客户端执行特定的几个命令,其中就包含了杀掉脚本运行的SCRIPT命令。

              最后我们来看一下SCRIPT命令是如何终止脚本运行的:

                void scriptCommand(client *c) {
                ...
                if (c->argc == 2 && !strcasecmp(c->argv[1]->ptr,"kill")) {
                if (server.lua_caller == NULL) {

                } else if (server.lua_caller->flags & CLIENT_MASTER) {

                } else if (server.lua_write_dirty) {

                } else {
                server.lua_kill = 1;
                addReply(c,shared.ok);
                }
                }
                ...
                }

                这里会通过redisServer.lua_caller
                来检查当前是否运行者Lua脚本,通过redisServer.lua_write_dirty
                来检查脚本之中是否已经运行过写命令,同时检查这个当前运行Lua脚本的客户端是不是主从模式之中代表主服务器的客户端对象。在排除了上述这些检查之后,会将redisServer.lua_kill
                这个字段设置为1。

                回头再看luaMaskCountHook
                这个函数,当我们通过SCRIPT命令将redisServer.lua_kill
                字段设置为1之后,当脚本执行再次进入luaMaskCountHook
                这个函数后,会通过lua_error
                这个接口来终止脚本的运行。

                脚本复制

                最后我们来看一下脚本的复制机制,此处的复制可以理解为广义上的复制,其一是指主从模式下,命令从主服务器到从服务器上的复制功能;另外一种则是在执行AOF持久化的过程之中,将命令追加到AOF文件之中的功能。不过为了简化描述过程,此处将以主从复制为例,来介绍脚本的复制机制。而本节所讨论的脚本复制机制包含两个独立的内容,一个是脚本本身的复制,另外一个内容是对脚本之中单条命令的复制。

                脚本本体复制

                这一部分比较容易理解,就是主服务器在客户端通过EVAL命令执行Lua脚本的时候,也会将这条命令转发给从服务器并执行命令,以完成数据的同步。因为通过EVAL命令,Lua脚本代码段都是通过参数的形式显式地给出的,因此不需要做额外地处理。比较麻烦的是EVALSHA命令的复制机制,对于EVALSHA命令是通过SHA1哈希值来指定所要执行的Lua脚本内容,而这些被缓存的脚本都是存储在服务器全局变量redisServer.lua_scripts
                这个哈希表之中的。不过不幸的是,在主从服务器建立连接进行全量的数据同步时,redisServer.lua_scripts
                这个哈希表并不在待同步数据的范围之内。因此如果直接转发EVALSHA命令到从服务器上的话,从服务器上很有可能并不存在给定SHA1哈希值对应的脚本。

                因此除非从服务器上已经缓存了指定的脚本代码,否则EVALSHA命令将会被转化为EVAL命令进行复制。为了确保这个功能的实现,Redis服务器在服务器全局数据结构之中定义了redisServer.repl_scriptcache_dict
                这个哈希表并将其做一个集合来使用。记录已经被从服务器缓存的Lua脚本代码,关于这部分数据,Redis给出了三个函数接口用于处理:

                  void replicationScriptCacheAdd(sds sha1);
                  int replicationScriptCacheExists(sds sha1);
                  void replicationScriptCacheFlush(void);

                  这三个函数里:

                  1. replicationScriptCacheAdd
                    ,当一个Lua脚本被传递给从服务器时,主服务器会通过这个函数接口,将这个脚本对应的SHA1哈希值加入主服务器的redisServer.repl_scriptcache_dict
                    这个集合中,用于表示这个脚本已经被从服务器所缓存。

                  2. replicationScriptCacheExists
                    ,通过这个函数接口则可以判断,给定哈希值的的Lua代码是否已经传递给从服务器。

                  3. replicationScriptCacheFlush
                    ,当有新的从服务器连接到当前的主服务器时,通过这个接口可以清空主服务器上redisServer.repl_scriptcache_dict
                    这个缓存,以达到强制重新同步Lua脚本代码的目录。

                    void evalGenericCommand(client *c, int evalsha) {
                    ...
                    if (evalsha && !server.lua_replicate_commands) {
                    if (!replicationScriptCacheExists(c->argv[1]->ptr)) {
                    robj *script = dictFetchValue(server.lua_scripts,c->argv[1]->ptr);
                    replicationScriptCacheAdd(c->argv[1]->ptr);
                    serverAssertWithInfo(c,NULL,script != NULL);
                    if (server.dirty == initial_server_dirty) {
                    rewriteClientCommandVector(c,3,resetRefCount(createStringObject("SCRIPT",6)),
                    resetRefCount(createStringObject("LOAD",4)),script);
                    } else {
                    rewriteClientCommandArgument(c,0,resetRefCount(createStringObject("EVAL",4)));
                    rewriteClientCommandArgument(c,1,script);
                    }
                    forceCommandPropagation(c,PROPAGATE_REPL|PROPAGATE_AOF);
                    }
                    }
                    ...
                    }

                    通过上面这个代码段,我们可以了解到Redis在执行EVALSHA命令时,关于复制机制的特殊处理:

                    1. 通过replicationScriptCacheExists
                      接口,检查参数中哈希值对应的脚本是否已经被从服务器缓存。

                    2. 如果没有被从服务器缓存,那么先通过replicationScriptCacheAdd
                      接口,将这个脚本加入到redisServer.repl_scriptcache_dict
                      集合之中。

                    3. 根据Lua脚本的代码逻辑对EVALSHA命令进行强制修改:

                      1. 如果脚本之中全部为只读命令,那么只需要让从服务器缓存这个脚本就可以,不需要在从服务器上执行Lua脚本的内部逻辑。因此EVALSHA命令则会强制被转化为SCRIPT LOAD命令传递给从服务器。

                      2. 如果脚本之中执行的Redis原生命令修改了数据库的键空间,那么需要将EVALSHA命令强制转化为EVAL命令传递给从服务器,让从服务器在缓存脚本代码的同时,执行脚本逻辑进行数据同步。

                    脚本命令复制

                    在了解脚本命令的复制之前,首先我们来看两个Redis中Lua脚本的应用场景:

                    1. 如果我们执行了一段非常复杂、非常耗时的Lua脚本,但是这段脚本代码中没有调用任何可写的Redis原生命令。换句话说,除了大量占用了系统资源之外,对Redis数据库键空间之中的数据本身没有任何影响。那么这样的代码是否有必要传递到从服务器上在重复执行一次呢?

                    2. 如果在脚本之中执行了带有不确定性的命令,并且Lua脚本会根据这些不确定性命令的返回结果执行不同的逻辑。举个例子,脚本之中可以根据TIME命令返回的系统时间进行逻辑判断并执行不同的命令,如果这样的脚本被发送到从服务器上执行或者在服务器启动时从AOF文件之中解析并重执行,那么很有可能这个脚本的执行逻辑并非是按照我们预期来进行的。对于这种情况,Redis需要如何处理呢?

                    对于上述的情况,Redis设计实现了脚本之中针对单独命令的复制功能,基于这种功能,我们可以选择性地对脚本之中的某些特定命令进行复制,而不是复制整个脚本本体,而最终的复制形式,是将这个脚本执行过程中的所有需要复制的命令通过MULTI以及EXEC这两个命令包装成一组事务命令进行复制,以保证命令执行的原子性。

                    Redis会通过服务器全局变量之中的redisServer.lua_replicate_commands
                    字段来判断是否开启了脚本中单独命令复制的功能。在Lua脚本之中,我们可以通过redis.replicate_commands()
                    这个函数,进而调用C语言接口luaRedisReplicateCommandsCommand
                    redisServer.lua_replicate_commands
                    设置为1,以开启这个特殊的功能。

                    如果这个功能开启,Redis则会根据redisServer.lua_repl
                    这个变量上存储的标记对命令执行不同的复制逻辑,在Lua脚本之中我们可以通过redis.set_repl(flags)
                    这个接口,来对这个标记进行调整。

                    当我们期待某一个命令不要被复制,我们可以在redis.call()
                    调用该原生命令之前,执行如下的Lua代码

                      redis.set_repl(redis.REPL_NONE)

                      当我们期待一条命令既能够被复制到从服务器,又能被追加到AOF文件之中时,我们可以在命令执行前,执行下面的Lua代码:

                        redis.set_repl(redis.REPL_ALL)

                        同理redis.REPL_AOF
                        则表示后续的命令只会被追加到AOF文件之中;redis.REPL_SLAVE
                        则表示后续命令只会被传递给从服务器上执行。

                        同时,为了辅助单独命令的复制功能,Redis定义了一个变量redisServer.lua_multi_emitted
                        ,这个变量表示当前脚本是否已经提交了一个MULTI命令。其运行的逻辑为,如果当前Lua脚本开启了命令复制的功能,当遇到第一条需要复制的命令时,会先追加一条MULTI命令,用于开启这个脚本命令事务。在脚本执行结束之后,也会判断redisServer.lua_multi_emitted
                        这个字段,如果为1则最后在追加一条EXEC命令,完成事务的命令序列,并将之追加到AOF文件之中或者传递给从服务器原子地执行命令序列。

                        补充

                        写在最后的一点,在编辑Redis上运行的Lua脚本时,我们不能通过redis.call
                        来调用Redis原生命令之中那些具有阻塞功能的命令。之所以无法调用,这是与脚本功能的设计实现有关的。

                        首先我们在来简述一下Lua脚本之中的执行过程,redisServer.lua_caller
                        作为运行Lua脚本的真实客户端对象,对应用于一条与用户真实建立的连接。Redis执行脚本的过程与处理其他客户端的命令一样,主线程会处理redisServer.lua_caller
                        这个客户端对象。而脚本之中调用的Redis原生命令,则是通过伪客户端对象redisServer.lua_client
                        来进行的。

                        而阻塞功能本质上是将阻塞的客户端设置成一个特殊的状态,在这个状态下,用户无法通过这个客户端对象执行其他的查询命令,仿佛这个客户端真的被挂起一下。而服务器并没有被阻塞,它会跳过这个客户端继续处理其他客户端的逻辑。

                        如果我们在Lua脚本之中调用阻塞命令,那么实际上“阻塞”的是伪客户端对象redisServer.lua_client
                        。脚本也不会阻塞在这条阻塞命令上,因为从服务器的角度上来看,阻塞相当与给客户端设置了阻塞状态之后立即返回。而真正脚本的调用者redisServer.lua_caller
                        根本不知道redisServer.lua_client
                        是否处于了阻塞状态,因为两个客户端本质上是两个独立的对象,仅仅是因为执行Lua脚本才将两个客户端暂时联系了起来。一旦脚本执行结束,两个客户端对象之间的联系就已经解除,这样即使redisServer.lua_client
                        从阻塞状态之中解除,也无法将这个事件通知给真正的客户端redisServer.lua_caller

                        如果我们期待可以在类似的原子操作命令序列之中支持阻塞命令,后面将要介绍的Redis模块功能则可以满足这样的需求。

                        文章转载自马基雅维利incoding,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                        评论