返回首页 Redis 源码日志

源码日志

Redis 服务框架

Redis 基础数据结构

Redis 内功心法

Redis 应用

其他

Redis 与 Lua 脚本

这篇文章,主要是讲 Redis 和 Lua 是如何协同工作的以及 Redis 如何管理 Lua 脚本。

Lua 简介

Lua 以可嵌入,轻量,高效,提升静态语言的灵活性,有了 Lua,方便对程序进行改动或拓展,减少编译的次数,在游戏开发中特别常见。举一个在 C 语言中调用 Lua 脚本的例子:

//这是 Lua 所需的三个头文件
//当然,你需要链接到正确的 lib
extern "C"
{
    #include "lua.h"
    #include "lauxlib.h"
    #include "lualib.h"
}
int main(int argc, char *argv[])
{
    lua_State *L = lua_open();
    // 此处记住,当你使用的是 5.1 版本以上的 Lua 时,请修改以下两句为
    // luaL_openlibs(L);
    luaopen_base(L);
    luaopen_io(L);
    // 记住, 当你使用的是 5.1 版本以上的 Lua 时请使用 luaL_dostring(L,buf);
    lua_dofile("script.lua");
    lua_close(L);
    return 0;
}

lua_dofile(”script.lua”); 这一句能为我们提供无限的遐想,开发人员可以在 script.lua 脚本文件中实现程序逻辑,而不需要重新编译 main.cpp 文件。在上面给出的例子中,c 语言执行了 lua 脚本。不仅如此,我们也可以将c 函数注册到 lua 解释器中,从而在 lua 脚本中,调用 c 函数。

Redis 为什么添加 Lua 支持

从上所说,lua 为静态语言提供更多的灵活性,redis lua 脚本出现之前 Redis 是没有服务器端运算能力的,主要是用来存储,用做缓存,运算是在客户端进行,这里有两个缺点:一、如此会破坏数据的一致性,试想如果两个客户端先后获取(get)一个值,它们分别对键值做不同的修改,然后先后提交结果,最终 Redis 服务器中的结果肯定不是某一方客户端所预期的。二、浪费了数据传输的网络带宽。

lua 出现之后这一问题得到了充分的解决,非常棒!有了 Lua 的支持,客户端可以定义对键值的运算。总之,可以让 Redis 更为灵活。

Lua 环境的初始化

在 Redis 服务器初始化函数 scriptingInit() 中,初始化了 Lua 的环境。

  • 加载了常用的 Lua 库,方便在 Lua 脚本中调用
  • 创建 SHA1->lua_script 哈希表,可见 Redis 会保存客户端执行过的 Lua 脚本

SHA1 是安全散列算法产生的一个固定长度的序列,你可以把它理解为一个键值。可见 Redis 服务器会保存客户端执行过的 Lua 脚本。这在一个 Lua 脚本需要被经常执行的时候是非常有用的。试想,客户端只需要给定一个 SHA1 序列就可以执行相应的 Lua 脚本了。事实上,EVLASHA 命令就是这么工作的。

  • 注册 Redis 的一些处理函数,譬如命令处理函数,日志函数。注册过的函数,可以在 lua 脚本中调用
  • 替换已经加载的某些库的函数
  • 创建虚拟客户端(fake client)。和 AOF,RDB 数据恢复的做法一样,是为了复用命令处理函数

重点展开第三、五点。

Lua 脚本执行 Redis 命令

要在lua 脚本中调用c 函数,会有以下几个步骤:

  1. 定义下面的函数:typedef int (*lua_CFunction) (lua_State *L);
  2. 为函数取一个名字,并入栈
  3. 调用 lua_pushcfunction() 将函数指针入栈
  4. 关联步骤 2 中的函数名和步骤 3 的函数指针

在 Redis 初始化的时候,会将 luaRedisPCallCommand(), luaRedisPCallCommand() 两个函数入栈:

void scriptingInit(void) {
    ......
    // 向lua 解释器注册redis 的数据或者变量
    /* Register the redis commands table and fields */
    lua_newtable(lua);
    // 注册redis.call 函数,命令处理函数
    /* redis.call */
    // 将"call" 入栈,作为key
    lua_pushstring(lua,"call");
    // 将luaRedisPCallCommand() 函数指针入栈,作为value
    lua_pushcfunction(lua,luaRedisCallCommand);
    // 弹出"call",luaRedisPCallCommand() 函数指针,即key-value,
    // 并在table 中设置key-values
    lua_settable(lua,-3);
    // 注册redis.pall 函数,命令处理函数
    /* redis.pcall */
    // 将"pcall" 入栈,作为key
    lua_pushstring(lua,"pcall");
    // 将luaRedisPCallCommand() 函数指针入栈,作为value
    lua_pushcfunction(lua,luaRedisPCallCommand);
    // 弹出"pcall",luaRedisPCallCommand() 函数指针,即key-value,
    // 并在table 中设置key-values
    lua_settable(lua,-3);
    ......
}

