Redis整体架构

Redis简介

简介概览

作者:

antirez.png

Redis 由意大利人 Salvatore Sanfilippo(网名 Antirez) 开发,上图是他的个人照片。Antirez 不仅帅的不像实力派,也非常有趣。Antirez 今年已经四十岁了,依旧在孜孜不倦地写代码,为 Redis 的开源事业持续贡献力量。

Redis是一个开放源代码(BSD许可)内存中的数据结构存储,用作数据库、缓存和消息代理。它支持字符串、哈希、列表、集合、带范围查询的排序集合、位图、超日志和流的地理空间索引等数据结构Redis基本数据结构&内部编码 。Redis具有内置的复制、lua脚本、lru回收、事务和不同级别的磁盘上持久性,并通过Redis Sentinel和Redis集群的自动分区提供高可用性。

源码github:https://github.com/antirez/redis

Redis为什么这么快?

正常情况下,Redis执行命令的速度非常快,官方给出的数字是读写性能可以达到10万/秒,当然这也取决于机器的性能,但这里先不讨论机器性能上的差异,只分析一下是什么造就了Redis除此之快的速度,可以大致归纳为以下五点:

  1. Redis是用C语言实现的,一般来说C语言实现的程序“距离”操作系统更近,执行速度相对会更快。
  2. 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些时间复杂度为 O(n) 级别的指令,一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。
  3. 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
  4. 使用多路I/O复用模型,非阻塞IO,Redis 单线程处理大量的并发客户端连接的模型。 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;
  5. 作者对于Redis源代码可以说是精打细磨,曾经有人评价Redis是少有的集性能和优雅于一身的开源代码。
  6. Redis为什么单线程?为什么Redis单线程

Redis压力测试

Redis 自带了一个压力测试工具redis-benchmark,使用这个工具就可以进行管道测试。

首先我们对一个普通的 set 指令进行压测,QPS 大约 5w/s。

1
2
> redis-benchmark -t set -q
SET: 51975.05 requests per second

我们加入管道选项-P参数,它表示单个管道内并行的请求数量,看下面P=2,QPS 达到了 9w/s。

1
2
> redis-benchmark -t set -P 2 -q
SET: 91240.88 requests per second

再看看P=3,QPS 达到了 10w/s。

1
SET: 102354.15 requests per second

但如果再继续提升 P 参数,发现 QPS 已经上不去了。这是为什么呢?

因为这里 CPU 处理能力已经达到了瓶颈,Redis 的单线程 CPU 已经飙到了 100%,所以无法再继续提升了。

整体架构

20191215157640117532062.png

2019121515764011904795.png

源码导读

redis-cli.c

客户端入口方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, char **argv) {
/* 省略各种模式...*/
if (argc == 0 && !config.eval) {
/* 发起服务端链接*/
cliConnect(0);
/* 发送命令,接收响应主入口 */
repl();
}
}
/* 监听客户端输入 */
linenoise(context ? config.prompt : "not connected> "))

/* 处理命令 resp协议转换 写入redisContext的buf */
redisAppendCommandArgv(context,argc,(const char**)argv,argvlen);

/* 获取响应 */

if (cliReadReply(output_raw) != REDIS_OK) {

hiredis.c

实现了redis-cli重要接口的C库

1
2
3
4
5
redisContext *redisConnect(const char *ip, int port);
int redisGetReply(redisContext *c, void **reply);
void *redisCommand(redisContext *c, const char *format, ...);
int redisBufferRead(redisContext *c);
int redisBufferWrite(redisContext *c, int *done);

net.c

通信相关

1
2
3
4
5
// 建立socket连接,并等待服务端响应
if ((s = socket(p->ai_family,p->ai_socktype,p->ai_protocol)) == -1)

// 写入命令 获取响应
int redisGetReply(redisContext *c, void **reply)

server.c

服务端程序

1
2
3
4
5
6
/*入口*/
int main(int argc, char **argv)
/* 初始化服务器,包括打开监听端口,创建epoll_create(),创建文件事件(连接应答处理器) */
initServer();
/* 时间循环入口 实现主要分享两个 一个select() ae.c 一个为ae_epoll.c */
aeMain(server.el);

ae.c

事件驱动程序库

1
2
3
4
5
6
7
8
aeEventLoop *aeCreateEventLoop(int setsize);
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
aeFileProc *proc, void *clientData);
long long aeCreateTimeEvent(aeEventLoop *eventLoop, long long milliseconds,
aeTimeProc *proc, void *clientData,
aeEventFinalizerProc *finalizerProc);
int aeProcessEvents(aeEventLoop *eventLoop, int flags);
void aeMain(aeEventLoop *eventLoop);

