【AICon】探索八个行业创新案例,教你在教育、金融、医疗、法律等领域实践大模型技术! >>> 了解详情
写点什么

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

  • 2016-12-21
  • 本文字数:7863 字

    阅读完需:约 26 分钟

综述

最近笔者阅读并研究 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 )关注我们。

2016-12-21 16:4917422

评论

发布
暂无评论
发现更多内容

实时 摔倒识别 /运动分析/打架等异常行为识别/控制手势识别等所有行为识别全家桶 原理 + 代码 + 数据+ 模型 开源!

cv君

AI 目标检测 视频理解 引航计划

2021年ONNX开发者大会即将召开

百度大脑

百度飞桨 ONNX

手把手教学基于深度学习的遥感影像倾斜框算法训练与分析

cv君

人工智能 深度学习 AI 智能 视觉

Three.Js杂记(一)——起步

空城机

大前端 WebGL 3D可视化 three.js

Three.js杂记(三)—— 物体运动

空城机

JavaScript 大前端 WebGL 3D可视化 three.js

爬虫入门经典(四) | 如何爬取豆瓣电影Top250

不温卜火

python 爬虫

爬虫入门经典(十五) | 邪恶想法之爬取百度妹子图

不温卜火

python 爬虫

前置机器学习(一):数学符号及希腊字母

caiyongji

机器学习

python 爬虫之selenium可视化爬虫

诡途

Python 爬虫 selenium

首席AI架构师进阶之旅开启!第4期60位AICA学员硬核开学

百度大脑

AI 百度飞桨

C 语言性能优化:循环展开

1

编程 程序员 性能优化 C语言 循环展开

想当程序员,如何判断自己是否适合当前端程序员?

孙叫兽

程序员 大前端 引航计划

它终于来了!

Python研究所

Python

一气之下开发了个群聊机器人

诡途

Python 办公自动化 群聊机器人

【实战问题】-- 缓存穿透,缓存击穿和缓存雪崩的区别以及解决方案

秦怀杂货店

Java redis 缓存 架构 分布式

波卡生态DeFi系统开发方案

薇電13242772558

区块链 defi

初来乍到,请多关照

空城机

杂记

Three.js杂记(二)——绘制点、线、面

空城机

JavaScript 大前端 WebGL 3D可视化 three.js

如何巧妙的去除数组中的空格?

程序媛观澜

c++ 字符串

爬虫入门经典(十二) | 一文带你快速爬取豆瓣电影

不温卜火

python 爬虫

一文看懂特权访问管理(PAM)

龙归科技

云计算 云存储

飞桨中国行首站重庆 解读产业 智造

百度大脑

百度 飞桨 中关村智酷

数据分析实战项目-蛋壳公寓投诉分析

诡途

Python 数据分析 蛋壳公寓

飞桨刷新分子性质预测榜单,助力AI药物研发

百度大脑

AI 药物研发 百度飞桨

大前端工程师进阶之路,Node全栈为前端带来更多可能

孙叫兽

大前端 全栈 Node

爬虫入门经典(七) | 一文带你爬取淘宝电场

不温卜火

python 爬虫

2.4 Go语言从入门到精通:条件和循环

xcbeyond

3月日更 Go 语言

爬虫入门经典(十八) | 滑动验证码识别

不温卜火

python 爬虫

寻找被遗忘的勇气(二十二)

Changing Lin

3月日更

助力香港成为全球寿命最长的城市,我们如何看医管局的数字化转型?

有只小耳朵

数字化转型 人才培养

Git教程 - Git 命令与操作

码语者

git DevOps

深入浅出 Redis client/server交互流程_数据库_赵景波_InfoQ精选文章