通过一次通信来看Redis服务端与客户端

如果想看通信过程,可略过第一个大标题~

一、服务端启动过程(初次启动或重启动):

1.1 首先创建一个redisServer实例变量作为服务器状态;

/* Global vars */
struct redisServer server; /* Server global state */
复制代码

1.2 然后调用server.c中的initServerConfig方法,用默认值设置redisServer实例变量;

//初始化unix时钟(包括lru时钟)
pthread_mutex_init(&server.lruclock_mutex,NULL);
pthread_mutex_init(&server.unixtime_mutex,NULL);
//设置服务器运行id(集群时用做服务器唯一标识)
getRandomHexChars(server.runid,CONFIG_RUN_ID_SIZE);
//设置默认服务器端口号(#define CONFIG_DEFAULT_SERVER_PORT:6379  /* TCP port. */)
server.port = CONFIG_DEFAULT_SERVER_PORT;
//设置服务器的运行架构:32bit或64bit
server.arch_bits = (sizeof(long) == 8) ? 64 : 32;
//设置默认AOF状态为关闭
server.aof_state = AOF_OFF;
//是否每次都同步AOF缓冲至硬盘
server.aof_fsync = CONFIG_DEFAULT_AOF_FSYNC;
//调用BGSVAE更新RDB文件的条件
appendServerSaveParams(60*60,1);  /* save after 1 hour and 1 change */
复制代码

1.3 载入用户配置选项,并覆盖原有的默认配置;

举个例子:我启动redis时不想用6379作为对外服务的端口,那么我在redis.conf文件中进行相应特殊化的修改,比如改为12345

# Accept connections on the specified port, default is 6379 (IANA #815344).
# If port 0 is specified Redis will not listen on a TCP socket.
port 12345
复制代码

1.4 初始化相应的数据结构(调用initServer函数):

(1)clients链表:用于记录连接到该服务端的客户端状态对象redisClient:

redisClient对象里面由哪些信息呢?比如说:客户端的名字,客户端套接字描述符,客户端通过TCP发来的RESP格式的请求数据,客户端发来请求的解析信息(比如这个请求是SET、RPUSH还是ZSET等等),请求的参数数量等;

服务器会为每一个连接的客户端新建一个 redisClient结构,用于保存客户端的相应状态;

客户端发来的请求会存放在querybuf输入缓冲区中,并通过一个SDS对象进行保存;

然后服务端根据RESP协议进行文本解析,解析出真实的命令并放在argv[]数组中,并将参数的数量记载在argc变量中:

PS:还有一些其他数据结构,但与CS通信无关

1.5 进行一些设置操作:

(1)为服务器设置进程信号处理器;

(2)初始化共享对象:如1~10000OK, ERR等常用的对象,从而避免反复创建相同的对象;

(3)初始化监听端口,并为需要监听的套接字关联相应的事件处理器(这个之后在通信时会详细讲); 并且会创建相应的定时事件,如定时调用serverCron函数,这个函数主要用于维护Redis服务器状态

(4)如果开启了AOF功能,打开相应的AOF文件,如果没有则创建;

(5)初始化服务器后台I/O模块(bio),为将来的I/O做好准备;

此时服务器便已经启动完成,并会打出如下的日志:

1.6 RDB或AOF的载入:

考虑到服务器可能是在宕机的情况下重启,那服务器需要通过RDB或AOF文件去还原原本的状态,并会打出相应的日志:

如果有AOF文件则使用AOF,否则使用RDB

1.7 启动事件循环:

Redis主要有两个事件循环,分为文件事件(监听相应的套接字)和事件事件(运行定时任务),并且是在一个单线程中按照时间优先的方式进行依次处理,此时Redis服务器便可以接收客户端的请求啦!

二、服务器端处理请求的过程:

2.1 客户端发送请求:

客户端将请求信息按照RESP文本协议写入套接字中:

比如我们想要发送一条如下命令:

127.0.0.1:6379> set ALI CVNot
复制代码

那么按照文本协议,它会被修改为

*3\r\n$3\r\n\set\r\n$3\r\nALI\r\n$5\r\nCVNot\r\n
复制代码

在网络上进行传输

2.2 服务端接收请求:

我们知道当客户端与服务端经过三次握手建立连接之后,客户端会将自己要发送的请求写入套接字中,并把套接字的文件描述符发送给服务端,服务端从中读取请求信息并将返回信息写入其中,最后再将其返回给客户端;

如果对I/O模型不太了解,可以参考我之前的文章:深入分析Java IO机制

Redis采用了多路复用的I/O模型(事件机制),从而实现了即使只有一个线程用于处理命令请求,也可以实现很高的效率;

2.2.1 建立连接:

Redis中是通过networking.c/acceptTcpHandler函数作为连接处理器来处理连接请求(即客户端调用了connect()函数想要与服务器端通过三次握手建立连接时):

void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
    //...
    cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
    acceptCommonHandler(cfd,0,cip);
    }
