Lua环境的初始化
现在我们来看看,Redis是如何初始化一个Lua环境的:
Redis会在服务器通用的初始化函数
initServer
的最后,为服务器初始化Lua环境。在执行
SCRIPT FLUSH
命令清空服务器上的Lua脚本缓存时,为了清空Lua环境之中可能残留的数据,也会对系统的Lua环境进行初始化。
void scriptingInit(int setup);
通过setup
参数,可以确定是对Lua环境的初始化是发生在系统启动时,还是通过SCRIPT
进行的。scriptingInit
这个初始话函数主要执行的逻辑为:
Redis会同通过
lua_open
函数来创建一个新的Lua环境,并通过luaLoadLibraries
为这个Lua环境加载脚本执行时必要的库;并通过luaRemoveUnsupportedFunctions
来移除Redis不支持的Lua原生函数。初始化Redis服务器全局变量中缓存脚本的哈希表
redisServer.lua_script
。在禁止创建Lua全局变量之前,先为Lua环境创建一个全局的表
redis
,并将前面定义的C语言接口以及其他系统变量注册进这个全局表redis
之中。例如将luaRedisCallCommand
这个C语言进口注册进redis
全局表,成为redis.call
这个函数。scriptingInit
会将Lua库函数之中一些具有副作用的函数替换成Redis自己实现的函数接口,需要被替换的函数有两个math.random
以及math.randomseed
这两个Lua库函数。为执行Lua脚本之中的Redis原生命令,Redis需要初始化一个用于执行命令的伪客户端
redisServer.lua_client
。最后Redis会通过
scriptingEnableGlobalsProtection
函数,来保护Lua环境,禁止后续用户在Lua脚本之中声明全局变量。
脚本命令的实现
对于Redis原生的脚本命令之中,较为重要的便是通过EVAL来执行脚本的命令以及通过SCRIPT命令来杀掉出现Bug脚本这两个命令,本小节之中也着重对这两个部分进行介绍。
void evalGenericCommand(client *c, int evalsha);
通过EVAL命令以及EVALSHA命令来执行Lua脚本代码的,下面我们来简要介绍一下evalGenericCommand
这个函数的实现逻辑:
首先清空服务器全局变量之中关于Lua脚本的数据字段,包括
redisServer.lua_random_dirty
以及redisServer.lua_write_dirty
等这样的字段,将系统之中的Lua环境的数据重置。获取Lua脚本对应的SHA1哈希值,可以如果是EVAL命令,则是通过命令之中给定的脚本计算出来;如果是EVALSHA命令则从命令参数之中获取到脚本的哈希值。
查找脚本的哈希值对应的函数是否存在于Lua环境之中,如果不存在说明是通过EVAL命令执行的新的Lua脚本。对于这种情况则调用
luaCreateFunction
,将这个新的Lua脚本加入系统缓存之中。将命令之中的
key
以及arg
参数,写入Lua脚本的全局变量KEYS
以及ARGV
之中,供脚本的函数逻辑使用。设置一些服务器关于Lua脚本执行的字段,包括当前执行脚本的客户端指针
redisServer.lua_caller
,脚本执行的开始时间redisServer.lua_time_start
。如果系统设置了脚本执行时间的上限redisServer.lua_time_limit
,那么会设置Lua的钩子luaMaskCountHook
用于检测脚本执行是否超时。通过
lua_pcall
来执行这个脚本函数。通过
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);
这三个函数里:
replicationScriptCacheAdd
,当一个Lua脚本被传递给从服务器时,主服务器会通过这个函数接口,将这个脚本对应的SHA1哈希值加入主服务器的redisServer.repl_scriptcache_dict
这个集合中,用于表示这个脚本已经被从服务器所缓存。replicationScriptCacheExists
,通过这个函数接口则可以判断,给定哈希值的的Lua代码是否已经传递给从服务器。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命令时,关于复制机制的特殊处理:
通过
replicationScriptCacheExists
接口,检查参数中哈希值对应的脚本是否已经被从服务器缓存。如果没有被从服务器缓存,那么先通过
replicationScriptCacheAdd
接口,将这个脚本加入到redisServer.repl_scriptcache_dict
集合之中。根据Lua脚本的代码逻辑对EVALSHA命令进行强制修改:
如果脚本之中全部为只读命令,那么只需要让从服务器缓存这个脚本就可以,不需要在从服务器上执行Lua脚本的内部逻辑。因此EVALSHA命令则会强制被转化为SCRIPT LOAD命令传递给从服务器。
如果脚本之中执行的Redis原生命令修改了数据库的键空间,那么需要将EVALSHA命令强制转化为EVAL命令传递给从服务器,让从服务器在缓存脚本代码的同时,执行脚本逻辑进行数据同步。
脚本命令复制
在了解脚本命令的复制之前,首先我们来看两个Redis中Lua脚本的应用场景:
如果我们执行了一段非常复杂、非常耗时的Lua脚本,但是这段脚本代码中没有调用任何可写的Redis原生命令。换句话说,除了大量占用了系统资源之外,对Redis数据库键空间之中的数据本身没有任何影响。那么这样的代码是否有必要传递到从服务器上在重复执行一次呢?
如果在脚本之中执行了带有不确定性的命令,并且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模块功能则可以满足这样的需求。