经注册后,开发人员可在 Lua 脚本中调用这两个函数,从而在 Lua 脚本也可以执行 Redis 命令,譬如在脚本删除某个键值对。以 luaRedisCallCommand() 为例,当它被回调的时候会完成:

  1. 检测参数的有效性,并通过 lua api 提取参数
  2. 向虚拟客户端 server.lua_client 填充参数
  3. 查找命令
  4. 执行命令
  5. 处理命令处理结果

fake client 的好处又一次体现出来了,这和 AOF 的恢复数据过程如出一辙。在 lua 脚本处理期间,Redis 服务器只服务于 fake client。

Redis Lua 脚本的执行过程

我们依旧从客户端发送一个 lua 相关命令开始。假定用户发送了 EVAL 命令如下:

eval 1 "set KEY[1] ARGV[1]" views 18000

此命令的意图是,将 views 的值设置为 18000。Redis 服务器收到此命令后,会调用对应的命令处理函数evalCommand() 如下:

void evalCommand(redisClient *c) {
    evalGenericCommand(c,0);
}
void evalGenericCommand(redisClient *c, int evalsha) {
    lua_State *lua = server.lua;
    char funcname[43];
    long long numkeys;
    int delhook = 0, err;
    // 随机数的种子,在产生哈希值的时候会用到
    redisSrand48(0);
    // 关于脏命令的标记
    server.lua_random_dirty = 0;
    server.lua_write_dirty = 0;
    // 检查参数的有效性
    if (getLongLongFromObjectOrReply(c,c->argv[2],&numkeys,NULL) != REDIS_OK)
        return;
    if (numkeys > (c->argc - 3)) {
        addReplyError(c,"Number of keys can't be greater than number of args");
        return;
    }
    // 函数名以f_ 开头
    funcname[0] = 'f';
    funcname[1] = '_';
    // 如果没有哈希值,需要计算lua 脚本的哈希值
    if (!evalsha) {
        // 计算哈希值,会放入到SHA1 -> lua_script 哈希表中
        // c->argv[1]->ptr 是用户指定的lua 脚本
        // sha1hex() 产生的哈希值存在funcname 中
        sha1hex(funcname+2,c->argv[1]->ptr,sdslen(c->argv[1]->ptr));
    } else {
        // 用户自己指定了哈希值
        int j;
        char *sha = c->argv[1]->ptr;
    for (j = 0; j < 40; j++)
        funcname[j+2] = tolower(sha[j]);
        funcname[42] = '\0';
    }
    // 将错误处理函数入栈
    // lua_getglobal() 会将读取指定的全局变量,且将其入栈
    lua_getglobal(lua, "__redis__err__handler");
    /* Try to lookup the Lua function */
    // 在lua 中查找是否注册了此函数。这一句尝试将funcname 入栈
    lua_getglobal(lua, funcname);
    if (lua_isnil(lua,-1)) { // funcname 在lua 中不存在
        // 将nil 出栈
        lua_pop(lua,1); /* remove the nil from the stack */
        // 已经确定funcname 在lua 中没有定义,需要创建
    if (evalsha) {
        lua_pop(lua,1); /* remove the error handler from the stack. */
        addReply(c, shared.noscripterr);
        return;
    }
    // 创建lua 函数funcname
    // c->argv[1] 指向用户指定的lua 脚本
    if (luaCreateFunction(c,lua,funcname,c->argv[1]) == REDIS_ERR) {
        lua_pop(lua,1);
        return;
    }
    // 现在lua 中已经有funcname 这个全局变量了,将其读取并入栈,
    // 准备调用
    lua_getglobal(lua, funcname);
    redisAssert(!lua_isnil(lua,-1));
    }
    // 设置参数,包括键和值
    luaSetGlobalArray(lua,"KEYS",c->argv+3,numkeys);
    luaSetGlobalArray(lua,"ARGV",c->argv+3+numkeys,c->argc-3-numkeys);
    // 选择数据集,lua_client 有专用的数据集
    /* Select the right DB in the context of the Lua client */
    selectDb(server.lua_client,c->db->id);
    // 设置超时回调函数,以在lua 脚本执行过长时间的时候停止脚本的运行
    server.lua_caller = c;
    server.lua_time_start = ustime()/1000;
    server.lua_kill = 0;
    if (server.lua_time_limit > 0 && server.masterhost == NULL) {
        // 当lua 解释器执行了100000,luaMaskCountHook() 会被调用
        lua_sethook(lua,luaMaskCountHook,LUA_MASKCOUNT,100000);
        delhook = 1;
    }
    // 现在,我们确定函数已经注册成功了. 可以直接调用lua 脚本
    err = lua_pcall(lua,0,1,-2);
    // 删除超时回调函数
    if (delhook) lua_sethook(lua,luaMaskCountHook,0,0); /* Disable hook */
        // 如果已经超时了,说明lua 脚本已在超时后背SCRPIT KILL 终结了
        // 恢复监听发送lua 脚本命令的客户端
    if (server.lua_timedout) {
        server.lua_timedout = 0;
        aeCreateFileEvent(server.el,c->fd,AE_READABLE,
        readQueryFromClient,c);
    }
    // lua_caller 置空
    server.lua_caller = NULL;
    // 执行lua 脚本用的是lua 脚本执行专用的数据集。现在恢复原有的数据集
    selectDb(c,server.lua_client->db->id); /* set DB ID from Lua client */
    // Garbage collection 垃圾回收
    lua_gc(lua,LUA_GCSTEP,1);
    // 处理执行lua 脚本的错误
    if (err) {
        // 告知客户端
        addReplyErrorFormat(c,"Error running script (call to %s): %s\n",
        funcname, lua_tostring(lua,-1));
        lua_pop(lua,2); /* Consume the Lua reply and remove error handler. */
    // 成功了
    } else {
    /* On success convert the Lua return value into Redis protocol, and
    * send it to * the client. */
    luaReplyToRedisReply(c,lua); /* Convert and consume the reply. */
    lua_pop(lua,1); /* Remove the error handler. */
    }
    // 将lua 脚本发布到主从复制上,并写入AOF 文件
    ......
}

