深入浅出Redis client/server交互流程

作者 赵景波

综述

最近笔者阅读并研究Redis源码,在Redis客户端与服务器端交互这个内容点上,需要参考网上一些文章,但是遗憾的是发现大部分文章都断断续续的非系统性的,不能给读者此交互流程的整体把握。所以这里我尝试,站在源码的角度,将Redis client/server交互流程尽可能简单地展现给大家,同时也站在DBA的角度给出一些日常工作中注意事项。

Redis client/server交互步骤分为以下6个步骤:

1. Client发起socket连接

2. Server接受socket连接

3.客户端开始写入

4. Server端接收写入

5. Server返回写入结果

6. Client收到返回结果

注:为使文章尽可能简洁,这里只讨论客户端命令写入的过程,不讨论客户端命令读取的流程。

在进一步阅读和了解互动流程之前,请大家确保已经熟练掌握了Linux Socket建立流程和epoll I/O多路复用技术两个技术点,这对文章内容的理解至关重要。

交互的整体流程

在介绍6个步骤之前,首先看一下Redis client/server交互流程整体的程序执行流程图:

上图中6个步骤分别用不同的颜色箭头表示,并且最终结果也用相对应的颜色标识。

首先看看绿色框里面的循环执行的方法,最末是epoll_wait方法,即等待事件产生的方法。然后再看第2、4、5步骤的末尾都有epoll_ctl方法,即epoll事件注册函数。关于epoll的相关技术解析请参看文末一段。

在这里的循环还有个beforeSleep方法,其实它跟我们这次讨论的话题没有太大的关系。但是还是想给大家介绍一下。

beforeSleep方法主要做以下几件事:

• 执行一次快速的主动过期检查,检查是否有过期的key

• 当有客户端阻塞时,向所有从库发送ACK请求

• unblock在同步复制时候被阻塞的客户端

• 尝试执行之前被阻塞客户端的命令

• 将AOF缓冲区的内容写入到AOF文件中

如果是集群,将会根据需要执行故障迁移、更新节点状态、保存node.conf配置文件。

如此,redis整个事件管理器机制就比较清楚了。接下来进一步探讨并理解事件是如何触发并创建。

交互的六大步骤

下面正式开始介绍redis client/server交互的6大步骤

一、Client发起socket连接

这里以redis-cli客户端为例,当执行以下语句时:

        [root@zbdba redis-3.0]# ./src/redis-cli -p 6379-h 127.0.0.1127.0.0.1:6379>

客户端会做如下操作:

1、获取客户端参数,如端口、ip地址、dbnum、socket等

也就是我们执行./src/redis-cli --help中列出的参数

2、根据用户指定参数确定客户端处于哪种模式

目前共有:

        Latency mode/Slave mode/Get RDB mode/Pipe mode/Find
        big keys/Stat mode/Scan mode/Intrinsic latency mode

以上8种模式

例如:stat模式

        [root@zbdba redis-3.0]# ./src/redis-cli -p 6379-h 127.0.0.1
        --stat
        ------- data ------ --------------------- load ---------------
        ----- - child -
        keys            mem           clients  blocked  requests
        connections
        1          817.18K  2       0       1 (+0)               2
        1          817.18K  2       0       2 (+1)               2
        1          817.18K  2       0       3 (+1)               2
        1          817.18K  2       0       4 (+1)               2
        1          817.18K  2       0       5 (+1)               2
        1          817.18K  2       0       6 (+1)               2

我们这里没有指定,就是默认的模式。

3、进入上图中step1的cliConnect方法,cliConnect主要包含redisConnect、redisConnectUnix方法。这两个方法分别用于TCP Socket连接以及Unix Socket连接,Unix Socket用于同一主机进程间的通信。我们上面是采用的TCP Socket连接方式也就是我们平常生产环境常用的方式,这里不讨论Unix Socket连接方式,如果要使用Unix Socket连接方式,需要配置unixsocket参数,并且按照下面方式进行连接:

        [root@zbdba redis-3.0]# ./src/redis-cli -s /tmp/redis.sock
        redis /tmp/redis.sock>

