查看原文
其他

经典面试题:Redis为什么这么快?

张张 架构精进之路
2024-08-31


Redis有多快

根据官方基准测试,在具有平均硬件的Linux机器上运行的单个Redis实例通常可以为简单命令(O(N)或O(log(N)))实现8w+的QPS,使用流水线批处理可以达到100w。
从性能角度来看,Redis可以称为高性能的缓存解决方案。



Redis为什么这么快

面试时经常被问到Redis高性能的原因,典型回答是下面这些:

  1. C语言实现,虽然C语言有助于Redis的性能,但语言并不是核心因素。

  2. 基于内存实现:仅内存I/O,相对于其他基于磁盘的数据库(MySQL等),Redis具有纯内存操作的自然性能优势。

  3. I/O复用模型,基于epoll/select/kqueue等I/O多路复用技术实现高吞吐量网络I/O。

  4. 单线程模型,单线程无法充分利用多核,但另一方面,它避免了多线程的频繁上下文切换以及锁等同步机制的开销。



为什么Redis选择单线程?


上面回答了是单线程的,接着会问为啥采用单线程模型。
Redis的CPU通常不会成为性能瓶颈,因为通常情况下Redis要么受到内存限制,要么受到网络限制。例如,使用流水线技术,在平均Linux系统上运行的Redis甚至可以每秒处理100万个请求,因此,如果应用程序主要使用O(N)O(log(N))命令,它几乎不会使用太多CPU。
这基本上意味着CPU通常不是数据库的瓶颈,因为大多数请求不会占用太多CPU资源,而是占用I/O资源。特别是对于Redis来说,如果不考虑像RDB/AOF这样的持久性方案,Redis是完全的内存操作,非常快速。Redis的真正性能瓶颈是网络I/O,即客户端和服务器之间的网络传输延迟,因此Redis选择了单线程的I/O多路复用来实现其核心网络模型。
「实际上选择单线程的更具体原因可以总结如下:」
  1. 避免过多的上下文切换开销:在多线程调度过程中,需要在CPU之间切换线程上下文,并且上下文切换涉及一系列寄存器替换、程序堆栈重置,甚至包括程序计数器、堆栈指针和程序状态字等快速表项的退休。因为单个进程内的多个线程共享进程地址空间,线程上下文要比进程上下文小得多,在跨进程调度的情况下,需要切换整个进程地址空间。
  2. 避免同步机制的开销:如果Redis选择多线程模型,因为Redis是一个数据库,不可避免地涉及底层数据同步问题,这必然会引入一些同步机制,如锁。我们知道Redis不仅提供简单的键值数据结构,还提供列表、集合、哈希等丰富的数据结构。不同的数据结构对于同步访问的锁定具有不同的粒度,这可能会在数据操作期间引入大量的锁定和解锁开销,增加了程序的复杂性并降低了性能。
  3. 简单和可维护性:Redis的作者Salvatore Sanfilippo(化名antirez)在Redis的设计和代码中有一种近乎偏执的简单哲学,当您阅读Redis源代码或向Redis提交PR时,您可以感受到这种偏执。因此,简单且可维护的代码必然是Redis在早期的核心准则之一,引入多线程不可避免地导致了代码复杂性的增加和可维护性的降低。



Redis真的是单线程的吗?

在回答这个问题之前,我们需要澄清“单线程”概念的范围:它是否涵盖了核心网络模型或整个Redis?如果是前者,答案是肯定的。Redis的网络模型在v6.0之前一直是单线程的;如果是后者,答案是不。Redis早在v4.0版本中就引入了多线程。
  • Redis v4.0(引入多线程进行异步任务)
  • Redis v6.0(正式在网络模型中实现I/O多线程)


单线程网络模型

从Redis v1.0到v6.0,Redis的核心网络模型一直是典型的单Reactor模型:使用epoll/select/kqueue等多路复用技术来处理事件(客户端请求)在单线程事件循环中,最后将响应数据写回客户端。
redis-io模型
在这里有几个核心概念需要了解。
  • 客户端:客户端对象,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命令,当连接触发写就绪事件时,将剩余的写缓冲区中的数据写回客户端。
