Redis源码阅读【10-事务与Lua】

Redis源码阅读【1-简单动态字符串】
Redis源码阅读【2-跳跃表】
Redis源码阅读【3-Redis编译与GDB调试】
Redis源码阅读【4-压缩列表】
Redis源码阅读【5-字典】
Redis源码阅读【6-整数集合】
Redis源码阅读【7-quicklist】
Redis源码阅读【8-命令处理生命周期-1】
Redis源码阅读【8-命令处理生命周期-2】
Redis源码阅读【8-命令处理生命周期-3】
Redis源码阅读【8-命令处理生命周期-4】
Redis源码阅读【番外篇-Redis的多线程】
Redis源码阅读【9-持久化】
Redis源码阅读【10-事务与Lua】
建议搭配源码阅读源码地址

1、前言

在多个业务场景下,事务性是很重要的一个概念,而Redis事务的本质就是,一组有序命令的集合。由于Redis执行事务的时候将命令存放入队列缓存中,对其它事务一定是不可见的,又加上Redis执行命令本身是单线程的,相当于Mysql中的事务串行执行,所以Redis的事务是没有隔离级别的概念。Redis会将事务集合统一执行,且执行期间不允许其它命令打断。Redis事务与Mysql不同的是,Redis事务提交前如果出错,那么全部命令都不会执行,但是如果运行期间出错,那么Redis任然会执行后续的命令(严格来说不具备绝对的原子性)。除了直接支持事务,Redis还允许使用Lua脚本的方式执行命令,通过Lua的方式也能实现命令的一次性执行,中间不允许其它命令插入。而且使用Lua可以一次性将需要执行的命令发送给Redis服务端,从而减少网络开销,此外Lua脚本是可以存储在Redis服务端,并且多次使用的。两者关系入下图所示:
在这里插入图片描述

2、client标记

在Redis,客户端Client一般会被标记为多个状态,这些状态都被标记在client->flags中,而Redis使用类似于位图的方式去同时保存这些状态,并且具备良好的拓展性,标记方式如下图所示:
在这里插入图片描述

2.1、Redis Client 事务相关标记

标记名称标记值标记描述
CLIENT_SLAVE1<<0当前客户端是一个 slave
CLIENT_MASTER1<<1当前客户端是一个 master
CLIENT_MONITOR1<<2当前客户是一个MONITOR(监视器)
CLIENT_MULTI1<<3当前客户端在MULTI上下文中,事务开启
CLIENT_BLOCKED1<<4当前客户端正在等待一个 blocking 操作
CLIENT_DIRTY_CAS1<<5当前客户端WATCH的一个或者多个key被修改,EXEC的时候将会失败
CLIENT_CLOSE_AFTER_REPLY1<<6close客户端,当reply结束
CLIENT_LUA1<<8当前客户端,不是常规客户端,而是Lua脚本
CLIENT_DIRTY_EXEC1<<12EXEC将会失败,所以不再允许入缓存队列
CLIENT_READONLY1<<8当前客户端,是只读状态
CLIENT_LUA_DEBUG1<<8使用调试模式,执行Lua脚本
CLIENT_LUA_DEBUG_SYNC1<<8使用调试模式,同步执行Lua脚本,不会fork出一个子进程

2.2、Redis Client 全部标记

这里列举出了和事务Lua相关的常用状态,完整的Client状态在server.h文件中,如下所示:
(注:1ULL->unsigned long long

/* Client flags */
#define CLIENT_SLAVE (1<<0)   /* This client is a repliaca */
#define CLIENT_MASTER (1<<1)  /* This client is a master */
#define CLIENT_MONITOR (1<<2) /* This client is a slave monitor, see MONITOR */
#define CLIENT_MULTI (1<<3)   /* This client is in a MULTI context */
#define CLIENT_BLOCKED (1<<4) /* The client is waiting in a blocking operation */
#define CLIENT_DIRTY_CAS (1<<5) /* Watched keys modified. EXEC will fail. */
#define CLIENT_CLOSE_AFTER_REPLY (1<<6) /* Close after writing entire reply. */
#define CLIENT_UNBLOCKED (1<<7) /* This client was unblocked and is stored in
                                  server.unblocked_clients */
#define CLIENT_LUA (1<<8) /* This is a non connected client used by Lua */
#define CLIENT_ASKING (1<<9)     /* Client issued the ASKING command */
#define CLIENT_CLOSE_ASAP (1<<10)/* Close this client ASAP */
#define CLIENT_UNIX_SOCKET (1<<11) /* Client connected via Unix domain socket */
#define CLIENT_DIRTY_EXEC (1<<12)  /* EXEC will fail for errors while queueing */
#define CLIENT_MASTER_FORCE_REPLY (1<<13)  /* Queue replies even if is master */
#define CLIENT_FORCE_AOF (1<<14)   /* Force AOF propagation of current cmd. */
#define CLIENT_FORCE_REPL (1<<15)  /* Force replication of current cmd. */
#define CLIENT_PRE_PSYNC (1<<16)   /* Instance don't understand PSYNC. */
#define CLIENT_READONLY (1<<17)    /* Cluster client is in read-only state. */
#define CLIENT_PUBSUB (1<<18)      /* Client is in Pub/Sub mode. */
#define CLIENT_PREVENT_AOF_PROP (1<<19)  /* Don't propagate to AOF. */
#define CLIENT_PREVENT_REPL_PROP (1<<20)  /* Don't propagate to slaves. */
#define CLIENT_PREVENT_PROP (CLIENT_PREVENT_AOF_PROP|CLIENT_PREVENT_REPL_PROP)
#define CLIENT_PENDING_WRITE (1<<21) /* Client has output to send but a write
                                        handler is yet not installed. */
#define CLIENT_REPLY_OFF (1<<22)   /* Don't send replies to client. */
#define CLIENT_REPLY_SKIP_NEXT (1<<23)  /* Set CLIENT_REPLY_SKIP for next cmd */
#define CLIENT_REPLY_SKIP (1<<24)  /* Don't send just this reply. */
#define CLIENT_LUA_DEBUG (1<<25)  /* Run EVAL in debug mode. */
#define CLIENT_LUA_DEBUG_SYNC (1<<26)  /* EVAL debugging without fork() */
#define CLIENT_MODULE (1<<27) /* Non connected client used by some module. */
#define CLIENT_PROTECTED (1<<28) /* Client should not be freed for now. */
#define CLIENT_PENDING_READ (1<<29) /* The client has pending reads and was put
                                       in the list of clients we can read
                                       from. */
#define CLIENT_PENDING_COMMAND (1<<30) /* Used in threaded I/O to signal after
                                          we return single threaded that the
                                          client has already pending commands
                                          to be executed. */
#define CLIENT_TRACKING (1ULL<<31) /* Client enabled keys tracking in order to
                                   perform client side caching. */
#define CLIENT_TRACKING_BROKEN_REDIR (1ULL<<32) /* Target client is invalid. */
#define CLIENT_TRACKING_BCAST (1ULL<<33) /* Tracking in BCAST mode. */
#define CLIENT_TRACKING_OPTIN (1ULL<<34)  /* Tracking in opt-in mode. */
#define CLIENT_TRACKING_OPTOUT (1ULL<<35) /* Tracking in opt-out mode. */
#define CLIENT_TRACKING_CACHING (1ULL<<36) /* CACHING yes/no was given,
                                              depending on optin/optout mode. */
#define CLIENT_TRACKING_NOLOOP (1ULL<<37) /* Don't send invalidation messages
                                             about writes performed by myself.*/
#define CLIENT_IN_TO_TABLE (1ULL<<38) /* This client is in the timeout table. */
#define CLIENT_PROTOCOL_ERROR (1ULL<<39) /* Protocol error chatting with it. */

3、事务

3.1、multiState & multiCmd & 缓存队列

在开始阅读事务之前,我们先看看承载事务的两个数据结构:multiStatemultiCmd。在Redis中使用MULTI命令,来开启一个事务,事务的维度是基于Client维度的开启的,也就是说同一个Client底下,同一时刻只能开启一个事务,不然会抛出如下的错误:
在这里插入图片描述
开启事务后到EXEC命令执行之前,这期间该Client传入的命令都会保存在Client对象的缓存队列中,队列的结构基本如下:

//事务
typedef struct multiState {
    multiCmd *commands;     //命令集合的指针
    int count;              //总计命令数
    int cmd_flags;          //标记当前事物的操作类型
} multiState;

//实际执行的命令
typedef struct multiCmd {
    robj **argv;
    int argc;
    struct redisCommand *cmd;
} multiCmd;

在这里插入图片描述
那么开启事务后,命令是怎么入队的呢?这里需要回到processCommand这个方法中,之前的文章有提到过这个方法《8-命令处理生命周期-4》。在processCommand会先判断当前Client是否有开启事务,如果开启了事务并且不是exec discard watch multi这几个命令,则会将命令添加进入缓存队列中,代码如下所示:

int processCommand(client *c) {
 	....................省略............................
 	 //判断是否开启事物以及 是否为exec discard watch multi 这四个命令
 	if (c->flags & CLIENT_MULTI &&
        c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
        c->cmd->proc != multiCommand && c->cmd->proc != watchCommand) {
        queueMultiCommand(c);
        addReply(c, shared.queued);
    } else {
        call(c, CMD_CALL_FULL);
        c->woff = server.master_repl_offset;
        if (listLength(server.ready_keys))
            handleClientsBlockedOnKeys();
    }
    ....................省略............................
}    

基本逻辑如下图所示:
在这里插入图片描述

可以看到,如果不需要入队列会直接调用call方法执行命令,如果需要入队会调用queueMultiCommand将命令添加到队列中,而queueMultiCommand的实现如下所示:

//添加一个新的待执行命令进入缓存队列中
void queueMultiCommand(client *c) {
    multiCmd *mc;
    int j;
    
    //如果入队期间出现了错误,则没必要再入队了直接返回
    if (c->flags & CLIENT_DIRTY_EXEC)
        return;
    //分配添加一个 multiCmd 并将待执行的命令保存进去
    c->mstate.commands = zrealloc(c->mstate.commands,
            sizeof(multiCmd)*(c->mstate.count+1));
    mc = c->mstate.commands+c->mstate.count;
    mc->cmd = c->cmd;
    mc->argc = c->argc;
    mc->argv = zmalloc(sizeof(robj*)*c->argc);
    memcpy(mc->argv,c->argv,sizeof(robj*)*c->argc);
    for (j = 0; j < c->argc; j++)
        incrRefCount(mc->argv[j]);
    c->mstate.count++;
    //确认命令执行类型例如: 读->读=读,写->写=写,读->写=写
    c->mstate.cmd_flags |= c->cmd->flags;
}

入队的操作比较简单,基本上操作顺序如下:

  • 分配内存空间
  • 添加命令到新结点
  • 增加引用次数
  • 确认命令标记位

如果在入队前发生过之前入队失败的情况,那么会直接返回。

3.2、multiCommand

前面了解了事务的缓存队列和结构,以及命令如何进入缓存队列,下面来看一下Redis是如何开启事务的,Redis的事物是基于Client维度的,开始方式很简单,其实就是修改一个状态:

//执行开启事务命令
void multiCommand(client *c) {
    //判断当前事务是否已经开启,不允许事务嵌套
    if (c->flags & CLIENT_MULTI) {
        addReplyError(c,"MULTI calls can not be nested");
        return;
    }
    //更改当前Client事务状态
    c->flags |= CLIENT_MULTI;
    addReply(c,shared.ok);
}

3.3、execCommand

EXEC命令相对MULTI来说就会复杂一些,其实现如下所示:

void execCommand(client *c) {
    int j;
    robj **orig_argv;
    int orig_argc;
    struct redisCommand *orig_cmd;
    //传播标记
    int must_propagate = 0; //是否需要传播 MULTI/EXEC 给AOF 或者slaves
    int was_master = server.masterhost == NULL;
    //没开启事务不能直接执行EXEC
    if (!(c->flags & CLIENT_MULTI)) {
        addReplyError(c,"EXEC without MULTI");
        return;
    }
    //如果Lua脚本执行超时,将标记设置为CLIENT_DIRTY_EXEC以便事务失败,避免命令EXEC失败但是状态还是不理清理事务
    //github上面的 issue #7353
    if (server.lua_timedout) {
        flagTransaction(c);
        addReply(c, shared.slowscripterr);
        return;
    }
    //判断我们是否需要终止事务?
    //1、一些key的乐观锁被触发
    //2、在入队期间发生过错误被标记为 CLIENT_DIRTY_EXEC
    if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
        addReply(c, c->flags & CLIENT_DIRTY_EXEC ? shared.execaborterr :
                                                   shared.nullarray[c->resp]);
        discardTransaction(c);
        goto handle_monitor;
    }
    //判断事务标记是否正确,如果不正确结束事务并且清理内存
    if (!server.loading && server.masterhost && server.repl_slave_ro &&
        !(c->flags & CLIENT_MASTER) && c->mstate.cmd_flags & CMD_WRITE)
    {
        addReplyError(c,
            "Transaction contains write commands but instance "
            "is now a read-only replica. EXEC aborted.");
        discardTransaction(c);
        goto handle_monitor;
    }
    /* Exec all the queued commands */
    unwatchAllKeys(c); //取消 watch 节约CPU资源
    orig_argv = c->argv;
    orig_argc = c->argc;
    orig_cmd = c->cmd;
    addReplyArrayLen(c,c->mstate.count);
    //循环执行缓存队列中的命令
    for (j = 0; j < c->mstate.count; j++) {
        c->argc = c->mstate.commands[j].argc;
        c->argv = c->mstate.commands[j].argv;
        c->cmd = c->mstate.commands[j].cmd;
        //当遇到第一个不是只读或者Admin的命令
        //可以通过构造一个 MULTI/..../EXEC 块传给 AOF 或者 replication来保证原子性
        if (!must_propagate &&
            !server.loading &&
            !(c->cmd->flags & (CMD_READONLY|CMD_ADMIN)))
        {
            execCommandPropagateMulti(c);
            must_propagate = 1;
        }

        int acl_keypos;
        int acl_retval = ACLCheckCommandPerm(c,&acl_keypos);
        if (acl_retval != ACL_OK) {
            addACLLogEntry(c,acl_retval,acl_keypos,NULL);
            addReplyErrorFormat(c,
                "-NOPERM ACLs rules changed between the moment the "
                "transaction was accumulated and the EXEC call. "
                "This command is no longer allowed for the "
                "following reason: %s",
                (acl_retval == ACL_DENIED_CMD) ?
                "no permission to execute the command or subcommand" :
                "no permission to touch the specified keys");
        } else {
            //调用call方法执行命令
            call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL);
        }
        c->mstate.commands[j].argc = c->argc;
        c->mstate.commands[j].argv = c->argv;
        c->mstate.commands[j].cmd = c->cmd;
    }
    c->argv = orig_argv;
    c->argc = orig_argc;
    c->cmd = orig_cmd;
    //结束清理事务
    discardTransaction(c);
    //判断命令是否需要传播
    if (must_propagate) {
        int is_master = server.masterhost == NULL;
        server.dirty++;
        //如果在事务期间,当前服务器主服务器变成从服务器
        //将会把缓存队列里面的命令交给新主服务器执行,并给从服务器单独追加一个EXEC命令
        if (server.repl_backlog && was_master && !is_master) {
            //追加EXEC命令
            char *execcmd = "*1\r\n$4\r\nEXEC\r\n";
            feedReplicationBacklog(execcmd,strlen(execcmd));
        }
    }

handle_monitor:
    if (listLength(server.monitors) && !server.loading)
        replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
}

从上面可以看成EXEC命令做了如下的动作:

  • 1、 判断当前是否通过MULTI已经开启事务,如果没有开启直接返回;
  • 2、 判断当前Lua脚本执行是否超时,如果超时则设置CLIENT_DIRTY_EXEC以便后续的事务失败;
  • 3、 判断是否需要终止事务,终止条件:A、事务中一些key的乐观锁被触发 或者 B、在入队期间发生过错误被标记为 CLIENT_DIRTY_EXEC
  • 4、 判断事务的标记是否正确,如果不正确终止事务并释放内存;
  • 5、 取消watchkeys监视,节省CPU资源;
  • 6、 遍历队列并且执行命令,如果设置AOFslaves需要传播MULTI/EXEC,会追加MULTI命令;
  • 7、 结束事务清理内存;
  • 8、 传播执行完成的命令给从库;
    流程图如下所示:
    在这里插入图片描述

此外,在执行execCommand的时候,是通过如下的校验去判断当前是否触发了WATCH或者入缓存队列失败的情况:

  if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
        addReply(c, c->flags & CLIENT_DIRTY_EXEC ? shared.execaborterr :
                                                   shared.nullarray[c->resp]);
        discardTransaction(c);
        goto handle_monitor;
    }

CLIENT_DIRTY_CAS(WATCH触发)CLIENT_DIRTY_EXEC(入队错误)这两个标记是在client->flags,当发生标记中的错误时候,client->flags会被标记上这两种状态,然后当实际执行EXEC的时候会去判断,是否触发,如果触发则EXEC执行失败。

3.4、watchCommand

Redis中的WATCH命令主要是用于监视一个,或者多个key,通过乐观锁的方式监视其是否被修改。若被修改,则在执行事务之前终止事务,不执行所有命令,从而保证共享数据的安全性。WATCH需要在调用MULTI之前执行。当一个key被WATCH的时候,在内存中的状态如下图所示:
在这里插入图片描述
从图中可以看出,存在两个watched_keys,这里我把watched_keys看成是一种索引,分别有DB维度的索引watched_keys(dict)client维度的索引watched_keys(list),其中DB维度的索引保存了当前DB中被WATCH key和其对应的clientclient维度的索引保存了当前client正在WATCHkey。当执行WATCH的时候,本质上就是往这两个索引中添加,代码如下

void watchCommand(client *c) {
    int j;
    if (c->flags & CLIENT_MULTI) {
        addReplyError(c,"WATCH inside MULTI is not allowed");
        return;
    }
    //argc 这里是输入的keys
    for (j = 1; j < c->argc; j++)
        watchForKey(c,c->argv[j]);
    addReply(c,shared.ok);
}

//client watch 对应的 key
void watchForKey(client *c, robj *key) {
    list *clients = NULL;
    listIter li;
    listNode *ln;
    watchedKey *wk;
    
    //如果当前client已经watch这个key直接返回
    listRewind(c->watched_keys,&li);
    while((ln = listNext(&li))) {
        wk = listNodeValue(ln);
        if (wk->db == c->db && equalStringObjects(key,wk->key))
            return; /* Key already watched */
    }
    //当前db的这个key没被watch的添加到watch字典中
    clients = dictFetchValue(c->db->watched_keys,key);
    if (!clients) {
        clients = listCreate();
        dictAdd(c->db->watched_keys,key,clients);//添加进字典
        incrRefCount(key); //增加被引用次数
    }
    listAddNodeTail(clients,c);
    //将当前这个key添加到client的watched_keys链表中
    wk = zmalloc(sizeof(*wk));
    wk->key = key;
    wk->db = c->db;
    incrRefCount(key); //增加被引用次数
    listAddNodeTail(c->watched_keys,wk);
}

添加WATCH后,最重要的就是如何触发WATCH了,目前Redis的触发方式是通过signalModifiedKey -> touchWatchedKey的方式触发。当每次调用完成,UPDATE类型的命令时候,Redis会调用signalModifiedKey ,去触发WATCH,其代码实现如下所示:

void signalModifiedKey(client *c, redisDb *db, robj *key) {
    touchWatchedKey(db,key);
    trackingInvalidateKey(c,key);
}

//触发被watch的key
void touchWatchedKey(redisDb *db, robj *key) {
    list *clients;
    listIter li;
    listNode *ln;
    if (dictSize(db->watched_keys) == 0) return;
    //通过watched_keys 字典查找当前watch的clients
    clients = dictFetchValue(db->watched_keys, key);
    //当前没有watch直接返回
    if (!clients) return;
    //如果存在这个key被watch,所有的client都被标记 CLIENT_DIRTY_CAS
    listRewind(clients,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        c->flags |= CLIENT_DIRTY_CAS;
    }
}

在这里插入图片描述
触发WATCH触发,就是通过DB维度的watched_keys找到当前正在WATCHclient,让后将这些client标记上CLIENT_DIRTY_CAS,以便在后续EXEC的时候,直接失败。

4、Lua

Redis引入Lua脚本有一下的优势:

  • 减少网络开销: 多个命令通过脚本一次发送,充分利用网络资源
  • 原子操作: 将脚本作为一个整体执行,中间不会插入其他命令,无需使用事务
  • 复用性: 客户端发送的脚本永久存在Redis中,其他客户端可以复用脚本
  • 可嵌入性: 可嵌入多种编程语言,支持不同操作系统跨平台交互

Lua脚本有两种方式进入Redis

  • 1、client通过socket整合传入(传入后可以保存在Redis中);
  • 2、Redis加载Lua文件

驻留在RedisLua脚本将会保存在server.lua_scripts 这个字典中:
在这里插入图片描述
Redis中,脚本字典有两个一个是server.lua_scripts另一个则是server.repl_scriptcache_dict,第二个字段主要是用来主从同步Lua驻留脚本使用的

4.1、Lua初始化

在前面的文章《8-命令处理生命周期-3》中有提到,Redis在初始化的时候会调用initServer对服务端内部的一些内容进行初始化,Lua脚本的执行环境也是在这个时候初始化的,其调用的方法是scriptingInit,其代码主要如下:

void scriptingInit(int setup) {
	//初始化Lua环境,返回Lua的帧栈
    lua_State *lua = lua_open();
    //初始化Lua相关的值
    if (setup) {
        server.lua_client = NULL;
        server.lua_caller = NULL;
        server.lua_cur_script = NULL;
        server.lua_timedout = 0;
        //初始化Lua Debugger 数值
        ldbInit();
    }
    //初始化保存Lua的字典,和脚本占用内存大小 lua_scripts_mem
    server.lua_scripts = dictCreate(&shaScriptObjectDictType,NULL);
    server.lua_scripts_mem = 0;
    //加载必要的Lua lib 内库
    luaLoadLibraries(lua);
    //移除不支持的Lua方法
    luaRemoveUnsupportedFunctions(lua);
    //创建Redis伪装客户端 redis.call
    lua_pushstring(lua,"call");
    lua_pushcfunction(lua,luaRedisCallCommand);
    lua_settable(lua,-3);
    //创建Redis伪装客户端 redis.pcall
    lua_pushstring(lua,"pcall");
    lua_pushcfunction(lua,luaRedisPCallCommand);
    lua_settable(lua,-3);
..........................省略(后面都是添加一些Redis的伪装方法)........................
}

上面代码中,Redis会初始化Lua环境,并且创建伪装客户端redis.callredis.pcall,通过这两个客户端可以直接执行Redis的命令,对应的执行方法是luaRedisCallCommandluaRedisPCallCommand
在这里插入图片描述
伪装客户端有 redis.callredis.pcall,这两者的区别是,redis.call会将Lua执行中出现的异常抛出,从而打断Lua脚本的执行,而redis.pcall不会,所以使用redis.pcall的时候要谨慎。

此外Redis为了方便对接Lua还提供了一个lua_State的结构体,具体结构内容就不展开了,因为涉及跨语言对接的问题。通过lua_State Redis可以实现对Lua脚本的操作,lua_State 保存在server.lua中,由于Redis处理了命令是单线程的,所以一个Redis实例下的所有Client共用一个lua_State

 //这里也可以称lua_State 为Lua翻译器
 lua_State *lua; /* The Lua interpreter. We use just one for all clients */

4.2、scriptCommand

SCRIPT命令主要是用来管理Redis中的脚本,比如脚本加载创建,开启调试模式,删除驻留脚本的操作,目前支持的命令输入有:DEBUG EXISTS FLUSH KILL LOAD

  • DEBUG开启脚本调试,并设置调试类型;
  • EXISTS 判断当前脚本的SHA值对应的脚本是否存在;
  • FLUSH 清空脚本缓存,把当前Redis里面保存的Lua脚本都清空;
  • KILL 杀死目前正在运行的脚本;
  • LOAD读取脚本到Redis字典中驻留,并返回SHA

4.2.1、DEBUG

DEBUG的目的主要是为了支持Lua脚本的调试,启主要入参有:no(关闭调试) yes(开启调试) sync(同步调试)这三种,通过设置CLIENT_LUA_DEBUGCLIENT_LUA_DEBUG_SYNC值,来判断是否需要DEBUG,Redis中为了方便对接Lua的断点,抽象出了一个结构体ldbState

#define LDB_BREAKPOINTS_MAX 64  //最大断点数量
#define LDB_MAX_LEN_DEFAULT 256 //默认maxlen 大小
//ldb的结构体
struct ldbState {
    connection *conn; //当前Debug的客户端连接
    int active; //当前Eval脚本执行断点激活标记 
    int forked; //当前是否fork出session进行Debug  
    list *logs; //需要发送给Client的Logs 
    list *traces; //执行过的命令追踪
    list *children; //所有fork出来子进程的pids 
    int bp[LDB_BREAKPOINTS_MAX]; //所有断点的行数(最大上限64个断点) 
    int bpcount; //有效的断点数量 
    int step;  //下一步行执行位置 
    int luabp;//下一步断点位置  
    sds *src;//Lua脚本的每一行  
    int lines;//脚本总行数 
    int currentline;//当前行号   
    sds cbuf;//Debug 时候 client 的命令缓冲区  
    size_t maxlen;// dump/reply 的最大空间大小  
    int maxlen_hint_sent; //是否有设置过maxlen 标记
} ldb;

