鍍金池/ 教程/ 大數(shù)據(jù)/ Redis 與 Lua 腳本
Redis 數(shù)據(jù)淘汰機(jī)制
積分排行榜
小剖 Memcache
Redis 數(shù)據(jù)結(jié)構(gòu) intset
分布式鎖
從哪里開始讀起,怎么讀
Redis 數(shù)據(jù)結(jié)構(gòu) dict
不在浮沙筑高臺(tái)
Redis 集群(上)
Redis 監(jiān)視器
源碼閱讀工具
Redis 日志和斷言
內(nèi)存數(shù)據(jù)管理
Redis 數(shù)據(jù)結(jié)構(gòu)綜述
源碼日志
Web 服務(wù)器存儲(chǔ) session
消息中間件
Redis 與 Lua 腳本
什么樣的源代碼適合閱讀
Redis 數(shù)據(jù)結(jié)構(gòu) sds
Memcached slab 分配策略
訂閱發(fā)布機(jī)制
Redis 是如何提供服務(wù)的
Redis 事務(wù)機(jī)制
Redis 集群(下)
主從復(fù)制
Redis 應(yīng)用
RDB 持久化策略
Redis 數(shù)據(jù)遷移
Redis 事件驅(qū)動(dòng)詳解
初探 Redis
Redis 與 Memcache
AOF 持久化策略
Redis 數(shù)據(jù)結(jié)構(gòu) redisOb
作者簡(jiǎn)介
Redis 數(shù)據(jù)結(jié)構(gòu) ziplist
Redis 數(shù)據(jù)結(jié)構(gòu) skiplist
Redis 哨兵機(jī)制

Redis 與 Lua 腳本

這篇文章,主要是講 Redis 和 Lua 是如何協(xié)同工作的以及 Redis 如何管理 Lua 腳本。

Lua 簡(jiǎn)介

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="" />

Redis 為什么添加 Lua 支持

從上所說(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 更為靈活。

Lua 環(huán)境的初始化

在 Redis 服務(wù)器初始化函數(shù) scriptingInit() 中,初始化了 Lua 的環(huán)境。

  • 加載了常用的 Lua 庫(kù),方便在 Lua 腳本中調(diào)用
  • 創(chuàng)建 SHA1->lua_script 哈希表,可見 Redis 會(huì)保存客戶端執(zhí)行過(guò)的 Lua 腳本

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="" />

  • 注冊(cè) Redis 的一些處理函數(shù),譬如命令處理函數(shù),日志函數(shù)。注冊(cè)過(guò)的函數(shù),可以在 lua 腳本中調(diào)用
  • 替換已經(jīng)加載的某些庫(kù)的函數(shù)
  • 創(chuàng)建虛擬客戶端(fake client)。和 AOF,RDB 數(shù)據(jù)恢復(fù)的做法一樣,是為了復(fù)用命令處理函數(shù)

重點(diǎn)展開第三、五點(diǎn)。

Lua 腳本執(zhí)行 Redis 命令

要在lua 腳本中調(diào)用c 函數(shù),會(huì)有以下幾個(gè)步驟:

  1. 定義下面的函數(shù):typedef int (*lua_CFunction) (lua_State *L);
  2. 為函數(shù)取一個(gè)名字,并入棧
  3. 調(diào)用 lua_pushcfunction() 將函數(shù)指針入棧
  4. 關(guān)聯(lián)步驟 2 中的函數(shù)名和步驟 3 的函數(shù)指針

在 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ì)完成:

  1. 檢測(cè)參數(shù)的有效性,并通過(guò) lua api 提取參數(shù)
  2. 向虛擬客戶端 server.lua_client 填充參數(shù)
  3. 查找命令
  4. 執(zhí)行命令
  5. 處理命令處理結(jié)果

fake client 的好處又一次體現(xiàn)出來(lái)了,這和 AOF 的恢復(fù)數(shù)據(jù)過(guò)程如出一轍。在 lua 腳本處理期間,Redis 服務(wù)器只服務(wù)于 fake client。

Redis Lua 腳本的執(zhí)行過(guò)程

我們依舊從客戶端發(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="" />

Lua 腳本的傳播

如上所說(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);
    }
  }
}

總結(jié)

Redis 服務(wù)器的工作模式是單進(jìn)程單線程,因?yàn)殚_發(fā)人員在寫 Lua 腳本的時(shí)候應(yīng)該特別注意時(shí)間復(fù)雜度的問(wèn)題,不要讓 Lua 腳本影響整個(gè) Redis 服務(wù)器的性能。