Redis中的原子操作(2)-redis中使用Lua指令碼保證命令原子性

ZhanLi發表於2022-06-06

Redis 如何應對併發訪問

上個文章中,我們分析了Redis 中命令的執行是單執行緒的,雖然 Redis6.0 版本之後,引入了 I/O 多執行緒,但是對於 Redis 命令的還是單執行緒去執行的。所以如果業務中,我們只用 Redis 中的單命令去處理業務的話,命令的原子性是可以得到保障的。

但是很多業務場景中,需要多個命令組合的使用,例如前面介紹的 讀取-修改-寫回 場景,這時候就不能保證組合命令的原子性了。所以這時候 Lua 就登場了。

使用 Lua 指令碼

Redis 在 2.6 版本推出了 Lua 指令碼功能。

引入 Lua 指令碼的優點:

1、減少網路開銷。可以將多個請求通過指令碼的形式一次傳送,減少網路時延。

2、原子操作。Redis會將整個指令碼作為一個整體執行,中間不會被其他請求插入。因此在指令碼執行過程中無需擔心會出現競態條件,無需使用事務。

3、複用。客戶端傳送的指令碼會永久存在redis中,這樣其他客戶端可以複用這一指令碼,而不需要使用程式碼完成相同的邏輯。

關於 Lua 的語法和 Lua 是一門什麼樣的語言,可以自行 google。

Redis 中如何使用 Lua 指令碼

redis 中支援 Lua 指令碼的幾個命令

redis 自 2.6.0 加入了 Lua 指令碼相關的命令,在 3.2.0 加入了 Lua 指令碼的除錯功能和命令 SCRIPT DEBUG。這裡對命令做下簡單的介紹。

EVAL:使用改命令來直接執行指定的Lua指令碼;

SCRIPT LOAD:將指令碼 script 新增到指令碼快取中,以達到重複使用,避免多次載入浪費頻寬,該命令不會執行指令碼。僅載入指令碼快取中;

EVALSHA:執行由 SCRIPT LOAD 載入到快取的命令;

SCRIPT EXISTS:以 SHA1 標識為引數,檢查指令碼是否存在指令碼快取裡面

SCRIPT FLUSH:清空 Lua 指令碼快取,這裡是清理掉所有的指令碼快取;

SCRIPT KILL:殺死當前正在執行的 Lua 指令碼,當且僅當這個指令碼沒有執行過任何寫操作時,這個命令才生效;

SCRIPT DEBUG:設定除錯模式,可設定同步、非同步、關閉,同步會阻塞所有請求。

EVAL

通過這個命令來直接執行執行的 Lua 指令碼,也是 Redis 中執行 Lua 指令碼最常用的命令。

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

來看下具體的引數

  • script: 需要執行的 Lua 指令碼;

  • numkeys: 指定的 Lua 指令碼需要處理鍵的數量,其實就是 key 陣列的長度;

  • key: 傳遞給 Lua 指令碼零到多個鍵,空格隔開,在 Lua 指令碼中通過 KEYS[INDEX] 來獲取對應的值,其中1 <= INDEX <= numkeys

  • arg: 自定義的引數,在 Lua 指令碼中通過 ARGV[INDEX] 來獲取對應的值,其中 INDEX 的值從1開始。

看了這些還是好迷糊,舉個例子

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2],ARGV[3]}" 2 key1 key2 arg1 arg2 arg3
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"
5) "arg3"

可以看到上面指定了 numkeys 的長度是2,然後後面 key 中放了兩個鍵值 key1 和 key2,通過 KEYS[1],KEYS[2] 就能獲取到傳入的兩個鍵值對。arg1 arg2 arg3 即為傳入的自定義引數,通過 ARGV[index] 就能獲取到對應的引數。

一般情況下,會將 Lua 放在一個單獨的 Lua 檔案中,然後去執行這個 Lua 指令碼。

redis

執行語法 --eval script key1 key2 , arg1 age2

舉個例子

# cat test.lua
return {KEYS[1],KEYS[2],ARGV[1],ARGV[2],ARGV[3]}

# redis-cli --eval ./test.lua  key1 key2 ,  arg1 arg2 arg3
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"
5) "arg3"

需要注意的是,使用檔案去執行,key 和 value 用一個逗號隔開,並且也不需要指定 numkeys。

Lua 指令碼中一般會使用下面兩個函式來呼叫 Redis 命令

redis.call()
redis.pcall()

redis.call() 與 redis.pcall() 很類似, 他們唯一的區別是當redis命令執行結果返回錯誤時, redis.call() 將返回給呼叫者一個錯誤,而 redis.pcall() 會將捕獲的錯誤以 Lua 表的形式返回。

127.0.0.1:6379> EVAL "return redis.call('SET','test')" 0
(error) ERR Error running script (call to f_77810fca9b2b8e2d8a68f8a90cf8fbf14592cf54): @user_script:1: @user_script: 1: Wrong number of args calling Redis command From Lua script
127.0.0.1:6379> EVAL "return redis.pcall('SET','test')" 0
(error) @user_script: 1: Wrong number of args calling Redis command From Lua script

同樣需要注意的是,指令碼里使用的所有鍵都應該由 KEYS 陣列來傳遞,就像這樣:

127.0.0.1:6379>  eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK

下面這種就是不推薦的

127.0.0.1:6379> eval "return redis.call('set','foo','bar')" 0
OK

原因有下面兩個

1、Redis 中所有的命令,在執行之前都會被分析,來確定會對那些鍵值對進行操作,對於 EVAL 命令來說,必須使用正確的形式來傳遞鍵,才能確保分析工作正確地執行;

2、使用正確的形式來傳遞鍵還有很多其他好處,它的一個特別重要的用途就是確保 Redis 叢集可以將你的請求傳送到正確的叢集節點。

EVALSHA

用來執行被 SCRIPT LOAD 載入到快取的命令,具體看下文的 SCRIPT LOAD 命令介紹。

SCRIPT 命令

Redis 提供了以下幾個 SCRIPT 命令,用於對指令碼子系統(scripting subsystem)進行控制。

SCRIPT LOAD

將指令碼 script 新增到指令碼快取中,以達到重複使用,避免多次載入浪費頻寬,該命令不會執行指令碼。僅載入指令碼快取中。

在指令碼被加入到快取之後,會返回一個通過SHA校驗返回唯一字串標識,使用 EVALSHA 命令來執行快取後的指令碼。

127.0.0.1:6379> SCRIPT LOAD "return {KEYS[1]}"
"8e5266f6a4373624739bd44187744618bc810de3"
127.0.0.1:6379> EVALSHA 8e5266f6a4373624739bd44187744618bc810de3 1 hello
1) "hello"
SCRIPT EXISTS

以 SHA1 標識為引數,檢查指令碼是否存在指令碼快取裡面。

這個命令可以接受一個或者多個指令碼 SHA1 資訊,返回一個1或者0的列表。

127.0.0.1:6379> SCRIPT EXISTS 8e5266f6a4373624739bd44187744618bc810de3 2323211
1) (integer) 1
2) (integer) 0

1 表示存在,0 表示不存在

SCRIPT FLUSH

清空 Lua 指令碼快取 Flush the Lua scripts cache,這個是清掉所有的指令碼快取。要慎重使用。

SCRIPT KILL

殺死當前正在執行的 Lua 指令碼,當且僅當這個指令碼沒有執行過任何寫操作時,這個命令才生效。

這個命令主要用於終止執行時間過長的指令碼,比如一個因為 BUG 而發生無限 loop 的指令碼。

# 沒有指令碼在執行時
127.0.0.1:6379> SCRIPT KILL
(error) ERR No scripts in execution right now.

# 成功殺死指令碼時
127.0.0.1:6379> SCRIPT KILL
OK
(1.10s)

# 嘗試殺死一個已經執行過寫操作的指令碼,失敗
127.0.0.1:6379> SCRIPT KILL
(error) ERR Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in an hard way using the SHUTDOWN NOSAVE command.
(1.19s)

