您的位置:首页 > 数据库 > Redis

Redis 与 Lua 脚本

2016-09-01 16:29 309 查看
这篇文章,主要是讲 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 函数,会有以下几个步骤:

定义下面的函数:
typedef int (*lua_CFunction) (lua_State *L)
;

为函数取一个名字,并入栈

调用 lua_pushcfunction() 将函数指针入栈

关联步骤 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() 为例,当它被回调的时候会完成:

检测参数的有效性,并通过 lua api 提取参数

向虚拟客户端 server.lua_client 填充参数

查找命令

执行命令

处理命令处理结果
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 服务器的性能。

FROM: http://wiki.jikexueyuan.com/project/redis/lua.html
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: