最近笔者阅读并研究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方法主要做以下几件事:
如此,redis整个事件管理器机制就比较清楚了。接下来进一步探讨并理解事件是如何触发并创建。
下面正式开始介绍redis client/server 交互的6大步骤
(点击放大图像)
这里以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选择,这里不着重讨论。
(点击放大图像)
服务器接收客户端的请求首先是从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连接已经建立,但是此时服务器端还继续做了2件事:
可以从图中得知,aeCreateFileEvent 调用aeApiAddEvent方法最终通过epoll_ctl 方法进行注册事件。
(点击放大图像)
客户端在与服务器端建立好socket连接之后,开始执行上图中step3的repl方法。从图中可知repl方法接受输入输出主要是采用linenoise插件。当然这是针对redis-cli客户端哦。linenoise 是一款优秀的命令行编辑库,被广泛的运用在各种DB上,如Redis、MongoDB,这里不详细讨论。客户端写入流程分为以下几步:
1、linenoise等待接受用户输入
2、linenoise 将用户输入内容传入cliSendCommand方法,cliSendCommand方法会判断命令是否为特殊命令,如:
客户端会根据以上命令设置对应的输出格式以及客户端的模式,因为这里我们是普通写入,所以不会涉及到以上的情况。
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));
此时客户端等待服务器端接收写入。
(点击放大图像)
服务器端依然在进行事件循环,在客户端发来内容的时候触发,对应的文件读取事件。这就是之前创建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方法会以下操作:
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过期包括主动检测以及被动监测
主动监测
被动监测
以上主要是让运维的同学更加清楚redis的key过期删除机制。
6、进入setKey方法,setKey方法最终会调用dbAdd方法,其实最终就是将该键值对存入服务器端维护的一个字典中,该字典是在服务器初始化的时候创建,用于存储服务器的相关信息,其中包括各种数据类型的键值存储。完成了写入方法时候,此时服务器端会给客户端返回结果。
7、进入prepareClientToWrite方法然后通过调用_addReplyToBuffer方法将返回结果写入到outbuf中(客户端连接时创建的client)
8、通过aeCreateFileEvent方法注册文件写事件并绑定sendReplyToClient方法
(点击放大图像)
此时按照惯例,aeMain主函数循环,监测到新注册的事件,调用sendReplyToClient方法。sendReplyToClient方法主要包含两个操作:
1、将outbuf内容写入到套接字描述符并传输到客户端,主要方法如下:
nwritten = write(fd,c->buf+c->sentlen,c->bufpos-c->sentlen);
2、aeDeleteFileEvent 用于删除 文件写事件
(点击放大图像)
客户端接收到服务器端的返回调用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 主要的三个方法:
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机制才能更好的理解后面的东西。
感谢木环对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们。
您需要注册一个InfoQ账号 或者才能进行评论。在您完成注册后还需要进行一些设置。