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

阅读数:12624 2016 年 12 月 21 日 16:49

综述

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

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

一、Client 发起 socket 连接

二、Server 接受 socket 连接

三、客户端 开始写入

四、server 端接收写入

五、server 返回写入结果

六、Client 收到返回结果

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

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

交互的整体流程

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

(点击放大图像)

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

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

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

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

  1. 执行一次快速的主动过期检查,检查是否有过期的 key
  2. 当有客户端阻塞时,向所有从库发送 ACK 请求
  3. unblock 在同步复制时候被阻塞的客户端
  4. 尝试执行之前被阻塞客户端的命令
  5. 将 AOF 缓冲区的内容写入到 AOF 文件中
  6. 如果是集群,将会根据需要执行故障迁移、更新节点状态、保存 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.1
127.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 连接建立流程就比较清楚了,如下:

    1. 服务器初始化建立 socket 监听
    2. 服务器初始化创建相关连接应答处理器, 通过 epoll_ctl 注册事件
    3. 客户端初始化创建 socket connect 请求
    4. 服务器接受到请求,用 epoll_wait 方法取出事件
    5. 服务器执行事件中的方法 (acceptTcpHandler/acceptUnixHandler) 并接受 socket 连接

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

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

参考

  1. http://redis.io/
  2. https://github.com/antirez/redis
  3. http://www.tenouk.com/Module39a.html

感谢木环对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

收藏

评论

微博

用户头像
发表评论

注册/登录 InfoQ 发表评论