本文内容
通过对 Redis 源码中的 scripting.c 文件进行分析,解释 Lua 脚本功能的实现机制。
预备知识
因为脚本功能的实现源码和命令关系密切,最好在阅读这篇文章之前先了解 Redis 的脚本功能是如何使用的,否则你可能无法看明白这里的一些实现决策是如何做出的。
EVAL 命令的文档是学习使用脚本功能的一个很好起点。
脚本功能的实现
Redis 脚本功能的实现代码放在源码的 src/scripting.c 文件中,主要分为三部分:
- Lua 嵌入 Redis
- EVAL 和 EVALSHA 命令的实现
- SCRIPT 命令的实现
以下是这三个部分的详细说明。
Lua 嵌入 Redis
要在 Redis 中执行 Lua 脚本,必须先将 Lua 嵌入到 Redis 服务器端中,并且初始化 Lua 脚本的相关环境。
在 scripting.c 中,以上工作是由 scriptingInit 函数完成的:
void scriptingInit(void) { // Lua 魔法从这里发生… } |
scriptingInit 的工作包括以下:
1. 创建新的 Lua 环境
lua_State *lua = lua_open(); |
lua_open 是一个 Lua 5.1 提供的 C API ,它用于创造一个新的 Lua 环境(environment/state)。
2. 载入函数包
Redis 的 Lua 环境中提供了好几个常用的包,比如 base 、 table 、 math 和 cjson 等,这些包都在创建环境之后通过 luaLoadLibraries 函数载入:
luaLoadLibraries(lua); |
3. 移除不能暴露给 Redis 环境的 Lua 函数
为了避免安全问题,一些带有特殊作用的函数,比如 openfile ,是不能暴露给执行 Redis 命令的 Lua 环境的,因此需要从 Lua 环境中移除这些函数:
luaRemoveUnsupportedFunctions(lua); |
4. 创建字典,用于保存脚本缓存
被 EVAL 执行过的脚本,或者被 SCRIPT LOAD 命令载入过的脚本,都会被保存到 Redis 的服务器中,方便将来直接使用 EVALSHA 调用。
所有的这些脚本都被缓存到一个字典中,根据脚本的 SHA1 校验和来进行索引。
dictCreate 创建了字典,并将它传给 server.lua_scripts 属性:
server.lua_scripts = dictCreate(&dbDictType,NULL); |
5. 创建并设置 redis table
在 Redis 的 Lua 环境中,所有对 Redis 的工作都是通过调用 redis.xxx 这样的函数来完成的。
比如执行在 Lua 中执行 Redis 命令,可以调用 redis.call 或者 redis.pcall 来完成。
又或者,可以使用 redis.log 来记录日志。
这些 Lua 函数都是通过以下语句来注册:
/* Register the redis commands table and fields */ lua_newtable(lua); /* redis.call */ lua_pushstring(lua,”call”); lua_pushcfunction(lua,luaRedisCallCommand); lua_settable(lua,-3); /* redis.pcall */ lua_pushstring(lua,”pcall”); lua_pushcfunction(lua,luaRedisPCallCommand); lua_settable(lua,-3); /* redis.log and log levels. */ lua_pushstring(lua,”log”); lua_pushcfunction(lua,luaLogCommand); lua_settable(lua,-3); lua_pushstring(lua,”LOG_DEBUG”); lua_pushnumber(lua,REDIS_DEBUG); lua_settable(lua,-3); lua_pushstring(lua,”LOG_VERBOSE”); lua_pushnumber(lua,REDIS_VERBOSE); lua_settable(lua,-3); lua_pushstring(lua,”LOG_NOTICE”); lua_pushnumber(lua,REDIS_NOTICE); lua_settable(lua,-3); lua_pushstring(lua,”LOG_WARNING”); lua_pushnumber(lua,REDIS_WARNING); lua_settable(lua,-3); /* redis.sha1hex */ lua_pushstring(lua, “sha1hex”); lua_pushcfunction(lua, luaRedisSha1hexCommand); lua_settable(lua, -3); /* Finally set the table as ‘redis’ global var. */ lua_setglobal(lua,”redis”); |
6. 覆盖 math table 中的 random 和 randomseed 函数
为了创建无副作用的脚本,Redis 使用修改过的 random 函数和 randomseed 函数,覆盖了原有的 math 包中的 random 和 randomseed 函数:
/* Replace math.random and math.randomseed with our implementations. */ lua_getglobal(lua,”math”); lua_pushstring(lua,”random”); lua_pushcfunction(lua,redis_math_random); lua_settable(lua,-3); lua_pushstring(lua,”randomseed”); lua_pushcfunction(lua,redis_math_randomseed); lua_settable(lua,-3); lua_setglobal(lua,”math”); |
7. 创建辅助函数,用于排序
一些 Redis 命令,比如 SMEMBERS 和 KEYS ,返回的结果集是无序的。
在脚本功能中,这些命令被称为 non deterministic 命令。
为了避免这些 non deterministic 命令产生副作用(返回值结果无序), Redis 使用一个辅助函数,用于对 non deterministic 命令的结果集进行排序,从而确保返回值无副作用:
/* Add a helper funciton that we use to sort the multi bulk output of non * deterministic commands, when containing ‘false’ elements. */ { char *compare_func = “function __redis__compare_helper(a,b)n” ” if a == false then a = ” endn” ” if b == false then b = ” endn” ” return a ”endn”; luaL_loadbuffer(lua,compare_func,strlen(compare_func),”@cmp_func_def”); lua_pcall(lua,0,0,0); } |
8. 创建客户端
Redis 会创建一个客户端,用于处理 Lua 中执行的 Redis 命令。
这个客户端无须链接(connect)到服务器,因为它本身已经运行在服务器上了:
/* Create the (non connected) client that we use to execute Redis commands * inside the Lua interpreter. * Note: there is no need to create it again when this function is called * by scriptingReset(). */ if (server.lua_client == NULL) { server.lua_client = createClient(-1); server.lua_client->flags |= REDIS_LUA_CLIENT; } |
另外需要提醒的一点是, Redis 从始到终都只是创建了一个 Lua 环境,以及一个 lua_client ,这就是一个 Redis 服务器端只能处理一个脚本的原因。
9. 对全局变量进行保护,避免遭到有意或无意的覆盖
scriptingEnableGlobalsProtection(lua); |
10. 将 Lua 环境设置给 Redis
server.lua = lua; |
完成以上 10 个步骤之后,一个完整的 Lua 环境就被创建并且设置好了。
接下来,可以开始研究 EVAL 和 EVALSHA 这两个命令的实现,看看它们是如何配合 Lua 环境,一起完成对 Lua 脚本进行求值的任务的。
EVAL 和 EVALSHA 命令的实现
EVAL 和 EVALSHA 分别通过 evalCommand 和 evalShaCommand 函数实现,而这两个函数都由 evalGenericCommand 函数实际实现,只是接受的参数有所不同。
void evalCommand(redisClient *c) { evalGenericCommand(c,0); // evalsha 参数为 0 } void evalShaCommand(redisClient *c) { // 如果传给 EVALSHA 的 SHA1 值长度不对 // 那么直接返回 noscripterr 错误 if (sdslen(c->argv[1]->ptr) != 40) { /* We know that a match is not possible if the provided SHA is * not the right length. So we return an error ASAP, this way * evalGenericCommand() can be implemented without string length * sanity check */ addReply(c, shared.noscripterr); return; } evalGenericCommand(c,1); // evalsha 参数为 1 } |
evalGenericCommand 函数完成了对脚本进行求值的任务:
void evalGenericCommand(redisClient *c, int evalsha) { // … } |
以下是这个函数的一些主要工作:
1. 初始化 FLAG
Redis 不允许脚本功能在执行一个 non deterministic 命令之后再继续执行一个写入功能, 另外,为了让一个纯读取(read only)的脚本在不打扰一个写入脚本的情况下进行读取,提升并发性, Redis 使用了两个 FLAG 变量,用于检查所执行命令的属性。
在后面的相关函数实现里,会看见这两个 FLAG 的应用。
/* We set this flag to zero to remember that so far no random command * was called. This way we can allow the user to call commands like * SRANDMEMBER or RANDOMKEY from Lua scripts as far as no write command * is called (otherwise the replication and AOF would end with non * deterministic sequences). * * Thanks to this flag we’ll raise an error every time a write command * is called after a random command was used. */ // 初始化 FLAG server.lua_random_dirty = 0; server.lua_write_dirty = 0; |
2. 生成函数名
在 Lua 环境中,所有的脚本都被定义为一个函数,而每个函数都是以 f_ + 脚本 SHA1 校验和的格式存在的。
举个例子,脚本 return redis.call(‘get’,’foo’) 的校验和为6b1bf486c81ceb7edf3c093f4c48582e38c0e791 ,当这个脚本通过evalGenericCommand 函数执行的时候,这个脚本会被放进一个 Lua 函数的函数体内里,而这个函数的名字就叫做 f_6b1bf486c81ceb7edf3c093f4c48582e38c0e791 ,就像执行以下 Lua 代码一样:
function f_6b1bf486c81ceb7edf3c093f4c48582e38c0e791() return redis.call(‘get’, ‘foo’) end |
生成函数名的工作由以下代码完成:
/* We obtain the script SHA1, then check if this function is already * defined into the Lua state */ funcname[0] = ‘f’; funcname[1] = ‘_’; // 如果被调用的命令是 EVAL ,那么根据脚本产生一个 SHA1 值 if (!evalsha) { /* Hash the code if this is an EVAL call */ sha1hex(funcname+2,c->argv[1]->ptr,sdslen(c->argv[1]->ptr)); // 如果被调用的命令是 EVALSHA ,那么直接使用参数中的 SHA1 值 } else { /* We already have the SHA if it is a EVALSHA */ int j; char *sha = c->argv[1]->ptr; for (j = 0; j < 40; j++) funcname[j+2] = tolower(sha[j]); funcname[42] = ‘ |