假如當前正在執行的指令碼已經執行過寫操作,那麼即使執行 SCRIPT KILL ,也無法將它殺死,因為這是違反 Lua 指令碼的原子性執行原則的。在這種情況下,唯一可行的辦法是使用 SHUTDOWN NOSAVE 命令,通過停止整個 Redis 程式來停止指令碼的執行,並防止不完整(half-written)的資訊被寫入資料庫中。

SCRIPT DEBUG

redis 從 v3.2.0 開始支援 Lua debugger,可以加斷點、print 變數資訊、除錯正在執行的程式碼......

如何進入除錯模式?

在原本執行的命令中增加 --ldb 即可進入除錯模式。

栗子

# redis-cli --ldb  --eval ./test.lua  key1 key2 ,  arg1 arg2 arg3
Lua debugging session started, please use:
quit    -- End the session.
restart -- Restart the script in debug mode again.
help    -- Show Lua script debugging commands.

* Stopped at 1, stop reason = step over
-> 1   local key1   = tostring(KEYS[1])

除錯模式有兩種,同步模式和除錯模式:

1、除錯模式:使用 --ldb 開啟,除錯模式下 Redis 會 fork 一個程式進去到隔離環境中,不會影響到 Redis 中的正常執行,同樣 Redis 中正常命令的執行也不會影響到除錯模式,兩者相互隔離,同時除錯模式下,除錯指令碼結束時,回滾指令碼操作的所有資料更改。

2、同步模式:使用 --ldb-sync-mode 開啟,同步模式下,會阻塞 Redis 中的命令,完全模擬了正常模式下的命令執行,除錯命令的執行結果也會被記錄。在此模式下除錯會話期間,Redis 伺服器將無法訪問,因此需要謹慎使用。

這裡簡單下看下,Redis 中如何進行除錯

看下 debugger 模式支援的命令

lua debugger> h
Redis Lua debugger help:
[h]elp               Show this help.
[s]tep               Run current line and stop again.
[n]ext               Alias for step.
[c]continue          Run till next breakpoint.
[l]list              List source code around current line.
[l]list [line]       List source code around [line].
                     line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
                     to show before/after [line].
[w]hole              List all source code. Alias for 'list 1 1000000'.
[p]rint              Show all the local variables.
[p]rint <var>        Show the value of the specified variable.
                     Can also show global vars KEYS and ARGV.
[b]reak              Show all breakpoints.
[b]reak <line>       Add a breakpoint to the specified line.
[b]reak -<line>      Remove breakpoint from the specified line.
[b]reak 0            Remove all breakpoints.
[t]race              Show a backtrace.
[e]eval <code>       Execute some Lua code (in a different callframe).
[r]edis <cmd>        Execute a Redis command.
[m]axlen [len]       Trim logged Redis replies and Lua var dumps to len.
                     Specifying zero as <len> means unlimited.
[a]bort              Stop the execution of the script. In sync
                     mode dataset changes will be retained.

Debugger functions you can call from Lua scripts:
redis.debug()        Produce logs in the debugger console.
redis.breakpoint()   Stop execution like if there was a breakpoing.
                     in the next line of code.

這裡來個簡單的分析

# cat test.lua
local key1   = tostring(KEYS[1])
local key2   = tostring(KEYS[2])
local arg1   = tostring(ARGV[1])

if key1 == 'test1' then
   return 1
end

if key2 == 'test2' then
   return 2
end

return arg1

# 進入 debuge 模式
# redis-cli --ldb  --eval ./test.lua  key1 key2 ,  arg1 arg2 arg3
Lua debugging session started, please use:
quit    -- End the session.
restart -- Restart the script in debug mode again.
help    -- Show Lua script debugging commands.

* Stopped at 1, stop reason = step over
-> 1   local key1   = tostring(KEYS[1])

# 新增斷點 
lua debugger> b 3
   2   local key2   = tostring(KEYS[2])
  #3   local arg1   = tostring(ARGV[1])
   4
   
# 列印輸入的引數 key
lua debugger> p KEYS
<value> {"key1"; "key2"}

