面试回答 Redis 是单线程的所以很快,让我回去等通知....
共 9722字,需浏览 20分钟
·
2024-06-19 08:00
阅读本文大概需要 12 分钟。
来自:juejin.cn/post/7275608678827884563
Redis有多快
(O(N)
或O(log(N)))
实现8w+的QPS,使用流水线批处理可以达到100w
。
Redis为什么这么快
-
C语言实现,虽然C语言有助于Redis的性能,但语言并不是核心因素。
-
基于内存实现:仅内存I/O,相对于其他基于磁盘的数据库(MySQL等),Redis具有纯内存操作的自然性能优势。
-
I/O复用模型,基于
epoll/select/kqueue
等I/O多路复用技术实现高吞吐量网络I/O。 -
单线程模型,单线程无法充分利用多核,但另一方面,它避免了多线程的频繁上下文切换以及锁等同步机制的开销。
为什么Redis选择单线程?
O(N)
或O(log(N))
命令,它几乎不会使用太多CPU。
-
避免过多的上下文切换开销:在多线程调度过程中,需要在CPU之间切换线程上下文,并且上下文切换涉及一系列寄存器替换、程序堆栈重置,甚至包括程序计数器、堆栈指针和程序状态字等快速表项的退休。因为单个进程内的多个线程共享进程地址空间,线程上下文要比进程上下文小得多,在跨进程调度的情况下,需要切换整个进程地址空间。
-
避免同步机制的开销:如果Redis选择多线程模型,因为Redis是一个数据库,不可避免地涉及底层数据同步问题,这必然会引入一些同步机制,如锁。我们知道Redis不仅提供简单的键值数据结构,还提供列表、集合、哈希等丰富的数据结构。不同的数据结构对于同步访问的锁定具有不同的粒度,这可能会在数据操作期间引入大量的锁定和解锁开销,增加了程序的复杂性并降低了性能。
-
简单和可维护性:Redis的作者
Salvatore Sanfilippo
(化名antirez
)在Redis的设计和代码中有一种近乎偏执的简单哲学,当您阅读Redis源代码或向Redis提交PR时,您可以感受到这种偏执。因此,简单且可维护的代码必然是Redis在早期的核心准则之一,引入多线程不可避免地导致了代码复杂性的增加和可维护性的降低。
Redis真的是单线程的吗?
-
Redis v4.0
(引入多线程进行异步任务) -
Redis v6.0
(正式在网络模型中实现I/O多线程)
单线程网络模型
epoll/select/kqueue
等多路复用技术来处理事件(客户端请求)在单线程事件循环中,最后将响应数据写回客户端。
-
客户端:客户端对象,Redis是典型的CS架构(客户端<-->服务器),客户端通过套接字与服务器建立网络通道,然后发送请求的命令,服务器执行请求的命令并回复。Redis使用client结构来存储与客户端相关的所有信息,包括但不限于包装套接字连接
-- *conn
,当前选择的数据库指针--*db
,读缓冲区--querybuf
,写缓冲区--buf
,写数据链接列表--reply
等。 -
aeApiPoll
:I/O多路复用API,基于epoll_wait/select/kevent等系统调用封装,监听读写事件以触发,然后进行处理,这是事件循环(Event Loop)中的核心函数,是事件驱动器运行的基础。 -
acceptTcpHandler
:连接响应处理器,底层使用系统调用accept接受来自客户端的新连接,并将新连接注册绑定命令读取处理器以进行后续的新客户端TCP连接处理;除了此处理器外,还有相应的acceptUnixHandler用于处理Unix域套接字和acceptTLSHandler用于处理TLS加密连接。 -
readQueryFromClient
:命令读取处理器,用于解析并执行客户端请求的命令。 -
beforeSleep
:在事件循环进入aeApiPoll并等待事件到达之前执行的函数。它包含一些常规任务,如将来自client->buf或client->reply的响应写回客户端、将AOF缓冲区中的数据持久化到磁盘等。还有一个afterSleep函数,在aeApiPoll之后执行。 -
sendReplyToClient
:命令回复处理器,当事件循环后仍然在写缓冲区中有数据时,将注册并绑定到相应连接的sendReplyToClient
命令,当连接触发写就绪事件时,将剩余的写缓冲区中的数据写回客户端。
epoll/select/kqueue/evport
,用于为Linux/MacOS/FreeBSD/Solaris
实现高性能事件循环模型。Redis的核心网络模型正式构建在AE之上,包括I/O多路复用和各种处理器绑定的注册,所有这些都是基于它实现的。
-
Redis服务器启动,打开主线程事件循环,将
acceptTcpHandler
连接响应处理器注册到用户配置的监听端口的文件描述符上,等待新连接的到来。 -
客户端与服务器之间建立网络连接。
-
调用
acceptTcpHandler
,主线程使用AE的API将readQueryFromClient
命令读取处理器绑定到新连接的文件描述符上,并初始化一个client
以绑定此客户端连接。 -
客户端发送请求命令,触发读就绪事件,主线程调用
readQueryFromClient
将客户端通过套接字发送的命令读入客户端->querybuf
读缓冲区。 -
接下来调用
processInputBuffer
,在其中使用processInlineBuffer
或processMultibulkBuffer
来根据Redis协议解析命令,最后调用processCommand来执行命令。 -
根据请求命令的类型(SET、GET、DEL、EXEC等),分配适当的命令执行器来执行,最后调用
addReply
系列函数中的一系列函数将响应数据写入到相应客户端的写缓冲区中:client->buf或client->reply,client->buf是首选的写出缓冲区,具有固定大小的16KB,通常可以缓冲足够的响应数据,但如果客户端在时间窗口内需要非常大的响应,则它将自动切换到client->reply链接列表,理论上可以容纳无限数量的数据(受机器物理内存限制)最后,将client添加到LIFO队列clients_pending_write
。 -
在事件循环中,主线程执行
beforeSleep
->handleClientsWithPendingWrites
,遍历clients_pending_write
队列,并调用writeToClient
将客户端写缓冲区中的数据返回给客户端,如果写缓冲区中仍然有剩余数据,则注册sendReplyToClient
命令到连接的回复处理器,等待客户端写入后继续在事件循环中写回剩余的响应数据。
多线程异步任务
antirez
对解决这个问题进行了深思熟虑。起初,他提出了一个渐进式的解决方案:使用定时器和数据游标,他将逐步删除少量数据,例如1000个对象,最终清除所有数据。但这个解决方案存在一个致命的缺陷:如果其他客户端继续写入正在逐步删除的键,而且删除速度跟不上写入的数据,那么内存将无休止地被消耗,这个问题通过一个巧妙的解决方案得以解决,但这个实现使Redis更加复杂。多线程似乎是一个牢不可破的解决方案:简单且容易理解。因此,最终,antirez选择引入多线程来执行这类非阻塞命令。antirez
在他的博客中更多地思考了这个问题:懒惰的Redis是更好的Redis。
UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC
等,它们会在后台线程中执行,不会阻塞主线程事件循环。这使得Redis可以更好地应对一些特定情况下的命令处理。
-
后台线程:这些异步任务由一个或多个后台线程负责执行,不影响主线程的事件循环,因此主线程可以继续处理其他请求。
-
非阻塞:异步任务是非阻塞的,因此它们不会阻止其他命令的执行,即使它们可能需要很长时间才能完成。
-
高可用性:通过将某些耗时操作转移到后台线程,Redis可以更好地保持高可用性。
多线程网络模型
-
优化网络I/O模块
-
提高机器内存读写速度
-
零拷贝技术或DPDK技术
-
利用多核
netty、libevent、libuv、POE(Perl)、Twisted(Python)
等。
I/O multiplexing
)+非阻塞I/O(non-blocking I/O
)模式。
Master-Workers
模式,比如Nginx
和Memcached
使用这种多线程模型,尽管项目之间的实现细节略有不同,但总体模式基本一致。
Redis多线程网络模型设计
-
Redis服务器启动,打开主线程事件循环,将
acceptTcpHandler
连接答复处理器注册到与用户配置的监听端口对应的文件描述符,并等待新连接的到来。 -
客户端与服务器之间建立网络连接。
-
调用
acceptTcpHandler
,主线程使用AE的API将readQueryFromClient
命令读取处理器绑定到与新连接对应的文件描述符上,并初始化一个客户端以绑定这个客户端连接。 -
客户端发送一个请求命令,触发一个读就绪事件。但不是通过套接字读取客户端的请求命令,而是服务器的主线程首先将客户端放入LIFO队列
clients_pending_read
中。 -
在事件循环中,主线程执行beforeSleep –>
handleClientsWithPendingReadsUsingThreads
,使用轮询的负载均衡策略将clients_pending_read队列中的连接均匀地分配给I/O线程。I/O线程通过套接字读取客户端的请求命令,将其存储在client->querybuf中并解析第一个命令,但不执行它,同时主线程忙于轮询并等待所有I/O线程完成读取任务。 -
当主线程和所有I/O线程都完成读取时,主线程结束忙碌的轮询,遍历
clients_pending_read
队列,执行所有已连接客户端的请求命令,首先调用processCommandResetClient来执行已解析的第一个命令,然后调用processInputBuffer
来解析和执行客户端连接的所有命令,使用processInlineBuffer
或processMultibulkBuffer
根据Redis协议解析命令,最后调用processCommand
来执行命令。 -
根据所请求命令的类型(
SET、GET、DEL、EXEC
等),分配相应的命令执行器来执行它,最后调用addReply系列函数中的一系列函数将响应数据写入相应的客户端写出缓冲区:client->buf
或client->reply
,client->buf
是首选的写出缓冲区,大小固定为16KB,通常足够缓冲足够的响应数据,但如果客户端需要在时间窗口内响应大量数据,则会自动切换到client->reply链表,理论上可以容纳无限量的数据(受到机器物理内存的限制),最后将客户端添加到LIFO队列clients_pending_write
中。 -
在事件循环中,主线程执行
beforeSleep –> handleClientsWithPendingWritesUsingThreads
,使用轮询的负载均衡策略将clients_pending_write
队列中的连接均匀地分配给I/O线程和主线程本身。I/O线程通过调用writeToClient
将客户端写缓冲区中的数据写回客户端,而主线程则忙于轮询,等待所有I/O线程完成写入任务。 -
当主线程和所有I/O线程都完成写入时,主线程结束忙碌的轮询,遍历
clients_pending_write
队列。如果客户端写缓冲区中还有数据,它将注册sendReplyToClient
以等待连接的写准备就绪事件,并等待客户端写入,然后继续在事件循环中写回剩余的响应数据。
总结
-
Redis多线程模型包括一个主线程(Main Reactor)和多个I/O线程(Sub Reactors)。
-
主线程负责接受新的连接,并将其分发到I/O线程进行独立处理。
-
I/O线程负责读取客户端的请求命令,但不执行它们。
-
主线程负责执行客户端的请求命令,包括解析和执行。
-
响应数据由I/O线程写回客户端。
推荐阅读:
熟悉 Redis 吗,那 Redis 的过期键删除策略是什么?
《云服务器限时免费领取》 轻量级服务器-2核4G5M 1个月 云服务器-2核4G3M 1个月 ⬇戳阅读原文领取 朕已阅