4、进入redisContextInit方法,redisContextInit方法用于创建一个Context结构体保存在内存中,如下:

        /* Context for a connection to Redis */
        typedef struct redisContext {
            int err; /* Error flags, 0 when there is no error */
              char errstr[128]; /* String representation of error when applicable */
            int fd;
            int flags;
            char *obuf; /* Write buffer */
            redisReader *reader; /* Protocol reader */
        } redisContext;

主要用于保存客户端的一些东西,最重要的就是write buffer和redisReader, write buffer用于保存客户端的写入,redisReader用于保存协议解析器的一些状态。

5、进入redisContextConnectTcp方法,开始获取IP地址和端口用于建立连接,主要方法如下:

        s = socket(p->ai_family, p->ai_socktype, p->ai_protocol
        connect(s, p->ai_addr, p->ai_addrlen)

到此客户端向服务端发起建立socket连接,并且等待服务器端响应。

当然cliConnect方法中还会调用cliAuth方法用于权限验证、cliSelect用于db选择,这里不着重讨论。

二、Server接受socket连接

服务器接收客户端的请求首先是从epoll_wait取出相关的事件,然后进入上图中step2中的方法,执行acceptTcpHandler或者acceptUnixHandler方法,那么这两个方法对应的事件是在什么时候注册的呢?他们是在服务器端初始化的时候创建。下面看看服务器端在初始化的时候与socket相关的地方:

1、打开TCP监听端口

            if (server.port ! = 0 &&
                    listenToPort(server.port, server.ipfd, &server.ipfd_count) == REDIS_ERR)
                exit(1);

2、打开Unix本地端口

          if (server.unixsocket ! = NULL) {
                unlink(server.unixsocket); /* don't care if this fails */
                    server.sofd = anetUnixServer(server.neterr, server. unixsocket,
                    server.unixsocketperm, server.tcp_backlog);
                if (server.sofd == ANET_ERR) {
                        redisLog(REDIS_WARNING, "Opening socket: %s", server.neterr);
                    exit(1);
                }
                anetNonBlock(NULL, server.sofd);
            }

3、为TCP连接关联连接应答处理器(accept)

        for (j = 0; j < server.ipfd_count; j++) {
                  if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
                    acceptTcpHandler, NULL) == AE_ERR)
                    {
                        redisPanic(
                              "Unrecoverable error creating server.ipfd file event.");
                    }
            }

4、为Unix Socket关联应答处理器

        if (server.sofd > 0 && aeCreateFileEvent
              (server.el, server.sofd, AE_READABLE,
                acceptUnixHandler, NULL) == AE_ERR)
        redisPanic("Unrecoverable  error  creating  server.sofd  file event.");

在1/2步骤涉及到的方法中是Linux Socket的常规操作,获取IP地址,端口。最终通过socket、bind、listen方法建立起Socket监听。也就是上图中acceptTcpHandler和acceptUnixHandler下面对应的方法。

在3/4步骤涉及到的方法中采用aeCreateFileEvent方法创建相关的连接应答处理器,在客户端请求连接的时候触发。

所以现在整个socket连接建立流程就比较清楚了,如下:

• 服务器初始化建立socket监听

• 服务器初始化创建相关连接应答处理器,通过epoll_ctl注册事件

• 客户端初始化创建socket connect请求

• 服务器接受到请求,用epoll_wait方法取出事件

服务器执行事件中的方法(acceptTcpHandler/acceptUnixHandler)并接受socket连接

至此客户端和服务器端的socket连接已经建立,但是此时服务器端还继续做了2件事:

采用createClient方法在服务器端为客户端创建一个client,因为I/O复用所以需要为每个客户端维持一个状态。这里的client也在内存中分配了一块区域,用于保存它的一些信息,如套接字描述符、默认数据库、查询缓冲区、命令参数、认证状态、回复缓冲区等。这里提醒一下DBA同学关于client-output-buffer-limit设置,设置不恰当将会引起客户端中断。

采用aeCreateFileEvent方法在服务器端创建一个文件读事件并且绑定readQueryFromClient方法。

可以从图中得知,aeCreateFileEvent调用aeApiAddEvent方法最终通过epoll_ctl方法进行注册事件。

三、客户端开始写入

客户端在与服务器端建立好socket连接之后,开始执行上图中step3的repl方法。从图中可知repl方法接受输入输出主要是采用linenoise插件。当然这是针对redis-cli客户端哦。linenoise是一款优秀的命令行编辑库,被广泛的运用在各种DB上,如Redis、MongoDB,这里不详细讨论。客户端写入流程分为以下几步:

1、linenoise等待接受用户输入

2、linenoise将用户输入内容传入cliSendCommand方法,cliSendCommand方法会判断命令是否为特殊命令,如:

        help
        info
        cluster nodes
        cluster info
        client list
        shutdown
        monitor
        subscribe
        psubscribe
        sync
        psync

客户端会根据以上命令设置对应的输出格式以及客户端的模式,因为这里我们是普通写入,所以不会涉及到以上的情况。

3、cliSendCommand方法会调用redisAppendCommandArgv方法,redisAppendCommandArgv方法会调用redisFormatCommandArgv和 __redisAppendCommand方法

redisFormatCommandArgv方法用于将客户端输入的内容格式化成redis协议:

例如:

        set zbdba jingbo
        *3\r\n$3\r\n set\r\n $5\r\n zbdba\r\n $6\r\n jingbo

__redisAppendCommand方法用于将命令写入到outbuf中

接着客户端进入下一个流程,将outbuf内容写入到套接字描述符上并传输到服务器端。

4、进入redisGetReply方法,该方法下主要有redisGetReplyFromReader和redisBufferWrite方法,redisGetReplyFromReader主要用于读取挂起的回复,redisBufferWrite方法用于将当前outbuf中的内容写入到套接字描述符中,并传输内容。

主要方法如下:

        nwritten = write(c->fd, c->obuf, sdslen(c->obuf));

此时客户端等待服务器端接收写入。

四、Server端接收写入

服务器端依然在进行事件循环,在客户端发来内容的时候触发,对应的文件读取事件。这就是之前创建socket连接的时候建立的事件,该事件绑定的方法是readQueryFromClient。此时进入step4的readQueryFromClient方法。

readQueryFromClient方法用于读取客户端的发送的内容。它的执行步骤如下:

1、在readQueryFromClient方法中从服务器端套接字描述符中读取客户端的内容到服务器端初始化client的查询缓冲中,主要方法如下:

        nread = read(fd, c->querybuf+qblen, readlen);

2、交给processInputBuffer处理,processInputBuffer主要包含两个方法,processInlineBuffer和processCommand。processInlineBuffer方法用于采用redis协议解析客户端内容并生成对应的命令并传给processCommand方法,processCommand方法则用于执行该命令

3、processCommand方法会以下操作:

• 处理是否为quit命令。

• 对命令语法及参数会进行检查。

• 这里如果采取认证也会检查认证信息。

• 如果Redis为集群模式,这里将进行hash计算key所属slot并进行转向操作。

• 如果设置最大内存,那么检查内存是否超过限制,如果超过限制会根据相应的内存策略删除符合条件的键来释放内存。

• 如果这是一个主服务器,并且这个服务器之前执行bgsave发生了错误,那么不执行命令。

• 如果min-slaves-to-write开启,如果没有足够多的从服务器将不会执行命令;注:所以DBA在此的设置非常重要,建议不是特殊场景不要设置。

• 如果这个服务器是一个只读从库的话,拒绝写入命令。

• 在订阅于发布模式的上下文中,只能执行订阅和退订相关的命令。