為什麼 Redis 中的 Lua 指令碼的執行是原子性的

我們知道 Redis 中單命令的執行是原子性的,因為命令的執行都是單執行緒去處理的。

那麼對於 Redis 中執行 Lua 指令碼也是原子性的,是如何實現的呢?這裡來探討下。

Redis 使用單個 Lua 直譯器去執行所有指令碼,並且, Redis 也保證指令碼會以原子性(atomic)的方式執行: 當某個指令碼正在執行的時候,不會有其他指令碼或 Redis 命令被執行。 這和使用 MULTI / EXEC 包圍的事務很類似。 在其他別的客戶端看來,指令碼的效果(effect)要麼是不可見的(not visible),要麼就是已完成的(already completed)。

Redis 中執行命令需要響應的客戶端狀態,為了執行 Lua 指令碼中的 Redis 命令,Redis 中專門建立了一個偽客戶端,由這個客戶端處理 Lua 指令碼中包含的 Redis 命令。

Redis 從始到終都只是建立了一個 Lua 環境,以及一個 Lua_client ,這就意味著 Redis 伺服器端同一時刻只能處理一個指令碼。

總結下就是:Redis 執行 Lua 指令碼時可以簡單的認為僅僅只是把命令打包執行了,命令還是依次執行的,只不過在 Lua 指令碼執行時是阻塞的,避免了其他指令的干擾。

這裡看下偽客戶端如何處理命令的

1、Lua 環境將 redis.call 函式或者 redis.pcall 函式需要執行的命令傳遞給偽客戶端;

2、偽客戶端將想要執行的命令傳送給命令執行器;

3、命令執行器執行對應的命令,並且返回給命令的結果給偽客戶端;

4、偽客戶端收到命令執行的返回資訊,將結果返回給 Lua 環境;

5、Lua 環境收到命令的執行結果,將結果返回給 redis.call 函式或者 redis.pcall 函式;

6、接收到結果的 redis.call 函式或者 redis.pcall 函式會將結果作為函式的返回值返回指令碼中的呼叫者。

這裡看下里面核心 EVAL 的實現

// https://github.com/redis/redis/blob/7.0/src/eval.c#L498
void evalCommand(client *c) {
    replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
    if (!(c->flags & CLIENT_LUA_DEBUG))
        evalGenericCommand(c,0);
    else
        evalGenericCommandWithDebugging(c,0);
}