networking.c

服务端应答处理器等程序库

1
2
3
4
client *createClient(int fd)
static void acceptCommonHandler(int fd, int flags, char *ip)
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask)
void sendReplyToClient(aeEventLoop *el, int fd, void *privdata, int mask)

Redis事件

文件事件

Redis通过套接字与客户端(或者其他服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列的网络通信操作。

Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器则被称为文件时间处理器。

文件事件处理器使用I/O多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的时间处理器。

当被监听的套接字准备好执行连接应答accept、读取read、写入write、关闭close等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

20191215157640121938938.png

时间事件

Redis服务器中一些操作(比如serverCron的函数)则需要在给定的时间点执行,时间事件就是服务器对这类定时操作的抽象。默认一秒执行10次,即100ms执行一次。配置在redis.conf中的hz

serverCron函数的主要工作:

  1. 更新服务器的各类统计信息,比如内存占用、数据库占用情况等。
  2. 清理数据库中的过期键值对。
  3. 关闭和清理链接失效的客户端。
  4. 尝试进行AOF或RDB持久化操作。
  5. 如果服务器是主服务器,对从服务器进行定时同步。
  6. 集群模式,对集群进行定期同步和连接测试。

2019121515764012422191.png

处理器

20191215157640126623986.png

  1. 连接应答处理器

    服务端启动时,创建文件事件绑定此处理器

  2. 命令请求处理器

    获取到客户端连接后创建文件事件绑定此处理器

  3. 命令回复处理器

    处理完命令后,创建文件事件绑定此处理器

  4. 服务时间处理器

    服务器启动时,创建时间处理器,绑定此处理器

服务端客户端通信

通信流程图

2019121515764012876577.png

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

2019121515764037271410.png

Client 发起socket 连接

20191215157640374787621.png

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

1
2
/src/redis-cli -p 6379 -h 127.0.0.1
127.0.0.1:6379>

客户端会做如下操作:

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

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

目前共有:

Latency mode/Slave mode/Get RDB mode/Pipe mode/Find

big keys/Stat mode/Scan mode/Intrinsic latency mode

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

3、进入上图中step1的cliConnect 方法,cliConnect主要包含redisConnect、redisConnectUnix方法。这两个方法分别用于TCP Socket连接以及Unix Socket连接,Unix Socket用于同一主机进程间的通信。我们上面是采用的TCP Socket连接方式也就是我们平常生产环境常用的方式

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

1
2
3
4
5
6
7
8
9
/* 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地址和端口用于建立连接,主要方法如下:

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

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

Server 接受socket连接

20191215157640381925004.png

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

  1. 执行一次快速的主动过期检查,检查是否有过期的keyRedis内存管理策略
  1. 将AOF缓冲区的内容写入到AOF文件中Redis持久化-missfresh
  1. ……

1、打开TCP监听端口

listenToPort(server.port,server.ipfd,&server.ipfd_count)

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

aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,acceptTcpHandler,NULL)

3、触发连接

用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方法。

客户端 开始写入

20191215157640387098685.png

客户端写入流程分为以下几步:

1、linenoise等待接受用户输入

2、linenoise 将用户输入内容传入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));

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

debug:https://draveness.me/redis-cli

resp:https://juejin.im/book/5afc2e5f6fb9a07a9b362527/section/5afc39496fb9a07ab458d0f1

server 端接收写入

20191215157640389156735.png

服务器端依然在进行事件循环,在客户端发来内容的时候触发,对应的文件读取事件。这就是之前创建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、进入call方法。

call方法会调用setCommand,因为这里我们执行的set missfresh miss,set 命令对应setCommand 方法

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

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

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

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

server 返回写入结果

2019121515764039118031.png

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

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

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

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

Client收到返回结果

20191215157640392823409.png

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

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

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

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

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

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

epoll I/O 多路复用技术

epoll详解参照资料:

  1. 详解:http://blog.chinaunix.net/uid-24517549-id-4051156.html
  1. 实现机制:https://blog.csdn.net/shenya1314/article/details/73691088
  1. 多种线程模型比对:IO模型解惑

介绍一下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 个文件描述符。
  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) //epoll事件注册函数,在创建文件事件的时候进行调用注册
  1. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)//等待事件的产生

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

1
2


lemon wechat
欢迎大家关注我的订阅号 SeeMoonUp
写的不错?鼓励一下?不差钱?