• 当这个服务器是从库,master_link down并且slave-serve-stale-data为no只允许info和slaveof命令。

• 如果服务器正在载入数据到数据库,那么只执行带有REDIS_CMD_LOADING标识的命令。

• lua脚本超时,只允许执行限定的操作,比如shutdown、script kill等。

4、最后进入call方法。

call方法会调用setCommand,因为这里我们执行的set zbdba jingbo, set命令对应setCommand方法,redis服务器端在开始初始化的时候就会初始化命令表,命令表如下:

        struct redisCommand redisCommandTable[] = {
            {"get", getCommand,2, "r",0, NULL,1,1,1,0,0},
            {"set", setCommand, -3, "wm",0, NULL,1,1,1,0,0},
            {"setnx", setnxCommand,3, "wm",0, NULL,1,1,1,0,0},
            {"setex", setexCommand,4, "wm",0, NULL,1,1,1,0,0},
            {"psetex", psetexCommand,4, "wm",0, NULL,1,1,1,0,0},
            {"append", appendCommand,3, "wm",0, NULL,1,1,1,0,0},
            {"strlen", strlenCommand,2, "r",0, NULL,1,1,1,0,0},
            {"del", delCommand, -2, "w",0, NULL,1, -1,1,0,0},
            {"exists", existsCommand,2, "r",0, NULL,1,1,1,0,0},
            {"setbit", setbitCommand,4, "wm",0, NULL,1,1,1,0,0},
            ....
        }

所以如果是其他的命令会调用其他相对应的方法。call方法还会做一些事件,比如发送命令到从库、发送命令到aof、计算命令执行的时间。

5、setCommand方法,setCommand方法会调用setGenericCommand方法,该方法首先会判断该key是否已经过期,最后调用setKey方法。

这里需要说明一点的是,通过以上的分析。redis的key过期包括主动检测以及被动监测。

主动监测:

• 在beforeSleep方法中执行key快速过期检查,检查模式为ACTIVE_EXPIRE_CYCLE_FAST。周期为每个事件执行完成时间到下一次事件循环开始。

• 在serverCron方法中执行key过期检查,这是key过期检查主要的地方,检查模式为ACTIVE_EXPIRE_CYCLE_SLOW, serverCron方法执行周期为1秒钟执行server.hz次,hz默认为10,所以约100ms执行一次。hz设置越大过期键删除就越精准,但是cpu使用率会越高,这里我们线上redis采用的默认值。redis主要是在这个方法里删除大部分的过期键。

被动监测

• 使用内存超过最大内存被迫根据相应的内存策略删除符合条件的key。

• 在key写入之前进行被动检查,检查key是否过期,过期就进行删除。

• 还有一种不友好的方式,就是randomkey命令,该命令随机从redis获取键,每次获取到键的时候会检查该键是否过期。

以上主要是让运维的同学更加清楚redis的key过期删除机制。

6、进入setKey方法,setKey方法最终会调用dbAdd方法,其实最终就是将该键值对存入服务器端维护的一个字典中,该字典是在服务器初始化的时候创建,用于存储服务器的相关信息,其中包括各种数据类型的键值存储。完成了写入方法时候,此时服务器端会给客户端返回结果。

7、进入prepareClientToWrite方法然后通过调用_addReplyToBuffer方法将返回结果写入到outbuf中(客户端连接时创建的client)

8、通过aeCreateFileEvent方法注册文件写事件并绑定sendReplyToClient方法

五、Server返回写入结果

此时按照惯例,aeMain主函数循环,监测到新注册的事件,调用sendReplyToClient方法。sendReplyToClient方法主要包含两个操作:

1、将outbuf内容写入到套接字描述符并传输到客户端,主要方法如下:

      nwritten = write(fd, c->buf+c->sentlen, c->bufpos-c->sentlen);

2、aeDeleteFileEvent用于删除文件写事件