// https://github.com/redis/redis/blob/7.0/src/eval.c#L417  
void evalGenericCommand(client *c, int evalsha) {
    lua_State *lua = lctx.lua;
    char funcname[43];
    long long numkeys;

    // 獲取輸入鍵的數量
    if (getLongLongFromObjectOrReply(c,c->argv[2],&numkeys,NULL) != C_OK)
        return;
    // 對鍵的正確性做一個快速檢查
    if (numkeys > (c->argc - 3)) {
        addReplyError(c,"Number of keys can't be greater than number of args");
        return;
    } else if (numkeys < 0) {
        addReplyError(c,"Number of keys can't be negative");
        return;
    }

    /* We obtain the script SHA1, then check if this function is already
     * defined into the Lua state */
    // 組合出函式的名字,例如 f_282297a0228f48cd3fc6a55de6316f31422f5d17
    funcname[0] = 'f';
    funcname[1] = '_';
    if (!evalsha) {
        /* Hash the code if this is an EVAL call */
        sha1hex(funcname+2,c->argv[1]->ptr,sdslen(c->argv[1]->ptr));
    } else {
        /* We already have the SHA if it is an EVALSHA */
        int j;
        char *sha = c->argv[1]->ptr;

        /* Convert to lowercase. We don't use tolower since the function
         * managed to always show up in the profiler output consuming
         * a non trivial amount of time. */
        for (j = 0; j < 40; j++)
            funcname[j+2] = (sha[j] >= 'A' && sha[j] <= 'Z') ?
                sha[j]+('a'-'A') : sha[j];
        funcname[42] = '\0';
    }

    /* Push the pcall error handler function on the stack. */
    lua_getglobal(lua, "__redis__err__handler");

    // 根據函式名,在 Lua 環境中檢查函式是否已經定義
    lua_getfield(lua, LUA_REGISTRYINDEX, funcname);
    // 如果沒有找到對應的函式
    if (lua_isnil(lua,-1)) {
        lua_pop(lua,1); /* remove the nil from the stack */
        // 如果執行的是 EVALSHA ,返回指令碼未找到錯誤
        if (evalsha) {
            lua_pop(lua,1); /* remove the error handler from the stack. */
            addReplyErrorObject(c, shared.noscripterr);
            return;
        }
        // 如果執行的是 EVAL ,那麼建立新函式,然後將程式碼新增到指令碼字典中
        if (luaCreateFunction(c,c->argv[1]) == NULL) {
            lua_pop(lua,1); /* remove the error handler from the stack. */
            /* The error is sent to the client by luaCreateFunction()
             * itself when it returns NULL. */
            return;
        }
        /* Now the following is guaranteed to return non nil */
        lua_getfield(lua, LUA_REGISTRYINDEX, funcname);
        serverAssert(!lua_isnil(lua,-1));
    }

    char *lua_cur_script = funcname + 2;
    dictEntry *de = dictFind(lctx.lua_scripts, lua_cur_script);
    luaScript *l = dictGetVal(de);
    int ro = c->cmd->proc == evalRoCommand || c->cmd->proc == evalShaRoCommand;

    scriptRunCtx rctx;
    // 通過函式 scriptPrepareForRun 初始化物件 scriptRunCtx
    if (scriptPrepareForRun(&rctx, lctx.lua_client, c, lua_cur_script, l->flags, ro) != C_OK) {
        lua_pop(lua,2); /* Remove the function and error handler. */
        return;
    }
    rctx.flags |= SCRIPT_EVAL_MODE; /* mark the current run as EVAL (as opposed to FCALL) so we'll
                                      get appropriate error messages and logs */

    // 執行Lua 指令碼
    luaCallFunction(&rctx, lua, c->argv+3, numkeys, c->argv+3+numkeys, c->argc-3-numkeys, ldb.active);
    lua_pop(lua,1); /* Remove the error handler. */
    scriptResetRun(&rctx);
}

