redis原始碼學習之lua執行原理

踩刀詩人發表於2020-12-22

聊聊redis執行lua原理

 

 

從一次面試場景說起

 

“看你簡歷上寫的精通redis”

“額,還可以啦”

“那你說說redis執行lua指令碼的原理”

“這個,這個,不就是那麼執行的嗎,eval 一段lua指令碼就行了”

“好的,瞭解了,今天面試先到這個吧,後續有訊息會通知你”

“好的,祝您生活愉快”

 

 

 

面試場景純屬娛樂,但這個面試題確實是筆者真實遇到過的,今天我們就來看看redis執行lua指令碼的原理,希望通過本篇學習可以解決心中的困惑,更深層次的講可以瞭解到兩種不同語言溝通的一點思想,我覺得這個是最寶貴的。

 

名詞解釋

redis:一個高效能的k,v資料庫,基於C語言編寫;

lua:一種輕量小巧的指令碼語言,用標準C語言編寫並以原始碼形式開放, 其設計目的是為了嵌入應用程式中,從而為應用程式提供靈活的擴充套件和定製功能。

 

需求緣起

首先說說什麼場景下需要用到lua指令碼,當你想一次執行一批redis指令而且又不希望中途被其他指令打斷的時候,也許有人說pipeline不香嗎?是的,pipeline也是一種提高效能的方法,但是它自身有兩個特點在某些場景下是無法替代lua指令碼的,其一:pipeline的執行是無法保證原子性的;其二:pipeline多條指令之間是無法共享上下文的,這個怎麼理解呢,比如pipeline中包括A,B兩條指令,如果B指令需要依賴A指令的執行結果,這時是無法獲取到的,舉個簡單例子如下:

判斷key1是否等於value1,如果等於就刪除key1,否則什麼都不做。

按正常思維這個程式碼很簡單,兩行程式碼搞定

