type
status
date
slug
summary
tags
category
icon
password
Property
Redis
服务器是一个事件驱动程序,服务器需要处理以下两类时间事件:- 文件事件(file event):
Redis
服务器通过嵌套字与客户端进行连接,文件事件就是服务器对嵌套字操作的抽象。服务器与客户端的通信会产生响应文件事件,服务器通过监听并处理这些事件来完成一系列网络通信操作;
- 时间事件(time event):
Redis
服务器中的一些操作需要在给定事件点执行,而时间事件就是服务器对这类定时操作的抽象;
文件事件
Redis
基于Reactor
模式开发了自己的网络事件处理器, 这个处理器被称为文件事件处理器(file event handler):- 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
- 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行, 但通过使用
I/O
多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与Redis
服务器中其他同样以单线程方式运行的模块进行对接, 这保持了Redis
内部单线程设计的简单性。文件事件处理器的构成
文件事件处理器的四个组成部分:套接字、 I/O 多路复用程序、 文件事件分派器(dispatcher)、 以及事件处理器。
- 文件事件是对套接字操作的抽象, 每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时, 就会产生一个文件事件。 因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。
- I/O 多路复用程序负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。
尽管多个文件事件可能会并发地出现, 但 I/O 多路复用程序总是会将所有产生事件的套接字都入队到一个队列里面, 然后通过这个队列, 以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字:
- 文件事件分派器接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器。
- 服务器会为执行不同任务的套接字关联不同的事件处理器, 这些处理器是一个个函数, 它们定义了某个事件发生时, 服务器应该执行的动作。
I/O 多路复用程序的实现
Redis
的 I/O 多路复用程序的所有功能都是通过包装常见的select
、epoll
、evport
和kqueue
这些 I/O 多路复用函数库来实现的, 每个I/O多路复用函数库在Redis
源码中都对应一个单独的文件, 比如ae_select.c
、ae_epoll.c
、ae_kqueue.c
, 诸如此类。因为
Redis
为每个 I/O 多路复用函数库都实现了相同的 API , 所以 I/O 多路复用程序的底层实现是可以互换的:Redis
在I/O多路复用程序的实现源码中用#include
宏定义了相应的规则, 程序会在编译时自动选择系统中性能最高的 I/O
多路复用函数库来作为Redis
的I/O
多路复用程序的底层实现:事件的类型
I/O 多路复用程序可以监听多个套接字的
ae.h/AE_READABLE
事件和 ae.h/AE_WRITABLE
事件:- 当套接字变得可读时(客户端对套接字执行
write
操作,或者执行close
操作), 或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect
操作), 套接字产生AE_READABLE
事件。
- 当套接字变得可写时(客户端对套接字执行
read
操作), 套接字产生AE_WRITABLE
事件。
I/O 多路复用程序允许服务器同时监听套接字的
AE_READABLE
事件和 AE_WRITABLE
事件, 如果一个套接字同时产生了这两种事件, 那么文件事件分派器会优先处理 AE_READABLE
事件, 等到 AE_READABLE
事件处理完之后, 才处理 AE_WRITABLE
事件。这也就是说, 如果一个套接字又可读又可写的话, 那么服务器将先读套接字, 后写套接字。
API
ae.c/aeCreateFileEvent
函数接受一个套接字描述符、 一个事件类型、 以及一个事件处理器作为参数, 将给定套接字的给定事件加入到 I/O 多路复用程序的监听范围之内, 并对事件和事件处理器进行关联。
ae.c/aeDeleteFileEvent
函数接受一个套接字描述符和一个监听事件类型作为参数, 让 I/O 多路复用程序取消对给定套接字的给定事件的监听, 并取消事件和事件处理器之间的关联。
ae.c/aeGetFileEvents
函数接受一个套接字描述符, 返回该套接字正在被监听的事件类型:- 如果套接字没有任何事件被监听, 那么函数返回
AE_NONE
- 如果套接字的读事件正在被监听, 那么函数返回
AE_READABLE
- 如果套接字的写事件正在被监听, 那么函数返回
AE_WRITABLE
- 如果套接字的读事件和写事件正在被监听, 那么函数返回
AE_READABLE | AE_WRITABLE
ae.c/aeWait
函数接受一个套接字描述符、一个事件类型和一个毫秒数为参数, 在给定的时间内阻塞并等待套接字的给定类型事件产生, 当事件成功产生, 或者等待超时之后, 函数返回。
ae.c/aeApiPoll
函数接受一个sys/time.h/struct timeval
结构为参数, 并在指定的时间內, 阻塞并等待所有被aeCreateFileEvent
函数设置为监听状态的套接字产生文件事件, 当有至少一个事件产生, 或者等待超时后, 函数返回。
ae.c/aeProcessEvents
函数是文件事件分派器, 它先调用aeApiPoll
函数来等待事件产生, 然后遍历所有已产生的事件, 并调用相应的事件处理器来处理这些事件。
ae.c/aeGetApiName
函数返回 I/O 多路复用程序底层所使用的 I/O 多路复用函数库的名称: 返回"epoll"
表示底层为epoll
函数库, 返回"select"
表示底层为select
函数库, 诸如此类。
文件事件的处理器
Redis
为文件事件编写了多个处理器, 这些事件处理器分别用于实现不同的网络通讯需求, 比如说:- 为了对连接服务器的各个客户端进行应答, 服务器要为监听套接字关联连接应答处理器。
- 为了接收客户端传来的命令请求, 服务器要为客户端套接字关联命令请求处理器。
- 为了向客户端返回命令的执行结果, 服务器要为客户端套接字关联命令回复处理器。
- 当主服务器和从服务器进行复制操作时, 主从服务器都需要关联特别为复制功能编写的复制处理器。
- 等等。
在这些事件处理器里面, 服务器最常用的要数与客户端进行通信的连接应答处理器、 命令请求处理器和命令回复处理器。
连接应答处理器
networking.c/acceptTcpHandler
函数是Redis
的连接应答处理器, 这个处理器用于对连接服务器监听套接字的客户端进行应答, 具体实现为sys/socket.h/accept
函数的包装。当
Redis
服务器进行初始化的时候, 程序会将这个连接应答处理器和服务器监听套接字的 AE_READABLE
事件关联起来, 当有客户端用sys/socket.h/connect
函数连接服务器监听套接字的时候, 套接字就会产生 AE_READABLE
事件, 引发连接应答处理器执行, 并执行相应的套接字应答操:命令请求处理器
networking.c/readQueryFromClient
函数是Redis
的命令请求处理器, 这个处理器负责从套接字中读入客户端发送的命令请求内容, 具体实现为unistd.h/read
函数的包装。当一个客户端通过连接应答处理器成功连接到服务器之后, 服务器会将客户端套接字的
AE_READABLE
事件和命令请求处理器关联起来, 当客户端向服务器发送命令请求的时候, 套接字就会产生AE_READABLE
事件, 引发命令请求处理器执行, 并执行相应的套接字读入操作:在客户端连接服务器的整个过程中, 服务器都会一直为客户端套接字的
AE_READABLE
事件关联命令请求处理器。命令回复处理器
networking.c/sendReplyToClient
函数是Redis
的命令回复处理器, 这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端, 具体实现为 unistd.h/write
函数的包装。当服务器有命令回复需要传送给客户端的时候, 服务器会将客户端套接字的
AE_WRITABLE
事件和命令回复处理器关联起来, 当客户端准备好接收服务器传回的命令回复时, 就会产生 AE_WRITABLE
事件, 引发命令回复处理器执行, 并执行相应的套接字写入操作:当命令回复发送完毕之后, 服务器就会解除命令回复处理器与客户端套接字的
AE_WRITABLE
事件之间的关联。一次完整的客户端与服务器连接事件示例
假设一个
Redis
服务器正在运作, 那么这个服务器的监听套接字的 AE_READABLE
事件应该正处于监听状态之下, 而该事件所对应的处理器为连接应答处理器。如果这时有一个
Redis
客户端向服务器发起连接, 那么监听套接字将产生 AE_READABLE
事件, 触发连接应答处理器执行: 处理器会对客户端的连接请求进行应答, 然后创建客户端套接字, 以及客户端状态, 并将客户端套接字的 AE_READABLE
事件与命令请求处理器进行关联, 使得客户端可以向主服务器发送命令请求。之后, 假设客户端向主服务器发送一个命令请求, 那么客户端套接字将产生
AE_READABLE
事件, 引发命令请求处理器执行, 处理器读取客户端的命令内容, 然后传给相关程序去执行。执行命令将产生相应的命令回复, 为了将这些命令回复传送回客户端, 服务器会将客户端套接字的
AE_WRITABLE
事件与命令回复处理器进行关联: 当客户端尝试读取命令回复的时候, 客户端套接字将产生 AE_WRITABLE
事件, 触发命令回复处理器执行, 当命令回复处理器将命令回复全部写入到套接字之后, 服务器就会解除客户端套接字的 AE_WRITABLE
事件与命令回复处理器之间的关联。时间事件
时间事件包括两种:
- 定时事件:让程序在指定时间之后执行一次。比如在20秒之后执行一次。
- 周期性事件:让程序每个指定时间执行一次。比如每隔30秒执行一次。
一个时间事件的三个属性组成:
- id:唯一标识,新事件
id
大于旧事件id
- when:毫秒精度 UNIX 时间戳,记录时间事件到达(arrive)时间
- timeProc:时间事件处理器,一个函数,当时间到了的时候,就执行该函数
如何区分定时事件和周期性事件呢?
依靠事件处理器的返回值区分是定时事件还是周期性事件,若返回值为
ae.h/AE_NOMORE
(默认是-1),那么这个事件就是定时事件,若不是这个值,则说明这个事件是周期性事件,且返回值是周期执行的时间(毫秒精度),例如:20毫秒之后需要再次执行,返回值就是20。如果一个事件是周期性事件,在事件处理器处理完之后,会对时间事件的when属性进行更新,当时间再次来到之后,再次重复,然后完成周期性执行。
时间事件的实现
Redis
中使用一个链表存放时间事件,新添加的时间事件在头部。当Redis
中的时间事件触发的时候,会遍历整个列表,找到所有已到达时间的事件,然后调用对应的处理器处理。这个列表并不是按照when排序的,所以寻找已经到达时间的时间需要遍历整个链表。API
ae.c/aeCreateTimeEvent
:创建时间事件,这个时间事件将在当前时间的milliseconds
毫秒后到达,事件处理器为proc
ae.c/aeDeleteFileEvent
:根据 id 从服务器中删除对应的时间事件
ae.c/aeSearchNearestTimer
:返回到达时间距离当前时间最接近的那个时间事件
ae.c/processTimeEvents
:时间事件执行器,遍历所有时间事件,并调用处理器处理已到达的时间事件。实际不存在,处理时间事件实际由ae.c/aeProcessEvents
函数负责
serverCron 函数
Redis
服务器需要定期对自身资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这个操作由 redis.c/serverCron
函数执行,其主要工作包括:- 更新服务器的各类信息,如时间、内存占用、数据库占用情况
- 清理数据库中的过期键值对
- 关闭和清理连接失败的客户端
- 尝试进行 AOF 或 RDB 持久化操作
- 如果服务器是主服务器,需要对服务器进行定期同步
- 如果服务器是集群模式,对集群进行定期同步和连接测试
事件的调度与执行
为什么需要事件调度?
因为
Reids
中有两种事件:时间事件和文件事件,所以需要在时间事件和文件事件之间进行调度和切换。Redis
服务器对文件事件与时间事件的调度由 ae.c/aeProcessEvents
函数负责:ae.c/aeProcessEvents
函数的处理逻辑:- 获取到达时间离当前时间最接近的时间事件;
- 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms
;
- 事件到达则将
remaind_ms
设为0;
- 根据
remaind_ms
的值创建timeval
结构;
- 阻塞并等待文件事件产生,最大阻塞事件由传入的
timeval
结构决定;
- 若
remaind_ms
的值为0,那么aeApiPoll
调用后马上返回,不阻塞;
- 处理所有已产生的文件事件;
- 处理所有已到达的时间事件;
事件处理角度下的服务器运行流程:
服务器启动之后,会以循环的方式一直在运行,直到服务关闭,循环中大概处理以下内容:
遍历时间事件链表,获取到距离现在最近的时间事件,并获取指定的运行时间戳,计算阻塞时间。如果已经超过了指定的运行时间,标记阻塞时间为0,生成时间事件。
若阻塞时间为0,则不等待文件事件产生,直接执行时间事件,然后继续下一次循环。
若阻塞时间>0,则阻塞时间内等待文件事件产生(循环)。
若产生了文件事件,则先对文件事件进行处理,期间不会打断文件事件的处理。文件事件处理完成之后,再次计算阻塞时间,若阻塞时间 > 0,则继续循环,若 <= 0,则跳出循环。
执行时间事件,然后继续下一次循环。
事件的调度和执行规则:
aeApiPoll
函数的最大阻塞时间由到达时间最接近当前时间的事件事件决定
- 文件事件和时间事件的处理都是同步、有序、原子地执行,服务器不会中途中断事件处理,也不会对事件进行抢占
- 时间事件会将非常耗时的持久化操作放到子线程或子进程执行
- 时间事件在文件事件之后执行,并且事件间不会出现抢占,所以时间事件的实际处理时间通常比时间事件的到达时间稍晚一些
Redis 的单进程单线程误区
Redis
并不完全是单进程单线程的,在进行 RBD 持久化,执行 BGSAVE 命令时,会创建一个子进程在后台进行备份;Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是常说 Redis 是单线程的原因。
之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:
- Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
- Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
- Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
I/O
多路复用程序总是将所有产生事件的套接字放到一个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字;Redis
的I/O
多路复用程序通过包装常见的底层I/O
多用复用函数库实现,程序在编译时自动选择系统中性能最高的I/O
多路复用函数库作为底层实现;Redis 在启动的时候,是会启动后台线程(BIO)的:
- Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
- Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,应该使用 unlink 命令来异步删除大key。
之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
- BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
- BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
- BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;
Redis 单线程模式是怎样的?
Redis 6.0 版本之前的单线模式如下图:
图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情:
- 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 创建一个服务端 socket
- 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket;
- 然后,将调用 epoll_ctl() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。
初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:
- 首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
- 接着,调用 epoll_wait 函数等待事件的到来:
- 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;
- 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
- 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
Redis 6.0 之前为什么使用单线程?
官方回答:CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。
除了上面的官方回答,选择单线程的原因也有下面的考虑。
使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
Redis 6.0 之后引入了多线程
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
同时,
Redis.conf
配置文件中提供了 IO 多线程个数的配置项。关于线程数的设置,官方的建议是如果为4核的CPU,建议线程数设置为2或3,如果为8核CPU建议线程数设置为6,线程数一定要小于机器核数,线程数并不是越大越好。
因此,
Redis 6.0
版本之后,Redis
在启动的时候,默认情况下会额外创建 6 个线程(这里的线程数不包括主线程):Redis-server
: Redis的主线程,主要负责执行命令;
bio_close_file
、bio_aof_fsync
、bio_lazy_free
:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
io_thd_1
、io_thd_2
、io_thd_3
:三个 I/O 线程,io-threads
默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担Redis
网络 I/O 的压力。