// https://github.com/redis/redis/blob/7.0/src/script_lua.c#L1583
void luaCallFunction(scriptRunCtx* run_ctx, lua_State *lua, robj** keys, size_t nkeys, robj** args, size_t nargs, int debug_enabled) {
    client* c = run_ctx->original_client;
    int delhook = 0;

    /* We must set it before we set the Lua hook, theoretically the
     * Lua hook might be called wheneven we run any Lua instruction
     * such as 'luaSetGlobalArray' and we want the run_ctx to be available
     * each time the Lua hook is invoked. */
    luaSaveOnRegistry(lua, REGISTRY_RUN_CTX_NAME, run_ctx);

    if (server.busy_reply_threshold > 0 && !debug_enabled) {
        lua_sethook(lua,luaMaskCountHook,LUA_MASKCOUNT,100000);
        delhook = 1;
    } else if (debug_enabled) {
        lua_sethook(lua,luaLdbLineHook,LUA_MASKLINE|LUA_MASKCOUNT,100000);
        delhook = 1;
    }

    /* Populate the argv and keys table accordingly to the arguments that
     * EVAL received. */
    // 根據EVAL接收到的引數填充 argv 和 keys table
    luaCreateArray(lua,keys,nkeys);
    /* On eval, keys and arguments are globals. */
    if (run_ctx->flags & SCRIPT_EVAL_MODE){
        /* open global protection to set KEYS */
        lua_enablereadonlytable(lua, LUA_GLOBALSINDEX, 0);
        lua_setglobal(lua,"KEYS");
        lua_enablereadonlytable(lua, LUA_GLOBALSINDEX, 1);
    }
    luaCreateArray(lua,args,nargs);
    if (run_ctx->flags & SCRIPT_EVAL_MODE){
        /* open global protection to set ARGV */
        lua_enablereadonlytable(lua, LUA_GLOBALSINDEX, 0);
        lua_setglobal(lua,"ARGV");
        lua_enablereadonlytable(lua, LUA_GLOBALSINDEX, 1);
    }

    /* At this point whether this script was never seen before or if it was
     * already defined, we can call it.
     * On eval mode, we have zero arguments and expect a single return value.
     * In addition the error handler is located on position -2 on the Lua stack.
     * On function mode, we pass 2 arguments (the keys and args tables),
     * and the error handler is located on position -4 (stack: error_handler, callback, keys, args) */
     // 呼叫執行函式
     // 這裡會有兩種情況
     // 1、沒有引數,只有一個返回值
     // 2、函式模式,有兩個引數
    int err;
    // 使用lua_pcall執行lua程式碼
    if (run_ctx->flags & SCRIPT_EVAL_MODE) {
        err = lua_pcall(lua,0,1,-2);
    } else {
        err = lua_pcall(lua,2,1,-4);
    }

    /* Call the Lua garbage collector from time to time to avoid a
     * full cycle performed by Lua, which adds too latency.
     *
     * The call is performed every LUA_GC_CYCLE_PERIOD executed commands
     * (and for LUA_GC_CYCLE_PERIOD collection steps) because calling it
     * for every command uses too much CPU. */
    #define LUA_GC_CYCLE_PERIOD 50
    {
        static long gc_count = 0;

        gc_count++;
        if (gc_count == LUA_GC_CYCLE_PERIOD) {
            lua_gc(lua,LUA_GCSTEP,LUA_GC_CYCLE_PERIOD);
            gc_count = 0;
        }
    }

    // 檢查指令碼是否出錯
    if (err) {
        /* Error object is a table of the following format:
         * {err='<error msg>', source='<source file>', line=<line>}
         * We can construct the error message from this information */
        if (!lua_istable(lua, -1)) {
            /* Should not happened, and we should considered assert it */
            addReplyErrorFormat(c,"Error running script (call to %s)\n", run_ctx->funcname);
        } else {
            errorInfo err_info = {0};
            sds final_msg = sdsempty();
            luaExtractErrorInformation(lua, &err_info);
            final_msg = sdscatfmt(final_msg, "-%s",
                                  err_info.msg);
            if (err_info.line && err_info.source) {
                final_msg = sdscatfmt(final_msg, " script: %s, on %s:%s.",
                                      run_ctx->funcname,
                                      err_info.source,
                                      err_info.line);
            }
            addReplyErrorSdsEx(c, final_msg, err_info.ignore_err_stats_update? ERR_REPLY_FLAG_NO_STATS_UPDATE : 0);
            luaErrorInformationDiscard(&err_info);
        }
        lua_pop(lua,1); /* Consume the Lua error */
    } else {
        // 將 Lua 函式執行所得的結果轉換成 Redis 回覆,然後傳給呼叫者客戶端
        luaReplyToRedisReply(c, run_ctx->c, lua); /* Convert and consume the reply. */
    }

    /* Perform some cleanup that we need to do both on error and success. */
    if (delhook) lua_sethook(lua,NULL,0,0); /* Disable hook */

    /* remove run_ctx from registry, its only applicable for the current script. */
    luaSaveOnRegistry(lua, REGISTRY_RUN_CTX_NAME, NULL);
}

這裡總結下 EVAL 函式的執行中幾個重要的操作流程

1、將 EVAL 命令中輸入的 KEYS 引數和 ARGV 引數以全域性陣列的方式傳入到 Lua 環境中。

2、為 Lua 環境裝載超時鉤子,保證在指令碼執行出現超時時可以殺死指令碼,或者停止 Redis 伺服器。

3、執行指令碼對應的 Lua 函式。

4、對 Lua 環境進行一次單步的漸進式 GC 。

5、執行清理操作:清除鉤子;清除指向呼叫者客戶端的指標;等等。

6、將 Lua 函式執行所得的結果轉換成 Redis 回覆,然後傳給呼叫者客戶端。