复制代码

主要调用了anetTcpAccept与acceptCommonHandler函数,前者用于建立连接,并如果连接成功则会返回一个套接字描述符;然后后者会为这个套接字描述符关联readQueryFromClient()函数作为命令请求处理器去处理建立完连接后客户端发来的请求命令(如:SET KEY VALUE等),其实这里与JAVA NIO的实现方式颇有相似之处,JAVA NIO也是在OP_CONNECT事件的处理程序中为该通道注册一个读事件;

static void acceptCommonHandler(int fd, int flags, char *ip) {
    client *c;
    //该方法为该套接字注册了一个命令请求处理器
    if ((c = createClient(fd)) == NULL) {
    }
    //判断连接上该客户端后;
    //redisServer结构体中的clients链表长度是否仍然满足服务端最大连接数的要求
    //不满足则拒绝连接
    if (listLength(server.clients) > server.maxclients) {
    }
复制代码
client *createClient(int fd) {
    client *c = zmalloc(sizeof(client));
    if (fd != -1) { //如果为-1表示为是一个伪客户端,即本地AOF客户端或LUA脚本客户端
        anetNonBlock(NULL,fd);//设置套接字为非阻塞
        anetEnableTcpNoDelay(NULL,fd);
        if (server.tcpkeepalive)//是否维持TCP连接
            anetKeepAlive(NULL,fd,server.tcpkeepalive);
        //重新为该套接字注册一个AE_READABLE事件,并关联一个命令请求处理器
        if (aeCreateFileEvent(server.el,fd,AE_READABLE,
            readQueryFromClient, c) == AE_ERR)
        {
            close(fd);
            zfree(c);
            return NULL;
        }
    }
}
复制代码

2.2.2 客户端发送命令:

(1)当一个客户端的请求信息已经被写到相应的被监听的套接字文件中之后,会产生一个AE_READABLE事件,然后会将该事件放入等待队列中,等待处理命令的线程处理完当前事件,文件事件分配器会为队头套接字分配文件事件,使用在上个阶段注册的命令请求处理器进行处理:

而这个处理的过程为:

首先我们需要将套接字文件中的RESP文本文件读入到输入缓冲区中,即每个客户端对应在clients链表上的redisClient结构中的querybuf缓冲区中,然后处理程序按照RESP协议还原出命令后,按照相应的指令,在命令表中查找命令所对应的命令实现函数,并将redisClient中的*cmd指针指向该函数

上图以 SET为例,这样 命令请求处理器便可以调用函数进行相应的处理;

处理完成后会将服务器处理完的回复保存在对应请求客户端的redisClient的输出缓冲区里面,缓冲区有定长缓冲区与可变缓冲区之分,前者即为一个固定大小的缓冲区,一般为16KB,用于存储一些OK, ERR等较短的字符串;较长的字符串通过redisClient的reply链表进行保存,并且可以自定义reply链表实现的可变缓冲区的阈值,并且大于该值该客户端就会被服务器关闭连接。

然后就是等待AE_WAITABLE事件,从而将缓存中的回复写到对应套接字中。

2.2.3 服务端返回结果:

而当一个套接字变得可写时(即客户端调用read()函数),会产生一个AE_WRITABLE事件,然后当获得线程处理时,会调用命令回复处理器,将服务器端的结果信息写入到该套接字中;如上述SET命令,则会返回一个OK

并且在执行完写命令后,如果开启了AOF的功能,那么Redis会以协议格式将被执行的写命令追加到服务器状态(redisServer)的aof_buf缓冲区的末尾,并且在每次结束一个事件循环前,会调用flushAppendOnlyFile函数,并根据不同的设置决定是否将缓冲中的内容写入和保存到AOF文件里面;

appendfsync选项 flushAppendOnlyFile的行为
always 将缓冲区中的所有内容写入并同步到AOF文件中
everysec 将缓冲区中的所有内容写入到AOF文件,如果上次同步AOF文件的事件距离现在超过1s,则会进行同步
no 将缓冲区中的所有内容写入到AOF文件,但不对AOF文件进行同步,何时同步由操作系统决定

看到这,我觉得很容易有个疑问,写入AOF文件为啥还要有写入与同步之分呢?是因为操作系统为了提高I/O的效率,使用了缓冲技术,即操作系统会将通过write()函数写入的数据先放入一个内存缓冲区中,然后等到指定的时限或者缓冲区被填满时再一次性写入;而是否同步,即是否调用fsyncfdatasync这两个函数在每次写入缓冲区后直接进行I/O写入到硬盘中。

2.2.4 客户端处理文本文件:

当客户端收到服务端的返回信息后会进行相应文本信息的处理,最后显示在客户端中:

三、 总结:

我觉得对于数据库操作,最需要记住的还是,我们与数据库的交互本质上是命令的交换。

用下面一张图,总结上面的所有阐述:

猜你喜欢

转载自juejin.im/post/5e65bd816fb9a07cb74be3e9