Redis内部实现了一个高性能事件库AE,基于epoll/select/kqueue/evport,用于为Linux/MacOS/FreeBSD/Solaris实现高性能事件循环模型。Redis的核心网络模型正式构建在AE之上,包括I/O多路复用和各种处理器绑定的注册,所有这些都是基于它实现的。

redis-io多路复用
到这里,我们可以描述一个客户端从Redis请求命令的工作方式。
  1. Redis服务器启动,打开主线程事件循环,将acceptTcpHandler连接响应处理器注册到用户配置的监听端口的文件描述符上,等待新连接的到来。
  2. 客户端与服务器之间建立网络连接。
  3. 调用acceptTcpHandler,主线程使用AE的API将readQueryFromClient命令读取处理器绑定到新连接的文件描述符上,并初始化一个client以绑定此客户端连接。
  4. 客户端发送请求命令,触发读就绪事件,主线程调用readQueryFromClient将客户端通过套接字发送的命令读入客户端->querybuf读缓冲区。
  5. 接下来调用processInputBuffer,在其中使用processInlineBufferprocessMultibulkBuffer来根据Redis协议解析命令,最后调用processCommand来执行命令。
  6. 根据请求命令的类型(SET、GET、DEL、EXEC等),分配适当的命令执行器来执行,最后调用addReply系列函数中的一系列函数将响应数据写入到相应客户端的写缓冲区中:client->buf或client->reply,client->buf是首选的写出缓冲区,具有固定大小的16KB,通常可以缓冲足够的响应数据,但如果客户端在时间窗口内需要非常大的响应,则它将自动切换到client->reply链接列表,理论上可以容纳无限数量的数据(受机器物理内存限制)最后,将client添加到LIFO队列clients_pending_write
  7. 在事件循环中,主线程执行beforeSleep -> handleClientsWithPendingWrites,遍历clients_pending_write队列,并调用writeToClient将客户端写缓冲区中的数据返回给客户端,如果写缓冲区中仍然有剩余数据,则注册sendReplyToClient命令到连接的回复处理器,等待客户端写入后继续在事件循环中写回剩余的响应数据。
对于那些希望利用多核性能的人来说,官方的Redis解决方案简单而直接:在同一台机器上运行更多的Redis实例。事实上,为了保证高可用性,一个在线业务不太可能以独立运行的方式存在。更常见的是使用Redis分布式集群,具有多个节点和数据分片,以提高性能和确保高可用性。


多线程异步任务

如前所述,Redis在v4.0版本中引入了多线程来执行一些异步操作,主要用于非常耗时的命令。通过将这些命令的执行设置为异步,可以避免阻塞单线程事件循环。
我们知道Redis的DEL命令用于删除一个或多个键的存储值,它是一个阻塞命令。在大多数情况下,要删除的键不会存储太多值,最多几十个或几百个对象,因此可以快速执行。但如果要删除具有数百万个对象的非常大的键值对,则此命令可能会阻塞至少几秒钟,由于事件循环是单线程的,它会阻塞随后的其他事件,从而降低吞吐量。
Redis的作者antirez对解决这个问题进行了深思熟虑。起初,他提出了一个渐进式的解决方案:使用定时器和数据游标,他将逐步删除少量数据,例如1000个对象,最终清除所有数据。但这个解决方案存在一个致命的缺陷:如果其他客户端继续写入正在逐步删除的键,而且删除速度跟不上写入的数据,那么内存将无休止地被消耗,这个问题通过一个巧妙的解决方案得以解决,但这个实现使Redis更加复杂。多线程似乎是一个牢不可破的解决方案:简单且容易理解。因此,最终,antirez选择引入多线程来执行这类非阻塞命令。antirez在他的博客中更多地思考了这个问题:懒惰的Redis是更好的Redis。
因此,在Redis v4.0之后,已添加了一些非阻塞命令,如UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC等,它们会在后台线程中执行,不会阻塞主线程事件循环。这使得Redis可以更好地应对一些特定情况下的命令处理。
多线程异步任务的主要特点:
  1. 后台线程:这些异步任务由一个或多个后台线程负责执行,不影响主线程的事件循环,因此主线程可以继续处理其他请求。
  2. 非阻塞:异步任务是非阻塞的,因此它们不会阻止其他命令的执行,即使它们可能需要很长时间才能完成。
  3. 高可用性:通过将某些耗时操作转移到后台线程,Redis可以更好地保持高可用性。