上面可以看到 lua 中的指令碼是由 lua_pcall 進行呼叫的,如果一個 lua 指令碼中有多個 redis.call 呼叫或者 redis.pcall 呼叫的請求命令,又是如何處理的呢,這裡來分析下

/* redis.call() */
static int luaRedisCallCommand(lua_State *lua) {
    return luaRedisGenericCommand(lua,1);
}

/* redis.pcall() */
static int luaRedisPCallCommand(lua_State *lua) {
    return luaRedisGenericCommand(lua,0);
}

// https://github.com/redis/redis/blob/7.0/src/script_lua.c#L838
static int luaRedisGenericCommand(lua_State *lua, int raise_error) {
    int j;
    scriptRunCtx* rctx = luaGetFromRegistry(lua, REGISTRY_RUN_CTX_NAME);
    if (!rctx) {
        luaPushError(lua, "redis.call/pcall can only be called inside a script invocation");
        return luaError(lua);
    }
    sds err = NULL;
    client* c = rctx->c;
    sds reply;

    // 處理請求的引數
    int argc;
    robj **argv = luaArgsToRedisArgv(lua, &argc);
    if (argv == NULL) {
        return raise_error ? luaError(lua) : 1;
    }

    static int inuse = 0;   /* Recursive calls detection. */

    ...

    /* Log the command if debugging is active. */
    if (ldbIsEnabled()) {
        sds cmdlog = sdsnew("<redis>");
        for (j = 0; j < c->argc; j++) {
            if (j == 10) {
                cmdlog = sdscatprintf(cmdlog," ... (%d more)",
                    c->argc-j-1);
                break;
            } else {
                cmdlog = sdscatlen(cmdlog," ",1);
                cmdlog = sdscatsds(cmdlog,c->argv[j]->ptr);
            }
        }
        ldbLog(cmdlog);
    }
    // 執行 redis 中的命令
    scriptCall(rctx, argv, argc, &err);
    if (err) {
        luaPushError(lua, err);
        sdsfree(err);
        /* push a field indicate to ignore updating the stats on this error
         * because it was already updated when executing the command. */
        lua_pushstring(lua,"ignore_error_stats_update");
        lua_pushboolean(lua, true);
        lua_settable(lua,-3);
        goto cleanup;
    }

    /* 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. */
     // 將返回值轉換成 lua 型別
     // 在客戶端的輸出緩衝區建立一個字串
    if (listLength(c->reply) == 0 && (size_t)c->bufpos < c->buf_usable_size) {
        /* This is a fast path for the common case of a reply inside the
         * client static buffer. Don't create an SDS string but just use
         * the client buffer directly. */
        c->buf[c->bufpos] = '\0';
        reply = c->buf;
        c->bufpos = 0;
    } else {
        reply = sdsnewlen(c->buf,c->bufpos);
        c->bufpos = 0;
        while(listLength(c->reply)) {
            clientReplyBlock *o = listNodeValue(listFirst(c->reply));

            reply = sdscatlen(reply,o->buf,o->used);
            listDelNode(c->reply,listFirst(c->reply));
        }
    }
    if (raise_error && reply[0] != '-') raise_error = 0;
    // 將回復轉換為 Lua 值,
    redisProtocolToLuaType(lua,reply);

    /* If the debugger is active, log the reply from Redis. */
    if (ldbIsEnabled())
        ldbLogRedisReply(reply);

    if (reply != c->buf) 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. */
    freeClientArgv(c);
    c->user = NULL;
    inuse--;

    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. */
        return luaError(lua);
    }
    return 1;
}