可以看出ldbState维护了一系列Lua断点有关的内容 ,是基于当前Lua环境通过这些属性,Redis可以保存并且对接上Lua的断点信息,并通过Redis本身暴露出来,具体就先不展开说明了,这里涉及到不用语言之间的交互问题,需要注意的是ldbState是维护全局Debug配置状态的地方,如果有一个client需要开启断点,只需要打标记就可以对接上当前已经初始化好的Debug环境,并不需要在对Debug环境进行一个初始化了
在这里插入图片描述
Redis只需要给相应需要开断点的client设置CLIENT_LUA_DEBUG标记位即可让当前client支持Lua断点,注意同一时刻只允许一个client进行断点

else if (c->argc == 3 && !strcasecmp(c->argv[1]->ptr,"debug")) {
        if (clientHasPendingReplies(c)) {
            addReplyError(c,"SCRIPT DEBUG must be called outside a pipeline");
            return;
        }
        if (!strcasecmp(c->argv[2]->ptr,"no")) {
        	//关闭Debug
            ldbDisable(c);
            addReply(c,shared.ok);
        } else if (!strcasecmp(c->argv[2]->ptr,"yes")) {
        	//开启Debug
            ldbEnable(c);
            addReply(c,shared.ok);
        } else if (!strcasecmp(c->argv[2]->ptr,"sync")) {
        	//开启Debug
            ldbEnable(c);
            addReply(c,shared.ok);
            c->flags |= CLIENT_LUA_DEBUG_SYNC;
        } else {
            addReplyError(c,"Use SCRIPT DEBUG yes/sync/no");
            return;
        }
    }
........................省略.........................
}
//关闭Debug
void ldbDisable(client *c) {
    c->flags &= ~(CLIENT_LUA_DEBUG|CLIENT_LUA_DEBUG_SYNC);
}

//开启Debug 并且设置一些断点需要的内容
void ldbEnable(client *c) {
    c->flags |= CLIENT_LUA_DEBUG; //添加Debug标记
    ldbFlushLog(ldb.logs);
    ldb.conn = c->conn;
    ldb.step = 1;
    ldb.bpcount = 0;
    ldb.luabp = 0;
    sdsfree(ldb.cbuf);
    ldb.cbuf = sdsempty();
    ldb.maxlen = LDB_MAX_LEN_DEFAULT;
    ldb.maxlen_hint_sent = 0;
}

4.2.2、EXISTS

EXISTS用来判断目标sha值对应的脚本是否存在,整体逻辑相对比较简单,通过shakey值去Lua脚本的字典中去找,来判断脚本是否存在

........................省略.........................
else if (c->argc >= 2 && !strcasecmp(c->argv[1]->ptr,"exists")) {
        int j;
        addReplyArrayLen(c, c->argc-2);
        for (j = 2; j < c->argc; j++) {
        	//在字典中查找,判断脚本是否存在
            if (dictFind(server.lua_scripts,c->argv[j]->ptr))
                addReply(c,shared.cone);
            else
                addReply(c,shared.czero);
        }
    }
........................省略.........................    

4.2.3、FLUSH

FLUSH的作用是清空当前Redis中的驻留脚本,其本质就是清空Redis中保存脚本的字典server.repl_scriptcache_dictserver.lua_scripts

else if (c->argc == 2 && !strcasecmp(c->argv[1]->ptr,"flush")) {
        scriptingReset(); //清空 server.lua_scripts
        addReply(c,shared.ok);
        replicationScriptCacheFlush(); // 清空 server.repl_scriptcache_dict
        server.dirty++; //使当前命令被传播给从库
    }

4.2.4、KILL

KILL会杀死当前正在运行的Lua脚本,避免发生Lua脚本死循环导致阻塞整个Redis的情况

else if (c->argc == 2 && !strcasecmp(c->argv[1]->ptr,"kill")) {
        if (server.lua_caller == NULL) { 
            addReplySds(c,sdsnew("-NOTBUSY No scripts in execution right now.\r\n"));
        } else if (server.lua_caller->flags & CLIENT_MASTER) {
            addReplySds(c,sdsnew("-UNKILLABLE The busy script was sent by a master instance in the context of replication and cannot be killed.\r\n"));
        } else if (server.lua_write_dirty) {
            addReplySds(c,sdsnew("-UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.\r\n"));
        } else {
            server.lua_kill = 1;
            addReply(c,shared.ok);
        }
    }

KILL的主要流程如下:

  • 1、 判断当前是否有Lua正在运行,如果没有直接返回错误信息;
  • 2、 判断当前的脚本是否为MASTER发送给从库执行的,如果是也直接返回错误信息;
  • 3、 判断当前的脚本是否在执行写操作,如果有也不允许杀死;
  • 4、 以上条件都不满足,直接标记lua_kill异步进行杀死;