总结 Redis的网络模型是单线程的,这意味着它使用单个事件循环来处理所有客户端请求。这个设计的优点是简单性和可维护性,但需要谨慎处理一些可能导致事件循环阻塞的命令。
为了处理一些非常耗时的命令,Redis v4.0引入了多线程异步任务。这些异步任务在后台线程中执行,不会阻塞主线程的事件循环,从而提高了Redis的吞吐量和可用性。
总而言之,Redis的单线程事件循环和多线程异步任务的设计是为了在性能和简单性之间取得平衡,以满足各种不同用例的需求。理解Redis的这些基本原理对于使用Redis进行高性能数据存储和缓存非常重要。


多线程网络模型

正如前面提到的,Redis最初选择了单线程的网络模型,原因是CPU通常不是性能瓶颈,瓶颈往往是内存和网络,因此单线程足够了。那么为什么Redis现在引入了多线程呢?简单的事实是Redis的网络I/O瓶颈变得越来越明显。
随着互联网的快速增长,互联网业务系统处理越来越多的在线流量,而Redis的单线程模式导致系统在网络I/O上消耗了大量CPU时间,从而降低了吞吐量。提高Redis性能有两种方式:
  1. 优化网络I/O模块
  2. 提高机器内存读写速度
后者依赖于硬件的发展,目前尚无法解决。因此,我们只能从前者入手,网络I/O的优化可以分为两个方向:
  1. 零拷贝技术或DPDK技术
  2. 利用多核
零拷贝技术存在局限性,无法完全适应像Redis这样的复杂网络I/O场景。DPDK技术通过绕过内核栈来绕过NIC I/O,过于复杂,需要内核甚至硬件的支持。
因此,充分利用多个核心是优化网络I/O最具成本效益的方式。
在6.0版本之后,Redis正式将多线程引入核心网络模型中,也称为I/O线程,现在Redis具有真正的多线程模型。在前面的部分中,我们了解了Redis 6.0之前的单线程事件循环模型,实际上是一个非常经典的反应器模型。
反应器模式在Linux平台上的大多数主流高性能网络库/框架中都有应用,比如netty、libevent、libuv、POE(Perl)、Twisted(Python)等。
反应器模式实际上是指使用I/O多路复用(I/O multiplexing)+非阻塞I/O(non-blocking I/O)模式。
Redis的核心网络模型,直到6.0版本,都是单一的反应器模型:所有事件都在单一线程中处理,尽管在4.0版本中引入了多线程,但更多是用于特定场景的补丁(删除超大键值等),不能被视为核心网络模型的多线程。
一般来说,单一反应器模型,在引入多线程后,会演变为多反应器模型,具有以下基本工作模型。
与单一线程事件循环不同,这种模式有多个线程(子反应器),每个线程维护一个独立的事件循环,主反应器接收新连接并将其分发给子反应器进行独立处理,而子反应器则将响应写回客户端。
多反应器模式通常可以等同于Master-Workers模式,比如NginxMemcached使用这种多线程模型,尽管项目之间的实现细节略有不同,但总体模式基本一致。


Redis多线程网络模型设计