// https://github.com/redis/redis/blob/7.0/src/script.c#L492
// 呼叫Redis命令。並且寫回結果到執行的ctx客戶端,
void scriptCall(scriptRunCtx *run_ctx, robj* *argv, int argc, sds *err) {
    client *c = run_ctx->c;

    // 設定偽客戶端執行命令
    c->argv = argv;
    c->argc = argc;
    c->user = run_ctx->original_client->user;

    /* Process module hooks */
    // 處理 hooks 模組
    moduleCallCommandFilters(c);
    argv = c->argv;
    argc = c->argc;

    // 查詢命令的實現函式
    struct redisCommand *cmd = lookupCommand(argv, argc);
    c->cmd = c->lastcmd = c->realcmd = cmd;
    
    ...

    int call_flags = CMD_CALL_SLOWLOG | CMD_CALL_STATS;
    if (run_ctx->repl_flags & PROPAGATE_AOF) {
        call_flags |= CMD_CALL_PROPAGATE_AOF;
    }
    if (run_ctx->repl_flags & PROPAGATE_REPL) {
        call_flags |= CMD_CALL_PROPAGATE_REPL;
    }
    // 執行命令
    call(c, call_flags);
    serverAssert((c->flags & CLIENT_BLOCKED) == 0);
    return;

error:
    afterErrorReply(c, *err, sdslen(*err), 0);
    incrCommandStatsOnError(cmd, ERROR_COMMAND_REJECTED);
}

luaRedisGenericCommand 函式處理的大致流程

1、檢查執行的環境以及引數;

2、執行命令;

3、將命令的返回值從 Redis 型別轉換成 Lua 型別,回覆給 Lua 環境;

4、環境的清理。

看下總體的命令處理過程

當然圖中的這個栗子,incr 命令已經能夠返回當前 key 的值,後面又加了個 get 僅僅是為了,演示 Lua 指令碼中多個 redis.call 的呼叫邏輯

redis

Redis 中 Lua 指令碼的使用

限流是是我們在業務開發中經常遇到的場景,這裡使用 Redis 中的 Lua 指令碼實現了一個簡單的限流元件,具體細節可參見

redis 實現 rate-limit

總結

當 Redis 中如果存在 讀取-修改-寫回 這種場景,我們就無法保證命令執行的原子性了;

Redis 在 2.6 版本推出了 Lua 指令碼功能。

引入 Lua 指令碼的優點:

1、減少網路開銷。可以將多個請求通過指令碼的形式一次傳送,減少網路時延。

2、原子操作。Redis會將整個指令碼作為一個整體執行,中間不會被其他請求插入。因此在指令碼執行過程中無需擔心會出現競態條件,無需使用事務。

3、複用。客戶端傳送的指令碼會永久存在redis中,這樣其他客戶端可以複用這一指令碼,而不需要使用程式碼完成相同的邏輯。

Redis 使用單個 Lua 直譯器去執行所有指令碼,並且, Redis 也保證指令碼會以原子性(atomic)的方式執行: 當某個指令碼正在執行的時候,不會有其他指令碼或 Redis 命令被執行。 這和使用 MULTI / EXEC 包圍的事務很類似。 在其他別的客戶端看來,指令碼的效果(effect)要麼是不可見的(not visible),要麼就是已完成的(already completed)。

Redis 中執行命令需要響應的客戶端狀態,為了執行 Lua 指令碼中的 Redis 命令,Redis 中專門建立了一個偽客戶端,由這個客戶端處理 Lua 指令碼中包含的 Redis 命令。

Redis 從始到終都只是建立了一個 Lua 環境,以及一個 Lua_client ,這就意味著 Redis 伺服器端同一時刻只能處理一個指令碼。

總結下就是:Redis 執行 Lua 指令碼時可以簡單的認為僅僅只是把命令打包執行了,命令還是依次執行的,只不過在 Lua 指令碼執行時是阻塞的,避免了其他指令的干擾。

參考

【Redis核心技術與實戰】https://time.geekbang.org/column/intro/100056701
【Redis設計與實現】https://book.douban.com/subject/25900156/
【EVAL簡介】http://www.redis.cn/commands/eval.html
【Redis學習筆記】https://github.com/boilingfrog/Go-POINT/tree/master/redis
【Redis Lua指令碼偵錯程式】http://www.redis.cn/topics/ldb.html
【redis中Lua指令碼的使用】https://boilingfrog.github.io/2022/06/06/Redis中的原子操作(2)-redis中使用Lua指令碼保證命令原子性/

相關文章