這篇文章,主要是講 Redis 和 Lua 是如何協(xié)同工作的以及 Redis 如何管理 Lua 腳本。
Lua 以可嵌入,輕量,高效,提升靜態(tài)語(yǔ)言的靈活性,有了 Lua,方便對(duì)程序進(jìn)行改動(dòng)或拓展,減少編譯的次數(shù),在游戲開發(fā)中特別常見。舉一個(gè)在 C 語(yǔ)言中調(diào)用 Lua 腳本的例子:
//這是 Lua 所需的三個(gè)頭文件
//當(dāng)然,你需要鏈接到正確的 lib
extern "C"
{
#include "lua.h"
#include "lauxlib.h"
#include "lualib.h"
}
int main(int argc, char *argv[])
{
lua_State *L = lua_open();
// 此處記住,當(dāng)你使用的是 5.1 版本以上的 Lua 時(shí),請(qǐng)修改以下兩句為
// luaL_openlibs(L);
luaopen_base(L);
luaopen_io(L);
// 記住, 當(dāng)你使用的是 5.1 版本以上的 Lua 時(shí)請(qǐng)使用 luaL_dostring(L,buf);
lua_dofile("script.lua");
lua_close(L);
return 0;
}
lua_dofile(”script.lua”); 這一句能為我們提供無(wú)限的遐想,開發(fā)人員可以在 script.lua 腳本文件中實(shí)現(xiàn)程序邏輯,而不需要重新編譯 main.cpp 文件。在上面給出的例子中,c 語(yǔ)言執(zhí)行了 lua 腳本。不僅如此,我們也可以將c 函數(shù)注冊(cè)到 lua 解釋器中,從而在 lua 腳本中,調(diào)用 c 函數(shù)。
http://wiki.jikexueyuan.com/project/redis/images/r8.png" alt="" />
從上所說(shuō),lua 為靜態(tài)語(yǔ)言提供更多的靈活性,redis lua 腳本出現(xiàn)之前 Redis 是沒(méi)有服務(wù)器端運(yùn)算能力的,主要是用來(lái)存儲(chǔ),用做緩存,運(yùn)算是在客戶端進(jìn)行,這里有兩個(gè)缺點(diǎn):一、如此會(huì)破壞數(shù)據(jù)的一致性,試想如果兩個(gè)客戶端先后獲取(get)一個(gè)值,它們分別對(duì)鍵值做不同的修改,然后先后提交結(jié)果,最終 Redis 服務(wù)器中的結(jié)果肯定不是某一方客戶端所預(yù)期的。二、浪費(fèi)了數(shù)據(jù)傳輸?shù)木W(wǎng)絡(luò)帶寬。
lua 出現(xiàn)之后這一問(wèn)題得到了充分的解決,非常棒!有了 Lua 的支持,客戶端可以定義對(duì)鍵值的運(yùn)算??傊?,可以讓 Redis 更為靈活。
在 Redis 服務(wù)器初始化函數(shù) scriptingInit() 中,初始化了 Lua 的環(huán)境。
SHA1 是安全散列算法產(chǎn)生的一個(gè)固定長(zhǎng)度的序列,你可以把它理解為一個(gè)鍵值??梢?Redis 服務(wù)器會(huì)保存客戶端執(zhí)行過(guò)的 Lua 腳本。這在一個(gè) Lua 腳本需要被經(jīng)常執(zhí)行的時(shí)候是非常有用的。試想,客戶端只需要給定一個(gè) SHA1 序列就可以執(zhí)行相應(yīng)的 Lua 腳本了。事實(shí)上,EVLASHA 命令就是這么工作的。
http://wiki.jikexueyuan.com/project/redis/images/r9.png" alt="" />
重點(diǎn)展開第三、五點(diǎn)。
要在lua 腳本中調(diào)用c 函數(shù),會(huì)有以下幾個(gè)步驟:
typedef int (*lua_CFunction) (lua_State *L)
;在 Redis 初始化的時(shí)候,會(huì)將 luaRedisPCallCommand(), luaRedisPCallCommand() 兩個(gè)函數(shù)入棧:
void scriptingInit(void) {
......
// 向lua 解釋器注冊(cè)redis 的數(shù)據(jù)或者變量
/* Register the redis commands table and fields */
lua_newtable(lua);
// 注冊(cè)redis.call 函數(shù),命令處理函數(shù)
/* redis.call */
// 將"call" 入棧,作為key
lua_pushstring(lua,"call");
// 將luaRedisPCallCommand() 函數(shù)指針入棧,作為value
lua_pushcfunction(lua,luaRedisCallCommand);
// 彈出"call",luaRedisPCallCommand() 函數(shù)指針,即key-value,
// 并在table 中設(shè)置key-values
lua_settable(lua,-3);
// 注冊(cè)redis.pall 函數(shù),命令處理函數(shù)
/* redis.pcall */
// 將"pcall" 入棧,作為key
lua_pushstring(lua,"pcall");
// 將luaRedisPCallCommand() 函數(shù)指針入棧,作為value
lua_pushcfunction(lua,luaRedisPCallCommand);
// 彈出"pcall",luaRedisPCallCommand() 函數(shù)指針,即key-value,
// 并在table 中設(shè)置key-values
lua_settable(lua,-3);
......
}
經(jīng)注冊(cè)后,開發(fā)人員可在 Lua 腳本中調(diào)用這兩個(gè)函數(shù),從而在 Lua 腳本也可以執(zhí)行 Redis 命令,譬如在腳本刪除某個(gè)鍵值對(duì)。以 luaRedisCallCommand() 為例,當(dāng)它被回調(diào)的時(shí)候會(huì)完成:
fake client 的好處又一次體現(xiàn)出來(lái)了,這和 AOF 的恢復(fù)數(shù)據(jù)過(guò)程如出一轍。在 lua 腳本處理期間,Redis 服務(wù)器只服務(wù)于 fake client。
我們依舊從客戶端發(fā)送一個(gè) lua 相關(guān)命令開始。假定用戶發(fā)送了 EVAL 命令如下:
eval 1 "set KEY[1] ARGV[1]" views 18000
此命令的意圖是,將 views 的值設(shè)置為 18000。Redis 服務(wù)器收到此命令后,會(huì)調(diào)用對(duì)應(yīng)的命令處理函數(shù)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;
// 隨機(jī)數(shù)的種子,在產(chǎn)生哈希值的時(shí)候會(huì)用到
redisSrand48(0);
// 關(guān)于臟命令的標(biāo)記
server.lua_random_dirty = 0;
server.lua_write_dirty = 0;
// 檢查參數(shù)的有效性
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;
}
// 函數(shù)名以f_ 開頭
funcname[0] = 'f';
funcname[1] = '_';
// 如果沒(méi)有哈希值,需要計(jì)算lua 腳本的哈希值
if (!evalsha) {
// 計(jì)算哈希值,會(huì)放入到SHA1 -> lua_script 哈希表中
// c->argv[1]->ptr 是用戶指定的lua 腳本
// sha1hex() 產(chǎn)生的哈希值存在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';
}
// 將錯(cuò)誤處理函數(shù)入棧
// lua_getglobal() 會(huì)將讀取指定的全局變量,且將其入棧
lua_getglobal(lua, "__redis__err__handler");
/* Try to lookup the Lua function */
// 在lua 中查找是否注冊(cè)了此函數(shù)。這一句嘗試將funcname 入棧
lua_getglobal(lua, funcname);
if (lua_isnil(lua,-1)) { // funcname 在lua 中不存在
// 將nil 出棧
lua_pop(lua,1); /* remove the nil from the stack */
// 已經(jīng)確定funcname 在lua 中沒(méi)有定義,需要?jiǎng)?chuàng)建
if (evalsha) {
lua_pop(lua,1); /* remove the error handler from the stack. */
addReply(c, shared.noscripterr);
return;
}
// 創(chuàng)建lua 函數(shù)funcname
// c->argv[1] 指向用戶指定的lua 腳本
if (luaCreateFunction(c,lua,funcname,c->argv[1]) == REDIS_ERR) {
lua_pop(lua,1);
return;
}
// 現(xiàn)在lua 中已經(jīng)有funcname 這個(gè)全局變量了,將其讀取并入棧,
// 準(zhǔn)備調(diào)用
lua_getglobal(lua, funcname);
redisAssert(!lua_isnil(lua,-1));
}
// 設(shè)置參數(shù),包括鍵和值
luaSetGlobalArray(lua,"KEYS",c->argv+3,numkeys);
luaSetGlobalArray(lua,"ARGV",c->argv+3+numkeys,c->argc-3-numkeys);
// 選擇數(shù)據(jù)集,lua_client 有專用的數(shù)據(jù)集
/* Select the right DB in the context of the Lua client */
selectDb(server.lua_client,c->db->id);
// 設(shè)置超時(shí)回調(diào)函數(shù),以在lua 腳本執(zhí)行過(guò)長(zhǎng)時(shí)間的時(shí)候停止腳本的運(yùn)行
server.lua_caller = c;
server.lua_time_start = ustime()/1000;
server.lua_kill = 0;
if (server.lua_time_limit > 0 && server.masterhost == NULL) {
// 當(dāng)lua 解釋器執(zhí)行了100000,luaMaskCountHook() 會(huì)被調(diào)用
lua_sethook(lua,luaMaskCountHook,LUA_MASKCOUNT,100000);
delhook = 1;
}
// 現(xiàn)在,我們確定函數(shù)已經(jīng)注冊(cè)成功了. 可以直接調(diào)用lua 腳本
err = lua_pcall(lua,0,1,-2);
// 刪除超時(shí)回調(diào)函數(shù)
if (delhook) lua_sethook(lua,luaMaskCountHook,0,0); /* Disable hook */
// 如果已經(jīng)超時(shí)了,說(shuō)明lua 腳本已在超時(shí)后背SCRPIT KILL 終結(jié)了
// 恢復(fù)監(jiān)聽發(fā)送lua 腳本命令的客戶端
if (server.lua_timedout) {
server.lua_timedout = 0;
aeCreateFileEvent(server.el,c->fd,AE_READABLE,
readQueryFromClient,c);
}
// lua_caller 置空
server.lua_caller = NULL;
// 執(zhí)行l(wèi)ua 腳本用的是lua 腳本執(zhí)行專用的數(shù)據(jù)集?,F(xiàn)在恢復(fù)原有的數(shù)據(jù)集
selectDb(c,server.lua_client->db->id); /* set DB ID from Lua client */
// Garbage collection 垃圾回收
lua_gc(lua,LUA_GCSTEP,1);
// 處理執(zhí)行l(wèi)ua 腳本的錯(cuò)誤
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 腳本發(fā)布到主從復(fù)制上,并寫入AOF 文件
......
}
對(duì)應(yīng) lua 腳本的執(zhí)行流程圖:
http://wiki.jikexueyuan.com/project/redis/images/r10.png" alt="" />
在解釋臟命令之前,先交代一點(diǎn)。
Redis 服務(wù)器執(zhí)行的 Lua 腳本和普通的命令一樣,都是會(huì)寫入 AOF 文件和發(fā)布至主從復(fù)制連接上的。以主從復(fù)制為例,將 Lua 腳本中發(fā)生的數(shù)據(jù)變更發(fā)布到從機(jī)上,有兩種方法。一,和普通的命令一樣,只要涉及寫的操作,都發(fā)布到從機(jī)上;二、直接將 Lua 腳本發(fā)送給從機(jī)。實(shí)際上,兩種方法都可以的,數(shù)據(jù)變更都能得到傳播,但首先,第一種方法中普通命令會(huì)被轉(zhuǎn)化為 Redis 通信協(xié)議的格式,和 Lua 腳本文本大小比較起來(lái),會(huì)浪費(fèi)更多的帶寬;其次,第一種方法也會(huì)浪費(fèi)較多的 CPU 的資源,因?yàn)閺臋C(jī)收到了 Redis 通信協(xié)議的格式的命令后,還需要轉(zhuǎn)換為普通的命令,然后才是執(zhí)行,這比純粹的執(zhí)行 lua 腳本,會(huì)浪費(fèi)更多的 CPU 資源。明顯,第二種方法是更好的。這一點(diǎn) Redis 做的比較細(xì)致。
上面的結(jié)果是,直接將 Lua 腳本發(fā)送給從機(jī)。但這會(huì)產(chǎn)生一個(gè)問(wèn)題。舉例一個(gè) Lua 腳本:
-- lua scrpit
local some_key
some_key = redis.call('RANDOMKEY') -- <--- TODO nil
redis.call('set',some_key,'123')
上面腳本想要做的是,從 Redis 服務(wù)器中隨機(jī)選取一個(gè)鍵,將其值設(shè)置為 123。從 RANDOMKEY 命令的命令處理函數(shù)來(lái)看,其調(diào)用了 random() 函數(shù),如此一來(lái)問(wèn)題就來(lái)了:當(dāng) lua 腳本被發(fā)布到不同的從機(jī)上時(shí),random() 調(diào)用返回的結(jié)果是不同的,因此主從機(jī)的數(shù)據(jù)就不一致了。
因此在 Redis 服務(wù)器配置選項(xiàng)目設(shè)置了兩個(gè)變量來(lái)解決這個(gè)問(wèn)題:
// 在lua 腳本中發(fā)生了寫操作
int lua_write_dirty; /* True if a write command was called during the
execution of the current script. */
// 在lua 腳本發(fā)生了未決的操作,譬如RANDOMKEY 命令操作
int lua_random_dirty; /* True if a random command was called during the
execution of the current script. */
在執(zhí)行 Lua 腳本之前,這兩個(gè)參數(shù)會(huì)被置零。在執(zhí)行 Lua 腳本中,執(zhí)行命令操作之前,Redis 會(huì)檢測(cè)寫操作之前是否執(zhí)行了 RANDOMKEY 命令,是則會(huì)禁止接下來(lái)的寫操作,因?yàn)槲礇Q的操作會(huì)被傳播到從機(jī)上;否則會(huì)嘗試更新上面兩個(gè)變量,如果發(fā)現(xiàn)寫操作 lua_write_dirty = 1;如果發(fā)現(xiàn)未決操作,lua_random_dirty = 1。對(duì)于這段話的表述,有下面的流程圖,大家也可以翻閱 luaRedisGenericCommand() 這個(gè)函數(shù):
http://wiki.jikexueyuan.com/project/redis/images/r11.png" alt="" />
如上所說(shuō),需要傳播 Lua 腳本中的數(shù)據(jù)變更,Redis 的做法是直接將 lua 腳本發(fā)送給從機(jī)和寫入 AOF 文件的。
Redis 的做法是,修改執(zhí)行 Lua 腳本客戶端的參數(shù)為“EVAL”和相應(yīng)的lua 腳本文本,至于發(fā)送到從機(jī)和寫入 AOF 文件,交由主從復(fù)制機(jī)制和 AOF 持久化機(jī)制來(lái)完成。下面摘一段代碼:
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);
// 添加到主從復(fù)制專用的腳本緩存中
replicationScriptCacheAdd(c->argv[1]->ptr);
redisAssertWithInfo(c,NULL,script != NULL);
// 重寫命令
// 參數(shù)1 為:EVAL
// 參數(shù)2 為:lua_script
// 如此一來(lái)在執(zhí)行AOF 持久化和主從復(fù)制的時(shí)候,lua 腳本就能得到傳播
rewriteClientCommandArgument(c,0,
resetRefCount(createStringObject("EVAL",4)));
rewriteClientCommandArgument(c,1,script);
}
}
}
Redis 服務(wù)器的工作模式是單進(jìn)程單線程,因?yàn)殚_發(fā)人員在寫 Lua 腳本的時(shí)候應(yīng)該特別注意時(shí)間復(fù)雜度的問(wèn)題,不要讓 Lua 腳本影響整個(gè) Redis 服務(wù)器的性能。