六、Client收到返回结果

客户端接收到服务器端的返回调用redisBufferRead方法,该方法主要用于从socket中读取数据。主要方法如下:

        nread = read(c->fd, buf, sizeof(buf));

并且将读取的数据交由redisReaderFeed方法,该方法主要用于将数据交给回复解析器处理,也就是cliFormatReplyRaw,该方法将回复内容格式化。最终通过

        fwrite(out, sdslen(out),1, stdout);

方法返回给客户端并打印展示给用户。

至此整个写入流程完成。以上还有很多细节没有说到,感兴趣的朋友可以自行阅读源码。

结语

在深入了解一个DB的时候,我的第一步就是去理解它执行一条命令执行的整个流程,这样就能对它整个运行流程较为熟悉,接着我们可以去深入各个细节的部分,比如Redis的相关数据结构、持久化以及高可用相关的东西。写这篇文章的初衷就是希望我们更加轻松的走好这第一步。这里还需要提醒的是,在我们进行Redis源码阅读的时候最关键的是需要灵活的使用GDB调试工具,它能帮我们更好的去理顺相关执行步骤,从而让我们更加容易理解其实现原理。

附录:两个相关重要知识点

1、Linux Socket建立流程

Linux socket建立过程如上图所示。在Linux编程时,无论是操作文件还是网络操作时都是通过文件描述符来进行读写的,但是他们有一点区别,这里我们不具体讨论,我们将网络操作时就称为套接字描述符。大家可以自行用c写一个简单的demo,这里就不详细说明了。

这里列出几个重要的方法:

        int socket(int family, int type, int protocol);
        int connect(int sockfd, const  struct sockaddr  * servaddr, socklen_taddrlen);
        int bind(int sockfd, const struct sockaddr * myaddr, socklen_taddrlen);
        int listen(int sockfd, int backlog);
        int accept(int sockfd, struct sockaddr *cliaddr, socklen_t * addrlen);

Redis client/server也是基于linux socket连接进行交互,并且最终调用以上方法绑定IP,监听端口最终与客户端建立连接。

2、epoll I/O多路复用技术

这里重点介绍一下epoll,因为Redis事件管理器核心实现基本依赖于它。首先来看epoll是什么,它能做什么?

epoll是在Linux 2.6内核中引进的,是一种强大的I/O多路复用技术,上面我们已经说到在进行网络操作的时候是通过文件描述符来进行读写的,那么平常我们就是一个进程操作一个文件描述符。然而epoll可以通过一个文件描述符管理多个文件描述符,并且不阻塞I/O。这使得我们单进程可以操作多个文件描述符,这就是redis在高并发性能还如此强大的原因之一。

下面简单介绍epoll主要的三个方法:

1. int epoll_create(int size) //创建一个epoll句柄用于监听文件描述符FD, size用于告诉内核这个监听的数目一共有多大。该epoll句柄创建后在操作系统层面只会占用一个fd值,但是它可以监听size+1个文件描述符。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)//epoll事件注册函数。

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)//等待事件的产生。

Redis的事件管理器主要是基于epoll机制,先采用epoll_ctl方法注册事件,然后再使用epoll_wait方法取出已经注册的事件。

我们知道redis支持多种平台,那么redis在这方面是如何兼容其他平台的呢?Redis会根据操作系统的类型选择对应的IO多路复用实现。

        #ifdef HAVE_EVPORT
        #include "ae_evport.c"
        #else
            #ifdef HAVE_EPOLL
            #include "ae_epoll.c"
            #else
                #ifdef HAVE_KQUEUE
                #include "ae_kqueue.c"
                #else
                #include "ae_select.c"
                #endif
            #endif
        #endif
        ae_evport.c sun solaris
        ae_poll.c linux
        ae_select.c unix/linux epoll是select的加强版
        ae_kqueue BSD/Apple

以上只是简单的介绍,大家需要详细了解了epoll机制才能更好的理解后面的东西。