Redis中的原子操作(2)-redis中使用Lua脚本保证命令原子性

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中的原子操作(2)-redis中使用Lua脚本保证命令原子性》

执行语法 --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中的原子操作(2)-redis中使用Lua脚本保证命令原子性》

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脚本保证命令原子性/

点赞