4.2.5、LOAD

LOAD的主要作用是读取Lua脚本并保存在Redis中,形成驻留脚本

else if (c->argc == 3 && !strcasecmp(c->argv[1]->ptr,"load")) {
        sds sha = luaCreateFunction(c,server.lua,c->argv[2]);  //添加脚本到字典中
        if (sha == NULL) return; /* The error was sent by luaCreateFunction(). */
        addReplyBulkCBuffer(c,sha,40);
        forceCommandPropagation(c,PROPAGATE_REPL|PROPAGATE_AOF); //设置强制传播当前LOAD的命令	
    } 

从上面看出load的最主要的方法是luaCreateFunction,在luaCreateFunction中主要做了一下几个内容:

  • 1、计算这段脚本的sha1值;
  • 2、通过sha1值判断当前脚本是否存在,若存在直接返回sha1值;
  • 3、拼接lua脚本的方法名,使用sha1值作为函数名称;
  • 4、将lua载入编译内存;
  • 5、尝试跑lua脚本;
  • 6、若第4,5步都没有问题将lua脚本保存在server.lua_scripts字典中;

luaCreateFunction代码如下所示:

sds luaCreateFunction(client *c, lua_State *lua, robj *body) {
    char funcname[43];
    dictEntry *de;

    funcname[0] = 'f';
    funcname[1] = '_';
    sha1hex(funcname+2,body->ptr,sdslen(body->ptr));//计算值sha1

    sds sha = sdsnewlen(funcname+2,40); //sha值最长42位
    //判断当前脚本是否存在,如果存在直接返回hash值
    if ((de = dictFind(server.lua_scripts,sha)) != NULL) {
        sdsfree(sha);
        return dictGetKey(de);
    }

    //这块代码主要作用是,通过字符串拼接吧传入的lua脚本编程一个lua函数,函数名称是sha值
    sds funcdef = sdsempty();
    funcdef = sdscat(funcdef,"function ");
    funcdef = sdscatlen(funcdef,funcname,42);//使用sha值作为函数名称
    funcdef = sdscatlen(funcdef,"() ",3);
    funcdef = sdscatlen(funcdef,body->ptr,sdslen(body->ptr));
    funcdef = sdscatlen(funcdef,"\nend",4);
    //将lua脚本载入编译内存中
    if (luaL_loadbuffer(lua,funcdef,sdslen(funcdef),"@user_script")) {
        if (c != NULL) {
            addReplyErrorFormat(c,
                "Error compiling script (new function): %s\n",
                lua_tostring(lua,-1));
        }
        lua_pop(lua,1);
        sdsfree(sha);
        sdsfree(funcdef);
        return NULL;
    }
    sdsfree(funcdef);
    //尝试跑脚本(主要检查语法)
    if (lua_pcall(lua,0,0,0)) {
        if (c != NULL) {
            addReplyErrorFormat(c,"Error running script (new function): %s\n",
                lua_tostring(lua,-1));
        }
        lua_pop(lua,1);
        sdsfree(sha);
        return NULL;
    }

    //将脚本保存在字典中
    int retval = dictAdd(server.lua_scripts,sha,body);
    serverAssertWithInfo(c ? c : server.lua_client,NULL,retval == DICT_OK);
    server.lua_scripts_mem += sdsZmallocSize(sha) + getStringObjectSdsUsedMemory(body);
    incrRefCount(body);
    return sha;
}

4.3、evalCommand & evalShaCommand

evalCommandEVAL命令的执行函数而evalShaCommand 则是 EVALSHA,本质上都是执行Lua脚本,一个是传入Lua脚本直接执行,一个是执行驻留脚本,底层逻辑是互通的主要都调用了evalGenericCommand函数实现,执行流程如下所示:
在这里插入图片描述
通过流程图可以看出,在实际执行脚本的时候本质上是直接通过对函数名称的压栈完成调用的,而server.lua_scripts的最要目的是为了传播脚本,而驻留脚本质上创建后会留在编译内存中,Redis通过压栈函数名称,来实现函数的调用,编译内存的结构入下图所示:

在这里插入图片描述
evalGenericCommand代码实现如下:

void evalGenericCommand(client *c, int evalsha) {
    lua_State *lua = server.lua;
    char funcname[43];
    long long numkeys;
    long long initial_server_dirty = server.dirty;
    int delhook = 0, err;
    
    //为了保证每次调用脚本的时候都能生成一样的随机数,具体可以去了解srand() 和 rand()的区别
    redisSrand48(0);

.................................[省略]................................
  
    /* We obtain the script SHA1, then check if this function is already
     * defined into the Lua state */
    funcname[0] = 'f';
    funcname[1] = '_';
    //判断入参是否有传入sha1值,如果没有是则是直接调用EVAL
    if (!evalsha) {
        /* Hash the code if this is an EVAL call */
        //EVAL在每次调用的时候都计算sha1值
        sha1hex(funcname+2,c->argv[1]->ptr,sdslen(c->argv[1]->ptr));
    } else {
        /* We already have the SHA if it is a EVALSHA */
        int j;
        char *sha = c->argv[1]->ptr;

        //将sha1值装换为小写
        for (j = 0; j < 40; j++)
            funcname[j+2] = (sha[j] >= 'A' && sha[j] <= 'Z') ?
                sha[j]+('a'-'A') : sha[j];
        funcname[42] = '\0';
    }
  
    //将redis处理 error的lua函数名称压栈
    lua_getglobal(lua, "__redis__err__handler");

    /* Try to lookup the Lua function */
    //查找lua脚本通过函数名字,如果找不到,并且是evalSha的话返回异常
    //这里本质上是函数名称压栈
    lua_getglobal(lua, funcname);
    if (lua_isnil(lua,-1)) {
        lua_pop(lua,1); /* remove the nil from the stack */ //函数没有定义,将空的函数指针nil出栈,并且创建一个函数,因为是使用EVAL的方式执行脚本
        /* Function not defined... let's define it if we have the
         * body of the function. If this is an EVALSHA call we can just
         * return an error. */
        if (evalsha) {
            lua_pop(lua,1); /* remove the error handler from the stack. */ //移除ERROR的处理函数
            addReply(c, shared.noscripterr);
            return;
        }
        //如果是调用EVAL则创建一个脚本函数
        if (luaCreateFunction(c,lua,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_getglobal(lua, funcname);
        serverAssert(!lua_isnil(lua,-1));
    }

    /* Populate the argv and keys table accordingly to the arguments that
     * EVAL received. */
    luaSetGlobalArray(lua,"KEYS",c->argv+3,numkeys);
    luaSetGlobalArray(lua,"ARGV",c->argv+3+numkeys,c->argc-3-numkeys);

    //设置一个lua钩子,如果lua脚本运行超时可以通过这个钩子停止脚本执行
    server.lua_caller = c;
    server.lua_cur_script = funcname + 2;
    server.lua_time_start = mstime();
    server.lua_kill = 0;
    //判断是否为调试模式
    if (server.lua_time_limit > 0 && ldb.active == 0) {
        lua_sethook(lua,luaMaskCountHook,LUA_MASKCOUNT,100000);
        delhook = 1;
    } else if (ldb.active) {
        lua_sethook(server.lua,luaLdbLineHook,LUA_MASKLINE|LUA_MASKCOUNT,100000);
        delhook = 1;
    }
    //执行前准备Lua客户端
    prepareLuaClient();
    /* At this point whether this script was never seen before or if it was
     * already defined, we can call it. We have zero arguments and expect
     * a single return value. */
    //执行Lua脚本
    err = lua_pcall(lua,0,1,-2);
    //执行后设置Lua客户端
    resetLuaClient();
    /* Perform some cleanup that we need to do both on error and success. */
    //无论成功还是失败都要执行的清理函数
    if (delhook) lua_sethook(lua,NULL,0,0); /* Disable hook */
    if (server.lua_timedout) {
        server.lua_timedout = 0;
        /* Restore the client that was protected when the script timeout
         * was detected. */
        //执行超时就不传播脚本
        unprotectClient(c);
        if (server.masterhost && server.master)
            queueClientForReprocessing(server.master);
    }
    server.lua_caller = NULL;
    server.lua_cur_script = NULL;

    /* 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. */
     //调用Lua gc ,并不会每次调用当达到LUA_GC_CYCLE_PERIOD 的时候才调用
    #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;
        }
    }

.................................[省略]................................  

    // EVALSHA 的执行需要 完整类型执行EVAL的方式传播给 Slave,除非所有 Slave已经有这些脚本
    // 判断是否需要传播Lua脚本,如果是驻留脚本,执行后需要通过server.lua_scripts将
    // 执行后的命令同步出去
    if (evalsha && !server.lua_replicate_commands) {
        if (!replicationScriptCacheExists(c->argv[1]->ptr)) {
            //脚本不在replication缓存中,通过server.lua_scripts找到脚本通过EVAL的方式执行
            robj *script = dictFetchValue(server.lua_scripts,c->argv[1]->ptr);
            //添加到replication缓存中
            replicationScriptCacheAdd(c->argv[1]->ptr);
            serverAssertWithInfo(c,NULL,script != NULL);

            //如果这个脚本是只读脚本,那么传播只以 SCRIPT LOAD的方式,传播后不会执行脚本
            //否则 需要调用 EVAL 的方式执行脚本
            if (server.dirty == initial_server_dirty) {
                rewriteClientCommandVector(c,3,
                    resetRefCount(createStringObject("SCRIPT",6)),
                    resetRefCount(createStringObject("LOAD",4)),
                    script);
            } else {
                rewriteClientCommandArgument(c,0,
                    resetRefCount(createStringObject("EVAL",4)));
                rewriteClientCommandArgument(c,1,script);
            }
            forceCommandPropagation(c,PROPAGATE_REPL|PROPAGATE_AOF);
        }
    }
}

5、总结

本篇文章主要介绍了,Lua嵌入脚本和Redis事务的执行和在Redis里面的大概实现,通过二者的实现区别能让大家理解事务Lua的优劣性,Lua可以帮助Redis实现复杂的功能,但是使用门槛高,对开发者有一定的要求,事务能快速使用已经提供的命令方便开发。各有各的好处,就看你的场景选择哪个更加好了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值