if "value1".equals(jedis.get("key1") { //@1
  jedis.del("key1")                  //@2
}

  

但是老司機一看就會說這個是有問題的,因為@1和@2之間有可能會插入其他指令,比如jedis.set("key1","value2"),那怎麼解決呢,很簡單,直接一段lua指令碼完事,如下:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

  

初識eval api

EVAL script numkeys key [key ...] arg [arg ...]

從 Redis 2.6.0 版本開始,通過內建的 Lua 直譯器,可以使用 EVAL 命令對 Lua 指令碼進行求值。

script 引數是一段 Lua  指令碼程式,它會被執行在 Redis 伺服器上下文中,這段指令碼不必(也不應該)定義為一個 Lua 函式。

numkeys 引數用於指定鍵名引數的個數。

鍵名引數 key [key ...] 從 EVAL 的第三個引數開始算起,表示在指令碼中所用到的那些 Redis 鍵(key),這些鍵名引數可以在 Lua 中通過全域性變數 KEYS 陣列,用 1 為基址的形式訪問( KEYS[1] , KEYS[2] ,以此類推)。

在命令的最後,那些不是鍵名引數的附加引數 arg [arg ...] ,可以在 Lua 中通過全域性變數 ARGV 陣列訪問,訪問的形式和 KEYS 變數類似( ARGV[1] 、 ARGV[2] ,諸如此類)。

上面這幾段長長的說明可以用一個簡單的例子來概括:

eval "return redis.call('set',KEYS[1],'bar')" 1 foo

  

執行流程初探

上面提到通過eval 指令來執行一段lua指令碼,現在就來看看具體的執行流程是什麼樣的,先放一張redis執行指令的整體流程,對執行過程感興趣的可以參考我另一篇文章redis工作流程初探

 

 

現在從上圖中6.1開始看起,redis根據命令執行相應的函式,eval對應的函式是evalCommand,看下evalCommand的大體流程。

 

 

 

這裡先放一個最簡化的流程圖,隨著過程深入慢慢豐富這個流程。

 

看完這個簡化流程,我這裡先拋幾個問題出來,後面一一解答。

  1. 為什麼要根據script生成functionName?

  2. 如何動態生成function?

  3. 具體是如何執行function的?

剖析

上一節拋了三個問題出來,這節將一一解答。

問題1:為什麼要根據script生成functionName?

lua雖然可以直接執行語句,但是Lua開放給C呼叫的介面是以函式為單位,所以這裡需要為script生成一個函式名稱,具體生成邏輯可以簡單理解為對script採用sha1演算法生成的雜湊串。

 

問題2:如何動態生成function?

首先看下lua中的函式定義格式如下:

function 方法名(引數1,引數2)
  return 結果
end

  

假設執行eval "redis.call('get','aaaa')" 0

那麼會根據以下規則生成function的字串定義:

  1. 根據script生成functionName,值為f_c1e0a03d7d32d0ade6850909efd61f92337847a8;

  2. 將script內容作為函式體;

 最終得到的結果是:

function f_7c6f28e03fe1da50a15a7396fd66d0927ee4f350() redis.call('get','aaa') end

  

以上只是生成了function的字串定義,真正要生成lua的函式還需要藉助Lua供的函式lua_load

int lua_load (lua_State *L,
              lua_Reader reader,
              void *data,
              const char *chunkname);
Loads a Lua chunk. If there are no errors, lua_load pushes the compiled chunk as a Lua function on top of the stack. Otherwise, it pushes an error message. The return values of lua_load are:

lua_load automatically detects whether the chunk is text or binary, and loads it accordingly (see program luac).

The lua_load function uses a user-supplied reader function to read the chunk (see lua_Reader). The data argument is an opaque value passed to the reader function.

The chunkname argument gives a name to the chunk, which is used for error messages and in debug information (see §3.8).

  

問題3:具體是如何執行function的?

通過前面的部分redis根據script已經動態生成了function,接下來就可以呼叫function了,這塊是最核心的部分了。

 

總體來說C語言呼叫Lua函式時需要藉助Lua提供的lua_call介面

void lua_call (lua_State *L, int nargs, int nresults);

  

這個介面一共需要三個引數,各自的含義如下:

L:lua_State型別變數,用來儲存執行過程中的狀態,包括函式,引數,返回值等;

nargs:本次函式呼叫所需要的引數個數;

nresults:本地呼叫結束以後期待的返回值個數;

 

看到這兒還是有一些懵逼,C到底是怎麼呼叫Lua函式的呢?對於兩個異構系統的相互呼叫一般需要兩個條件:

  1. 存在一層適配層,這一層負責做相關的轉換,對於C和Lua互調來說,這一層由Lua底層實現,比如上面的luc_call,lua_load等等;

  2. 需要某種通訊協議來達成共識,這樣才能順暢的交流;

而剛才提到的某種通訊協議在lua_call的介面說明中也提到了,具體如下:

首先,要呼叫的函式被壓入棧;然後,該函式的引數按直接順序入棧;也就是說,第一個引數先入棧。最後呼叫lua_call實現函式呼叫; nargs是您壓入堆的引數數量。呼叫函式時,將從棧中彈出所有引數和函式。函式返回時,函式結果將被壓入棧,函式結果以直接順序被推入堆疊(第一個結果被首先推入),因此在呼叫之後,最後一個結果在堆疊頂部。

 

通過一組圖描述下呼叫過程

 

 

 階段1-載入函式

lua_load

  

階段2-函式入棧

lua_getglobal(luaState, funcname);

 

階段3-引數入棧

lua_pushnil、lua_pushnumber、lua_pushstring等

  

階段4-函式呼叫

lua_call(luaState, 2, 1);//呼叫函式,該函式接收兩個引數,最終一個返回值  

 

階段5-獲取返回值

lua_tostring(luaState, -1)//以字串形式返回棧頂元素,也就是返回值

綜上所述,C呼叫lua函式之前需要將要呼叫的函式,函式需要的引數入棧,最終使用lua_call來實現函式呼叫,呼叫時需要明確的指出本地呼叫的引數個數,返回值個數,看到這兒你可能會問,為什麼還需要指出引數個數、返回值個數呢?其實這就是所謂的通訊協議,通訊載體是一個棧,棧裡面即放了函式,也放了函式引數,適配層(其實就是lua底層)如何知道函式在什麼位置呢?執行完以後該返回幾個結果呢(lua函式可以返回多個結果,但呼叫者可能不需要這麼多)?這些都需要呼叫者明確的告訴適配層。  

 

 

 這裡放個C語言呼叫Lua的例子來幫助理解,程式碼如下:

#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>



int main(void){
  //定義一段lua函式
  char lua_func[] = "function hello(v) return v end";
  //建立luaState
  lua_State* L = luaL_newstate();
  
  //載入lua_func中內容為一個lua函式
  if (luaL_loadbuffer(L, lua_func, strlen(lua_func), "@user_script")){
    printf(lua_tostring(L, -1));
    return -1;
  }
  lua_pcall(L, 0, LUA_MULTRET, 0);

  //hello函式入棧
  lua_getglobal(L, "hello");
  //hello函式所需引數入棧
  lua_pushstring(L, "world");
  //使用lua_pcall呼叫hello函式,告訴它需要一個引數一個返回值
  if (lua_pcall(L, 1, 1, 0)){
    //如果呼叫失敗輸出錯誤資訊,錯誤資訊在棧的頂部,所以用lua_tostring(L,-1)
    printf(lua_tostring(L, -1));
    getchar();
    return -1;
  }

  //沒有錯誤,輸出hello函式返回值,返回值在棧的頂部
  printf(lua_tostring(L, -1));
  
  //這個是為了讓命令列不要退出
  getchar();
  return 0;
}

  

可以看到輸出了hello函式返回的引數值“world”

 

 

更進一步

前面的章節僅僅能算一個鋪墊,只是聊了聊C語言呼叫Lua函式的知識,離“redis中Lua執行原理”真相還差一截,為什麼這麼說呢?

 

我們依然以前面的那段lua指令碼

eval "return redis.call('set',KEYS[1],'bar')" 1 foo

  

來展開,redis.call從字面理解對應著一次redis操作,這個操作難道是lua完成的?

 

 

redis.call('set',KEYS[1],'bar')可以理解為呼叫了redis物件(Lua語言中應該叫table)的call方法,引數分別為'set',KEYS[1],'bar',當執行redis.call時,其最終會對映到redis原始碼中的luaRedisCallCommand方法,這個對映操作是在redis啟動時scriptingInit函式完成的,跟著原始碼看下這塊邏輯:

 

void scriptingInit(void) {
    //1.初始化luaState
    lua_State *lua = lua_open();

    //2.載入一些lua庫
    luaLoadLibraries(lua);
    luaRemoveUnsupportedFunctions(lua);

    //3.初始化一個空的lua table,併入棧s,這時table在棧頂,對應的index=-1
    lua_newtable(lua);

    //4.壓字串"call"入棧,這時"call"在棧頂,index=-1,前一步的table在棧底
    //對應的index=-2
    lua_pushstring(lua,"call");

    //5.壓c函式luaRedisCallCommand入棧,這時"luaRedisCallCommand"在棧頂,index=-1,
    //前兩步壓入棧的table和"call"在棧中的index分別為-3,-2
    lua_pushcfunction(lua,luaRedisCallCommand);

    //6.為table賦值,table處在-3位置,依次從棧中彈出兩個元素作為table的
    //value和key,執行table[key] = value,賦值以後的table類似於這樣的結構
    //{"call":luaRedisCallCommand}
    //lua_settable以後棧中只剩table
    lua_settable(lua,-3);

    //7.從棧頂彈出一個元素設定為全域性變數,並命名為redis,因為目前棧中只剩
    //table,所以redis就是table
    lua_setglobal(lua,"redis");
  }

  

上面這段程式碼的主要作用是將redis.call這個Lua呼叫對映為luaRedisCallCommand這個C呼叫,那接下來應該還有兩個點值得我們關注:

  1. 引數如何傳遞給C函式的;

  2. C函式呼叫完成以後如何返回結果給Lua。

前面在說C呼叫Lua時說過,對於兩個異構系統的相互呼叫一般需要兩個條件:

  1. 存在一層適配層,這一層負責做相關的轉換,對於C和Lua互調來說,這一層由Lua底層實現,比如上面的luc_call,lua_load等等;

  2. 需要某種通訊協議來達成共識,這樣才能順暢的交流;

同樣的,這兩個前提條件同樣適用於Lua呼叫C,轉換依然由Lua底層實現,通訊載體依然是一個棧,通訊協議雖然有一點變化,但是原理類似,具體如下:

Lua底層對存在C對映關係的lua函式呼叫時,比如redis.call,Lua底層會將函式引數依次壓棧,當C函式呼叫時從棧中獲取引數,C函式執行完成以後將返回值壓棧,C函式的返回結果為返回值的數量,Lua底層根據函式返回值去棧中獲取一定數量的值作為lua的返回值;

通過一組圖描述下呼叫過程:

 

 

  階段1-c函式入棧

void lua_pushcfunction (lua_State *L, lua_CFunction f);

  

階段2-將C函式設定為lua全域性變數,其實就是lua呼叫到c呼叫的對映

void lua_setglobal (lua_State *L, const char *name);

  

階段3-函式呼叫

redis.call('set',KEYS[1],'bar')

 lua底層會將redis.call這個lua呼叫的引數依次壓棧,然後觸發對應的C函式,比如luaRedisCallCommand,它會從棧中獲取引數然後執行。

 

階段4-C函式呼叫完成

lua_pushxxx(luaState,結果);//結果壓棧
return 1;//返回結果的個數

  

階段5-獲取C函式執行結果

 這一步是由lua底層自動完成的,lua底層根據C函式的返回結果去棧中獲取相應的結果,比如返回值為1,那就獲取棧頂元素作為返回值,如果返回值為2,那就獲取棧頂前兩個元素作為返回值。

 

最後一起來看下luaRedisGenericCommand這個C函式的原始碼:

int luaRedisGenericCommand(lua_State *lua, int raise_error) {
    //獲取函式個數
    int j, argc = lua_gettop(lua);
    struct redisCommand *cmd;
    robj **argv;
    redisClient *c = server.lua_client;
    sds reply;

    /* Build the arguments vector */
    argv = zmalloc(sizeof(robj*)*argc);

    //從棧中依次獲取各引數的值
    for (j = 0; j < argc; j++) {
        if (!lua_isstring(lua,j+1)) break;
        argv[j] = createStringObject((char*)lua_tostring(lua,j+1),
                                     lua_strlen(lua,j+1));
    }
   
    /* Setup our fake client for command execution */
    //將引數個數和引數值告訴redis client,這裡比較有意思,為什麼叫
    //fake client呢?正常情況下redis client都是真實的應用程式,但是這裡是
    //redis server偽造的一個redis client
    c->argv = argv;
    c->argc = argc;

    /* Command lookup */
    //根據redis命令查詢對應的c函式,argv[0]就是redis命令
    cmd = lookupCommand(argv[0]->ptr);
    if (!cmd || ((cmd->arity > 0 && cmd->arity != argc) ||
                   (argc < -cmd->arity)))
    {
        if (cmd)
            luaPushError(lua,
                "Wrong number of args calling Redis command From Lua script");
        else
            luaPushError(lua,"Unknown Redis command called from Lua script");
        goto cleanup;
    }

    /* There are commands that are not allowed inside scripts. */
    if (cmd->flags & REDIS_CMD_NOSCRIPT) {
        luaPushError(lua, "This Redis command is not allowed from scripts");
        goto cleanup;
    }

    /* Write commands are forbidden against read-only slaves, or if a
     * command marked as non-deterministic was already called in the context
     * of this script. */
    if (cmd->flags & REDIS_CMD_WRITE) {
        if (server.lua_random_dirty) {
            luaPushError(lua,
                "Write commands not allowed after non deterministic commands");
            goto cleanup;
        } else if (server.masterhost && server.repl_slave_ro &&
                   !server.loading &&
                   !(server.lua_caller->flags & REDIS_MASTER))
        {
            luaPushError(lua, shared.roslaveerr->ptr);
            goto cleanup;
        } else if (server.stop_writes_on_bgsave_err &&
                   server.saveparamslen > 0 &&
                   server.lastbgsave_status == REDIS_ERR)
        {
            luaPushError(lua, shared.bgsaveerr->ptr);
            goto cleanup;
        }
    }

    if (cmd->flags & REDIS_CMD_RANDOM) server.lua_random_dirty = 1;
    if (cmd->flags & REDIS_CMD_WRITE) server.lua_write_dirty = 1;

    /* Run the command */
    c->cmd = cmd;
    //呼叫具體的C函式
    call(c,REDIS_CALL_SLOWLOG | REDIS_CALL_STATS);

    /* Convert the result of the Redis command into a suitable Lua type.
     * The first thing we need is to create a single string from the client
     * output buffers. */

    //解析響應結果
    reply = sdsempty();
    if (c->bufpos) {
        reply = sdscatlen(reply,c->buf,c->bufpos);
        c->bufpos = 0;
    }
    while(listLength(c->reply)) {
        robj *o = listNodeValue(listFirst(c->reply));

        reply = sdscatlen(reply,o->ptr,sdslen(o->ptr));
        listDelNode(c->reply,listFirst(c->reply));
    }
    if (raise_error && reply[0] != '-') raise_error = 0;

    //根據不同的響應將返回值壓入棧
    redisProtocolToLuaType(lua,reply);
    /* Sort the output array if needed, assuming it is a non-null multi bulk
     * reply as expected. */
    if ((cmd->flags & REDIS_CMD_SORT_FOR_SCRIPT) &&
        (reply[0] == '*' && reply[1] != '-')) {
            luaSortArray(lua);
    }
    sdsfree(reply);
    c->reply_bytes = 0;

cleanup:
    /* Clean up. Command code may have changed argv/argc so we use the
     * argv/argc of the client instead of the local variables. */
    for (j = 0; j < c->argc; j++)
        decrRefCount(c->argv[j]);
    zfree(c->argv);

    if (raise_error) {
        /* If we are here we should have an error in the stack, in the
         * form of a table with an "err" field. Extract the string to
         * return the plain error. */
        lua_pushstring(lua,"err");
        lua_gettable(lua,-2);
        return lua_error(lua);
    }

    //返回結果的數量
    return 1;
}

  

總結

redis和Lua能夠直接通訊得益於底層都是C實現的,關鍵在於LuaState,可以簡單理解為一個棧,充當了通訊的載體,其次就是通訊協議的定義,引數的傳遞、返回值的獲取、方法的呼叫都通過簡單的入棧、出棧操作實現。

 

推薦閱讀

Lua官方文件  http://www.lua.org/manual/5.1/manual.html

 

Lua程式設計指南 http://www.lua.org/pil/24.html http://www.lua.org/pil/25.html http://www.lua.org/pil/26.html 

 

                               來我的公眾號與我交流

                                                               

 

相關文章