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 函数,会有以下几个步骤:
- 定义下面的函数:
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 服务器的性能。