对应 lua 脚本的执行流程图:

脏命令

在解释脏命令之前,先交代一点。

Redis 服务器执行的 Lua 脚本和普通的命令一样,都是会写入 AOF 文件和发布至主从复制连接上的。以主从复制为例,将 Lua 脚本中发生的数据变更发布到从机上,有两种方法。一,和普通的命令一样,只要涉及写的操作,都发布到从机上;二、直接将 Lua 脚本发送给从机。实际上,两种方法都可以的,数据变更都能得到传播,但首先,第一种方法中普通命令会被转化为 Redis 通信协议的格式,和 Lua 脚本文本大小比较起来,会浪费更多的带宽;其次,第一种方法也会浪费较多的 CPU 的资源,因为从机收到了 Redis 通信协议的格式的命令后,还需要转换为普通的命令,然后才是执行,这比纯粹的执行 lua 脚本,会浪费更多的 CPU 资源。明显,第二种方法是更好的。这一点 Redis 做的比较细致。

上面的结果是,直接将 Lua 脚本发送给从机。但这会产生一个问题。举例一个 Lua 脚本:

-- lua scrpit
local some_key
some_key = redis.call('RANDOMKEY') -- <--- TODO nil
redis.call('set',some_key,'123')

上面脚本想要做的是,从 Redis 服务器中随机选取一个键,将其值设置为 123。从 RANDOMKEY 命令的命令处理函数来看,其调用了 random() 函数,如此一来问题就来了:当 lua 脚本被发布到不同的从机上时,random() 调用返回的结果是不同的,因此主从机的数据就不一致了。

因此在 Redis 服务器配置选项目设置了两个变量来解决这个问题:

// 在lua 脚本中发生了写操作
int lua_write_dirty; /* True if a write command was called during the
execution of the current script. */
// 在lua 脚本发生了未决的操作,譬如RANDOMKEY 命令操作
int lua_random_dirty; /* True if a random command was called during the
execution of the current script. */

在执行 Lua 脚本之前,这两个参数会被置零。在执行 Lua 脚本中,执行命令操作之前,Redis 会检测写操作之前是否执行了 RANDOMKEY 命令,是则会禁止接下来的写操作,因为未决的操作会被传播到从机上;否则会尝试更新上面两个变量,如果发现写操作 lua_write_dirty = 1;如果发现未决操作,lua_random_dirty = 1。对于这段话的表述,有下面的流程图,大家也可以翻阅 luaRedisGenericCommand() 这个函数:

Lua 脚本的传播

如上所说,需要传播 Lua 脚本中的数据变更,Redis 的做法是直接将 lua 脚本发送给从机和写入 AOF 文件的。

Redis 的做法是,修改执行 Lua 脚本客户端的参数为“EVAL”和相应的lua 脚本文本,至于发送到从机和写入 AOF 文件,交由主从复制机制和 AOF 持久化机制来完成。下面摘一段代码:

void evalGenericCommand(redisClient *c, int evalsha) {
    ......
    if (evalsha) {
    if (!replicationScriptCacheExists(c->argv[1]->ptr)) {
    /* This script is not in our script cache, replicate it as
    * EVAL, then add it into the script cache, as from now on
    * slaves and AOF know about it. */
    // 从server.lua_scripts 获取lua 脚本
    // c->argv[1]->ptr 是SHA1
    robj *script = dictFetchValue(server.lua_scripts,c->argv[1]->ptr);
    // 添加到主从复制专用的脚本缓存中
    replicationScriptCacheAdd(c->argv[1]->ptr);
    redisAssertWithInfo(c,NULL,script != NULL);
    // 重写命令
    // 参数1 为:EVAL
    // 参数2 为:lua_script
    // 如此一来在执行AOF 持久化和主从复制的时候,lua 脚本就能得到传播
    rewriteClientCommandArgument(c,0,
        resetRefCount(createStringObject("EVAL",4)));
    rewriteClientCommandArgument(c,1,script);
    }
  }
}

总结

Redis 服务器的工作模式是单进程单线程,因为开发人员在写 Lua 脚本的时候应该特别注意时间复杂度的问题,不要让 Lua 脚本影响整个 Redis 服务器的性能。

上一篇: Redis 事务机制 下一篇: Redis 哨兵机制