Redis也实现了多线程,但不是标准的多反应器/主工作模式。让我们先看一下Redis多线程网络模型的一般设计。
  1. Redis服务器启动,打开主线程事件循环,将acceptTcpHandler连接答复处理器注册到与用户配置的监听端口对应的文件描述符,并等待新连接的到来。
  2. 客户端与服务器之间建立网络连接。
  3. 调用acceptTcpHandler,主线程使用AE的API将readQueryFromClient命令读取处理器绑定到与新连接对应的文件描述符上,并初始化一个客户端以绑定这个客户端连接。
  4. 客户端发送一个请求命令,触发一个读就绪事件。但不是通过套接字读取客户端的请求命令,而是服务器的主线程首先将客户端放入LIFO队列clients_pending_read中。
  5. 在事件循环中,主线程执行beforeSleep –> handleClientsWithPendingReadsUsingThreads,使用轮询的负载均衡策略将clients_pending_read队列中的连接均匀地分配给I/O线程。I/O线程通过套接字读取客户端的请求命令,将其存储在client->querybuf中并解析第一个命令,但不执行它,同时主线程忙于轮询并等待所有I/O线程完成读取任务。
  6. 当主线程和所有I/O线程都完成读取时,主线程结束忙碌的轮询,遍历clients_pending_read队列,执行所有已连接客户端的请求命令,首先调用processCommandResetClient来执行已解析的第一个命令,然后调用processInputBuffer来解析和执行客户端连接的所有命令,使用processInlineBufferprocessMultibulkBuffer根据Redis协议解析命令,最后调用processCommand来执行命令。
  7. 根据所请求命令的类型(SET、GET、DEL、EXEC等),分配相应的命令执行器来执行它,最后调用addReply系列函数中的一系列函数将响应数据写入相应的客户端写出缓冲区:client->bufclient->replyclient->buf是首选的写出缓冲区,大小固定为16KB,通常足够缓冲足够的响应数据,但如果客户端需要在时间窗口内响应大量数据,则会自动切换到client->reply链表,理论上可以容纳无限量的数据(受到机器物理内存的限制),最后将客户端添加到LIFO队列clients_pending_write中。
  8. 在事件循环中,主线程执行beforeSleep –> handleClientsWithPendingWritesUsingThreads,使用轮询的负载均衡策略将clients_pending_write队列中的连接均匀地分配给I/O线程和主线程本身。I/O线程通过调用writeToClient将客户端写缓冲区中的数据写回客户端,而主线程则忙于轮询,等待所有I/O线程完成写入任务。
  9. 当主线程和所有I/O线程都完成写入时,主线程结束忙碌的轮询,遍历clients_pending_write队列。如果客户端写缓冲区中还有数据,它将注册sendReplyToClient以等待连接的写准备就绪事件,并等待客户端写入,然后继续在事件循环中写回剩余的响应数据。
大部分逻辑与之前的单线程模型相同,唯一的改变是将读取客户端请求和写回响应数据的逻辑异步化到I/O线程中。这里需要特别注意的是,I/O线程只负责读取和解析客户端命令,实际的命令执行最终是在主线程上完成的。


总结

当面试官再问Redis为啥这么快时别傻傻再回答Redis是单线程了,否则只能回去等通知了。
Redis的多线程网络模型通过将读取和写回数据的任务异步化,以及更好地利用多核CPU,从而提高了Redis在处理大量在线流量时的性能表现。
1.「多线程设计」
  • Redis多线程模型包括一个主线程(Main Reactor)和多个I/O线程(Sub Reactors)。
  • 主线程负责接受新的连接,并将其分发到I/O线程进行独立处理。
  • I/O线程负责读取客户端的请求命令,但不执行它们。
  • 主线程负责执行客户端的请求命令,包括解析和执行。
  • 响应数据由I/O线程写回客户端。
2.「异步读写」:Redis的多线程模型异步化了读取客户端请求和写回响应数据的过程。客户端请求首先被放入待读取队列,然后由I/O线程读取。执行命令仍然在主线程上进行,但这种异步化提高了系统的并发性和吞吐量。

·END·

相关阅读:




参考文章:https://juejin.cn/post/7275608678827884563

版权申明:内容来源网络,仅供学习研究,版权归原创者所有。如有侵权烦请告知,我们会立即删除并表示歉意。谢谢!


专注架构技术研究,一起跨越职业瓶颈!

关注公众号,免费领学习资料



如果您觉得还不错,欢迎关注和转发~     

继续滑动看下一个
架构精进之路
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存