Redis全面解析一:redis是单线程结构为何还可以支持高并发

前言

redis设计成单线程结构考虑:

从redis的性能上进行考虑,单线程避免了上下文频繁切换问题,效率高;
从redis的内部结构设计原理进行考虑,redis是基于Reactor模式开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器

理解redis单线程

Redis客户端对服务端的每次调用都经历了发送命令,执行命令,返回结果三个过程。其中执行命令阶段,由于Redis是单线程来处理命令的,所有到达服务端的命令都不会立刻执行,所有的命令都会进入一个队列中,然后逐个执行,并且多个客户端发送的命令的执行顺序是不确定的,但是可以确定的是不会有两条命令被同时执行,不会产生并发问题,这就是Redis的单线程基本模型。

如果想深入理解单线程模型,先得了解redis服务器和客户端建立连接以及读取数据机制。

redis服务器事件机制

Redis 采用事件驱动机制来处理大量的网络IO。它并没有使用 libevent 或者 libev 这样的成熟开源方案,而是自己实现一个非常简洁的事件驱动库 ae_event。

Redis中的事件驱动库只关注网络IO,以及定时器。该事件库处理下面两类事件:

  • 文件事件(file  event):用于处理 Redis 服务器和客户端之间的网络IO。Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接以及读写,而文件事件就是服务器对套接字操作的抽象。
  • 时间事件(time  eveat):Redis 服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是处理这类定时操作的。

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

(1)文件事件:

Redis基于Reactor模式开发了自己的网络事件处理器,被称为文件事件处理器(file event handler):

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

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

文件事件处理器的构成:文件事件处理器的四个组成部分,分别时套接字、I/O多路复用程序、文件事件分派器(dispatcher),以及事件处理器。

注意:其中I/O多路复用程序与文件事件派发器 还有一层socket层,通过队列向文件事件分派器传送socket

(2)I/O多路复用:

 Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,(前面已经解释过为什么使用单线程,但是单线程相对多线程这种致命的缺陷:单线程效率低,同时存在io阻塞问题,其他请求将不得不等待,怎么解决呢I/O多路复用就是为了解决这个问题而出现的

I/O多路复用:“多路”指的是多个网络连接,“复用”指的是复用同一个线程。解决I/O传输层单线程阻塞问题。

①采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),当多个连接没有读写数据时,当前线程阻塞,当有一个或多个流有 I/O事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),将流的处理过程依次放入队列中,放入队列后I/O多路复用程序此次任务完成,并不关心任务是否执行,可进行下一次事件放入队列的操作。通过此种方式解决了I/O访问层单线程阻塞问题。

② Redis在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈。所以将事件依次放入队列中逐步执行的操作耗时可以忽略。

 I/O多路复用程序负责监听多个套接字,并向文件事件派发器传递那些产生了事件的套接字。尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生的套接字都放到同一个队列里边,然后文件事件处理器会以有序、同步、单个套接字的方式处理该队列中的套接字,也就是处理就绪的文件事件。

Redis 具有很高的吞吐量保证:

(1) 网络IO都是通过Socket实现,Server在某一个端口持续监听,客户端通过Socket(IP+Port)与服务器建立连接(ServerSocket.accept),成功建立连接之后,就可以使用Socket中封装的InputStream和OutputStream进行IO交互了。针对每个客户端,Server都会创建一个新线程专门用于处理。

(2) 默认情况下,网络IO是阻塞模式,即服务器线程在数据到来之前处于【阻塞】状态,等到数据到达,会自动唤醒服务器线程,着手进行处理。I/O多路复用程序负责监听多个套接字。

(3) 为了提升服务器线程处理效率,有以下三种思路

      a.非阻塞[忙轮询]:采用死循环方式轮询每一个流,如果有IO事件就处理,这样一个线程可以处理多个流,但效率不高,容易导致CPU空转。

      b.Select代理(无差别轮询):可以观察多个流的IO事件,如果所有流都没有IO事件,则将线程进入阻塞状态,如果有一个或多个发生了IO事件,则唤醒线程去处理。但是会遍历所有的流,找出流需要处理的流。如果流个数为N,则时间复杂度为O(N)

      c.Epoll代理:Select代理有一个缺点,线程在被唤醒后轮询所有的Stream,会存在无效操作。Epoll哪个流发生了I/O事件会通知处理线程,对流的操作都是有意义的,复杂度降低到了O(1)。

Redis 使用的IO多路复用技术主要有:

selectepollevportkqueue等。每个IO多路复用函数库在 Redis 源码中都对应一个单独的文件,比如ae_select.c,ae_epoll.c, ae_kqueue.c等。Redis 会根据不同的操作系统,按照不同的优先级选择多路复用技术。事件响应框架一般都采用该架构,比如 netty 和 libevent。

clipboard.png

文件事件处理器过程

①客户端与redis进行通信大致流程:

å¨è¿éæå¥å¾çæè¿°

  • 首先在redis启动初始化的时候,redis会先将事件处理器中的连接应答处理器和AE_READABLE事件关联起来;
  • 如果客户端向redis发起连接,会产生AE_READABLE事件(步骤A),产生该事件后会被IO多路复用程序监听到(步骤B),然后IO多路复用程序会把监听到的socket信息放入到队列中(步骤C),事件分配器每次从队列中取出一个socket(步骤D),然后事件分派器把socket给对应的事件处理器(步骤E)。由于连接应答处理器和AE_READABLE事件在redis初始化的时候已经关联起来,所以由连接应答处理器来处理跟客户端建立连接,然后通过ServerSocket创建一个与客户端一对一对应的socket,如叫socket01,同时将这个socket01的AE_READABLE事件和命令请求处理器关联起来。

②客户端向redis发生请求时(读、写操作)

å¨è¿éæå¥å¾çæè¿°

  • 首先就会在对应的socket如socket01上会产生AE_READABLE事件(步骤A),产生该事件后会被IO多路复用程序监听到(步骤B),然后IO多路复用程序会把监听到的socket信息放入到队列中(步骤C),事件分配器每次从队列中取出一个socket(步骤D),然后事件分派器把socket给对应的事件处理器(步骤E)。由于命令处理器和socket01的AE_READABLE事件关联起来了,然后对应的命令请求处理器来处理。
  • 这个命令请求处理器会从事件分配器传递过来的socket01上读取相关的数据,如何执行相应的读写处理。操作执行完之后,redis就会将准备好相应的响应数据(如你在redis客户端输入 set a 123回车时会看到响应ok),并将socket01的AE_WRITABLE事件和命令回复处理器关联起来。

③当客户端会查询redis是否完成相应的操作

å¨è¿éæå¥å¾çæè¿°

  • 当客户端会查询redis是否完成相应的操作,就会在socket01上产生一个AE_WRITABLE事件,会由对应的命令回复处理器来处理,就是将准备好的相应数据写入socket01(由于socket连接是双向的),返回给客户端,如读操作,客户端会显示ok。
  • 如果命令回复处理器执行完成后,就会删除这个socket01的AE_WRITABLE事件和命令回复处理器的关联。

注:这样客户端就和redis进行了一次通信。由于连接应答处理器执行一次就够了,如果客户端在次进行操作就会由命令请求处理器来处理,反复执行。

扩展

(1)Redis数据库结构

redis底层实现实际上也是大量的对象、函数。只不过是用C而不是用Java。
redis的数据库就是一个对象redisDb,redis服务器对象redisServer内部会持有一个redisDb数组,初始的时候数组大小为16,即redis数据库最开始有16个,客户端存放的数据就在这16个中的一个。
我们知道redis是以键值对存储数据的,实际上redisDb内部保存了一字典dict,字典又保存了客户端的多个键值对,这个字典又被称为键空间。

读到这里你应该明白了,所有客户端存储在redis的数据都在redisServer对象的redisDb对象数组的某一个元素里面的dict下面
redisDb还有一个expires属性,这个属性也是一个字典,用与保存对象的过期时间,怎么进行数据增删改查?了解hashmap的你一定知道,直接操作dict键空间就可以了。

(2)Redis的垃圾回收机制

Redis回收过期对象的策略:定期删除+惰性删除

问题1、什么是定期删除?

答:定期删除就是每隔一定时间就进行一次删除,但与其他定期删除不同,redis定期删除并不会删除所有数据库中的所有过期对象。redis会检查某一些数据库(redisDb数组中的一些)中的某一些键,如果过期就删除,redis还会保存已经检查到了第几个数据库了,下次直接在该数据库开始检查。redis默认情况下每隔100ms执行一次定期删除,默认扫描16个数据库,每隔库检查20个键。

问题2、什么是惰性删除?

答:当客户端调用读写数据库的命令的时候,redis会判断这些命令涉及到的键是否过期,如果过期就删除。


常见疑问

通过上述的原理以及结构表述,我们再来重新回顾下问题

1.redis为什么不采用多线程处理而使用单线程处理机制?

  • 多线程处理可能涉及到锁
  • 多线程处理会涉及到线程切换而消耗CPU
  • redis基于内存,瓶颈在于物理内存,不在于执行效率

2.redis 单线程模型为什么效率这么高

  • 纯内存操作
  • 核心是基于非阻塞的 IO 多路复用机制解决单线程阻塞问题
  • 单线程反而避免了多线程的频繁上下文切换问题
  • redis底层使用了一些特殊的数据结构如跳跃表等,通过这些数据结构的优化可以让对象更快的存入内存

3.Redis不存在线程安全问题

Redis采用了线程封闭的方式,把任务封闭在一个线程,自然避免了线程安全问题,不过对于需要依赖多个redis操作(即:多个Redis操作命令)的复合操作来说,依然需要锁,而且有可能是分布式锁。

注:redis单线程机制无法发挥多核CPU性能,不过可以通过在单机开多个Redis实例来完善

 

文章参考:

https://baijiahao.baidu.com/s?id=1644978229039981414&wfr=spider&for=pc

https://segmentfault.com/a/1190000020014518

https://blog.csdn.net/qq_38601777/article/details/91325622

https://www.cnblogs.com/myseries/p/11733861.html

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章